You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

587 lines
18 KiB

6 years ago
6 years ago
6 years ago
6 years ago
6 years ago
  1. import os
  2. from django.conf import settings
  3. from django.template.loader import render_to_string
  4. from django.utils import timezone
  5. import json
  6. import requests
  7. from urllib.parse import quote, urlencode
  8. from hc.accounts.models import Profile
  9. from hc.lib import emails
  10. from hc.lib.string import replace
  11. try:
  12. import apprise
  13. except ImportError:
  14. # Enforce
  15. settings.APPRISE_ENABLED = False
  16. def tmpl(template_name, **ctx):
  17. template_path = "integrations/%s" % template_name
  18. # \xa0 is non-breaking space. It causes SMS messages to use UCS2 encoding
  19. # and cost twice the money.
  20. return render_to_string(template_path, ctx).strip().replace("\xa0", " ")
  21. class Transport(object):
  22. def __init__(self, channel):
  23. self.channel = channel
  24. def notify(self, check):
  25. """ Send notification about current status of the check.
  26. This method returns None on success, and error message
  27. on error.
  28. """
  29. raise NotImplementedError()
  30. def is_noop(self, check):
  31. """ Return True if transport will ignore check's current status.
  32. This method is overriden in Webhook subclass where the user can
  33. configure webhook urls for "up" and "down" events, and both are
  34. optional.
  35. """
  36. return False
  37. def checks(self):
  38. return self.channel.project.check_set.order_by("created")
  39. class Email(Transport):
  40. def notify(self, check, bounce_url):
  41. if not self.channel.email_verified:
  42. return "Email not verified"
  43. unsub_link = self.channel.get_unsub_link()
  44. headers = {
  45. "X-Bounce-Url": bounce_url,
  46. "List-Unsubscribe": "<%s>" % unsub_link,
  47. "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
  48. }
  49. try:
  50. # Look up the sorting preference for this email address
  51. p = Profile.objects.get(user__email=self.channel.email_value)
  52. sort = p.sort
  53. except Profile.DoesNotExist:
  54. # Default sort order is by check's creation time
  55. sort = "created"
  56. # list() executes the query, to avoid DB access while
  57. # rendering a template
  58. ctx = {
  59. "check": check,
  60. "checks": list(self.checks()),
  61. "sort": sort,
  62. "now": timezone.now(),
  63. "unsub_link": unsub_link,
  64. }
  65. emails.alert(self.channel.email_value, ctx, headers)
  66. def is_noop(self, check):
  67. if not self.channel.email_verified:
  68. return True
  69. if check.status == "down":
  70. return not self.channel.email_notify_down
  71. else:
  72. return not self.channel.email_notify_up
  73. class Shell(Transport):
  74. def prepare(self, template, check):
  75. """ Replace placeholders with actual values. """
  76. ctx = {
  77. "$CODE": str(check.code),
  78. "$STATUS": check.status,
  79. "$NOW": timezone.now().replace(microsecond=0).isoformat(),
  80. "$NAME": check.name,
  81. "$TAGS": check.tags,
  82. }
  83. for i, tag in enumerate(check.tags_list()):
  84. ctx["$TAG%d" % (i + 1)] = tag
  85. return replace(template, ctx)
  86. def is_noop(self, check):
  87. if check.status == "down" and not self.channel.cmd_down:
  88. return True
  89. if check.status == "up" and not self.channel.cmd_up:
  90. return True
  91. return False
  92. def notify(self, check):
  93. if not settings.SHELL_ENABLED:
  94. return "Shell commands are not enabled"
  95. if check.status == "up":
  96. cmd = self.channel.cmd_up
  97. elif check.status == "down":
  98. cmd = self.channel.cmd_down
  99. cmd = self.prepare(cmd, check)
  100. code = os.system(cmd)
  101. if code != 0:
  102. return "Command returned exit code %d" % code
  103. class HttpTransport(Transport):
  104. @classmethod
  105. def get_error(cls, response):
  106. # Override in subclasses: look for a specific error message in the
  107. # response and return it.
  108. return None
  109. @classmethod
  110. def _request(cls, method, url, **kwargs):
  111. try:
  112. options = dict(kwargs)
  113. options["timeout"] = 5
  114. if "headers" not in options:
  115. options["headers"] = {}
  116. if "User-Agent" not in options["headers"]:
  117. options["headers"]["User-Agent"] = "healthchecks.io"
  118. r = requests.request(method, url, **options)
  119. if r.status_code not in (200, 201, 202, 204):
  120. m = cls.get_error(r)
  121. if m:
  122. return f'Received status code {r.status_code} with a message: "{m}"'
  123. return f"Received status code {r.status_code}"
  124. except requests.exceptions.Timeout:
  125. # Well, we tried
  126. return "Connection timed out"
  127. except requests.exceptions.ConnectionError:
  128. return "Connection failed"
  129. @classmethod
  130. def get(cls, url, **kwargs):
  131. # Make 3 attempts--
  132. for x in range(0, 3):
  133. error = cls._request("get", url, **kwargs)
  134. if error is None:
  135. break
  136. return error
  137. @classmethod
  138. def post(cls, url, **kwargs):
  139. # Make 3 attempts--
  140. for x in range(0, 3):
  141. error = cls._request("post", url, **kwargs)
  142. if error is None:
  143. break
  144. return error
  145. @classmethod
  146. def put(cls, url, **kwargs):
  147. # Make 3 attempts--
  148. for x in range(0, 3):
  149. error = cls._request("put", url, **kwargs)
  150. if error is None:
  151. break
  152. return error
  153. class Webhook(HttpTransport):
  154. def prepare(self, template, check, urlencode=False):
  155. """ Replace variables with actual values. """
  156. def safe(s):
  157. return quote(s) if urlencode else s
  158. ctx = {
  159. "$CODE": str(check.code),
  160. "$STATUS": check.status,
  161. "$NOW": safe(timezone.now().replace(microsecond=0).isoformat()),
  162. "$NAME": safe(check.name),
  163. "$TAGS": safe(check.tags),
  164. }
  165. for i, tag in enumerate(check.tags_list()):
  166. ctx["$TAG%d" % (i + 1)] = safe(tag)
  167. return replace(template, ctx)
  168. def is_noop(self, check):
  169. if check.status == "down" and not self.channel.url_down:
  170. return True
  171. if check.status == "up" and not self.channel.url_up:
  172. return True
  173. return False
  174. def notify(self, check):
  175. spec = self.channel.webhook_spec(check.status)
  176. if not spec["url"]:
  177. return "Empty webhook URL"
  178. url = self.prepare(spec["url"], check, urlencode=True)
  179. headers = {}
  180. for key, value in spec["headers"].items():
  181. headers[key] = self.prepare(value, check)
  182. body = spec["body"]
  183. if body:
  184. body = self.prepare(body, check)
  185. if spec["method"] == "GET":
  186. return self.get(url, headers=headers)
  187. elif spec["method"] == "POST":
  188. return self.post(url, data=body.encode(), headers=headers)
  189. elif spec["method"] == "PUT":
  190. return self.put(url, data=body.encode(), headers=headers)
  191. class Slack(HttpTransport):
  192. def notify(self, check):
  193. text = tmpl("slack_message.json", check=check)
  194. payload = json.loads(text)
  195. return self.post(self.channel.slack_webhook_url, json=payload)
  196. class HipChat(HttpTransport):
  197. def is_noop(self, check):
  198. return True
  199. class OpsGenie(HttpTransport):
  200. @classmethod
  201. def get_error(cls, response):
  202. try:
  203. return response.json().get("message")
  204. except ValueError:
  205. pass
  206. def notify(self, check):
  207. headers = {
  208. "Conent-Type": "application/json",
  209. "Authorization": "GenieKey %s" % self.channel.opsgenie_key,
  210. }
  211. payload = {"alias": str(check.code), "source": settings.SITE_NAME}
  212. if check.status == "down":
  213. payload["tags"] = check.tags_list()
  214. payload["message"] = tmpl("opsgenie_message.html", check=check)
  215. payload["note"] = tmpl("opsgenie_note.html", check=check)
  216. payload["description"] = tmpl("opsgenie_description.html", check=check)
  217. url = "https://api.opsgenie.com/v2/alerts"
  218. if self.channel.opsgenie_region == "eu":
  219. url = "https://api.eu.opsgenie.com/v2/alerts"
  220. if check.status == "up":
  221. url += "/%s/close?identifierType=alias" % check.code
  222. return self.post(url, json=payload, headers=headers)
  223. class PagerDuty(HttpTransport):
  224. URL = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
  225. def notify(self, check):
  226. description = tmpl("pd_description.html", check=check)
  227. payload = {
  228. "service_key": self.channel.pd_service_key,
  229. "incident_key": str(check.code),
  230. "event_type": "trigger" if check.status == "down" else "resolve",
  231. "description": description,
  232. "client": settings.SITE_NAME,
  233. "client_url": check.details_url(),
  234. }
  235. return self.post(self.URL, json=payload)
  236. class PagerTree(HttpTransport):
  237. def notify(self, check):
  238. url = self.channel.value
  239. headers = {"Conent-Type": "application/json"}
  240. payload = {
  241. "incident_key": str(check.code),
  242. "event_type": "trigger" if check.status == "down" else "resolve",
  243. "title": tmpl("pagertree_title.html", check=check),
  244. "description": tmpl("pagertree_description.html", check=check),
  245. "client": settings.SITE_NAME,
  246. "client_url": settings.SITE_ROOT,
  247. "tags": ",".join(check.tags_list()),
  248. }
  249. return self.post(url, json=payload, headers=headers)
  250. class PagerTeam(HttpTransport):
  251. def notify(self, check):
  252. url = self.channel.value
  253. headers = {"Content-Type": "application/json"}
  254. payload = {
  255. "incident_key": str(check.code),
  256. "event_type": "trigger" if check.status == "down" else "resolve",
  257. "title": tmpl("pagerteam_title.html", check=check),
  258. "description": tmpl("pagerteam_description.html", check=check),
  259. "client": settings.SITE_NAME,
  260. "client_url": settings.SITE_ROOT,
  261. "tags": ",".join(check.tags_list()),
  262. }
  263. return self.post(url, json=payload, headers=headers)
  264. class Pushbullet(HttpTransport):
  265. def notify(self, check):
  266. text = tmpl("pushbullet_message.html", check=check)
  267. url = "https://api.pushbullet.com/v2/pushes"
  268. headers = {
  269. "Access-Token": self.channel.value,
  270. "Conent-Type": "application/json",
  271. }
  272. payload = {"type": "note", "title": settings.SITE_NAME, "body": text}
  273. return self.post(url, json=payload, headers=headers)
  274. class Pushover(HttpTransport):
  275. URL = "https://api.pushover.net/1/messages.json"
  276. def notify(self, check):
  277. others = self.checks().filter(status="down").exclude(code=check.code)
  278. # list() executes the query, to avoid DB access while
  279. # rendering a template
  280. ctx = {"check": check, "down_checks": list(others)}
  281. text = tmpl("pushover_message.html", **ctx)
  282. title = tmpl("pushover_title.html", **ctx)
  283. pieces = self.channel.value.split("|")
  284. user_key, prio = pieces[0], pieces[1]
  285. # The third element, if present, is the priority for "up" events
  286. if len(pieces) == 3 and check.status == "up":
  287. prio = pieces[2]
  288. payload = {
  289. "token": settings.PUSHOVER_API_TOKEN,
  290. "user": user_key,
  291. "message": text,
  292. "title": title,
  293. "html": 1,
  294. "priority": int(prio),
  295. }
  296. # Emergency notification
  297. if prio == "2":
  298. payload["retry"] = settings.PUSHOVER_EMERGENCY_RETRY_DELAY
  299. payload["expire"] = settings.PUSHOVER_EMERGENCY_EXPIRATION
  300. return self.post(self.URL, data=payload)
  301. class VictorOps(HttpTransport):
  302. def notify(self, check):
  303. description = tmpl("victorops_description.html", check=check)
  304. mtype = "CRITICAL" if check.status == "down" else "RECOVERY"
  305. payload = {
  306. "entity_id": str(check.code),
  307. "message_type": mtype,
  308. "entity_display_name": check.name_then_code(),
  309. "state_message": description,
  310. "monitoring_tool": settings.SITE_NAME,
  311. }
  312. return self.post(self.channel.value, json=payload)
  313. class Matrix(HttpTransport):
  314. def get_url(self):
  315. s = quote(self.channel.value)
  316. url = settings.MATRIX_HOMESERVER
  317. url += "/_matrix/client/r0/rooms/%s/send/m.room.message?" % s
  318. url += urlencode({"access_token": settings.MATRIX_ACCESS_TOKEN})
  319. return url
  320. def notify(self, check):
  321. plain = tmpl("matrix_description.html", check=check)
  322. formatted = tmpl("matrix_description_formatted.html", check=check)
  323. payload = {
  324. "msgtype": "m.text",
  325. "body": plain,
  326. "format": "org.matrix.custom.html",
  327. "formatted_body": formatted,
  328. }
  329. return self.post(self.get_url(), json=payload)
  330. class Discord(HttpTransport):
  331. def notify(self, check):
  332. text = tmpl("slack_message.json", check=check)
  333. payload = json.loads(text)
  334. url = self.channel.discord_webhook_url + "/slack"
  335. return self.post(url, json=payload)
  336. class Telegram(HttpTransport):
  337. SM = "https://api.telegram.org/bot%s/sendMessage" % settings.TELEGRAM_TOKEN
  338. @classmethod
  339. def get_error(cls, response):
  340. try:
  341. return response.json().get("description")
  342. except ValueError:
  343. pass
  344. @classmethod
  345. def send(cls, chat_id, text):
  346. return cls.post(
  347. cls.SM, json={"chat_id": chat_id, "text": text, "parse_mode": "html"}
  348. )
  349. def notify(self, check):
  350. text = tmpl("telegram_message.html", check=check)
  351. return self.send(self.channel.telegram_id, text)
  352. class Sms(HttpTransport):
  353. URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json"
  354. def is_noop(self, check):
  355. return check.status != "down"
  356. def notify(self, check):
  357. profile = Profile.objects.for_user(self.channel.project.owner)
  358. if not profile.authorize_sms():
  359. profile.send_sms_limit_notice("SMS")
  360. return "Monthly SMS limit exceeded"
  361. url = self.URL % settings.TWILIO_ACCOUNT
  362. auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
  363. text = tmpl("sms_message.html", check=check, site_name=settings.SITE_NAME)
  364. data = {
  365. "From": settings.TWILIO_FROM,
  366. "To": self.channel.sms_number,
  367. "Body": text,
  368. }
  369. return self.post(url, data=data, auth=auth)
  370. class WhatsApp(HttpTransport):
  371. URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json"
  372. def is_noop(self, check):
  373. if check.status == "down":
  374. return not self.channel.whatsapp_notify_down
  375. else:
  376. return not self.channel.whatsapp_notify_up
  377. def notify(self, check):
  378. profile = Profile.objects.for_user(self.channel.project.owner)
  379. if not profile.authorize_sms():
  380. profile.send_sms_limit_notice("WhatsApp")
  381. return "Monthly message limit exceeded"
  382. url = self.URL % settings.TWILIO_ACCOUNT
  383. auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
  384. text = tmpl("whatsapp_message.html", check=check, site_name=settings.SITE_NAME)
  385. data = {
  386. "From": "whatsapp:%s" % settings.TWILIO_FROM,
  387. "To": "whatsapp:%s" % self.channel.sms_number,
  388. "Body": text,
  389. }
  390. return self.post(url, data=data, auth=auth)
  391. class Trello(HttpTransport):
  392. URL = "https://api.trello.com/1/cards"
  393. def is_noop(self, check):
  394. return check.status != "down"
  395. def notify(self, check):
  396. params = {
  397. "idList": self.channel.trello_list_id,
  398. "name": tmpl("trello_name.html", check=check),
  399. "desc": tmpl("trello_desc.html", check=check),
  400. "key": settings.TRELLO_APP_KEY,
  401. "token": self.channel.trello_token,
  402. }
  403. return self.post(self.URL, params=params)
  404. class Apprise(HttpTransport):
  405. def notify(self, check):
  406. if not settings.APPRISE_ENABLED:
  407. # Not supported and/or enabled
  408. return "Apprise is disabled and/or not installed"
  409. a = apprise.Apprise()
  410. title = tmpl("apprise_title.html", check=check)
  411. body = tmpl("apprise_description.html", check=check)
  412. a.add(self.channel.value)
  413. notify_type = (
  414. apprise.NotifyType.SUCCESS
  415. if check.status == "up"
  416. else apprise.NotifyType.FAILURE
  417. )
  418. return (
  419. "Failed"
  420. if not a.notify(body=body, title=title, notify_type=notify_type)
  421. else None
  422. )
  423. class MsTeams(HttpTransport):
  424. def notify(self, check):
  425. text = tmpl("msteams_message.json", check=check)
  426. payload = json.loads(text)
  427. return self.post(self.channel.value, json=payload)
  428. class Zulip(HttpTransport):
  429. @classmethod
  430. def get_error(cls, response):
  431. try:
  432. return response.json().get("msg")
  433. except ValueError:
  434. pass
  435. def notify(self, check):
  436. _, domain = self.channel.zulip_bot_email.split("@")
  437. url = "https://%s/api/v1/messages" % domain
  438. auth = (self.channel.zulip_bot_email, self.channel.zulip_api_key)
  439. data = {
  440. "type": self.channel.zulip_type,
  441. "to": self.channel.zulip_to,
  442. "topic": tmpl("zulip_topic.html", check=check),
  443. "content": tmpl("zulip_content.html", check=check),
  444. }
  445. return self.post(url, data=data, auth=auth)