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.

764 lines
24 KiB

4 years ago
6 years ago
6 years ago
6 years ago
6 years ago
  1. import os
  2. import time
  3. from django.conf import settings
  4. from django.template.loader import render_to_string
  5. from django.utils import timezone
  6. from django.utils.html import escape
  7. import json
  8. import requests
  9. from urllib.parse import quote, urlencode
  10. from hc.accounts.models import Profile
  11. from hc.lib import emails
  12. from hc.lib.string import replace
  13. try:
  14. import apprise
  15. except ImportError:
  16. # Enforce
  17. settings.APPRISE_ENABLED = False
  18. try:
  19. import dbus
  20. except ImportError:
  21. # Enforce
  22. dbus = None
  23. settings.SIGNAL_CLI_ENABLED = False
  24. def tmpl(template_name, **ctx):
  25. template_path = "integrations/%s" % template_name
  26. # \xa0 is non-breaking space. It causes SMS messages to use UCS2 encoding
  27. # and cost twice the money.
  28. return render_to_string(template_path, ctx).strip().replace("\xa0", " ")
  29. class Transport(object):
  30. def __init__(self, channel):
  31. self.channel = channel
  32. def notify(self, check):
  33. """ Send notification about current status of the check.
  34. This method returns None on success, and error message
  35. on error.
  36. """
  37. raise NotImplementedError()
  38. def is_noop(self, check):
  39. """ Return True if transport will ignore check's current status.
  40. This method is overridden in Webhook subclass where the user can
  41. configure webhook urls for "up" and "down" events, and both are
  42. optional.
  43. """
  44. return False
  45. def checks(self):
  46. return self.channel.project.check_set.order_by("created")
  47. class Email(Transport):
  48. def notify(self, check):
  49. if not self.channel.email_verified:
  50. return "Email not verified"
  51. unsub_link = self.channel.get_unsub_link()
  52. headers = {
  53. "X-Status-Url": check.status_url,
  54. "List-Unsubscribe": "<%s>" % unsub_link,
  55. "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
  56. }
  57. from hc.accounts.models import Profile
  58. # If this email address has an associated account, include
  59. # a summary of projects the account has access to
  60. try:
  61. profile = Profile.objects.get(user__email=self.channel.email_value)
  62. projects = list(profile.projects())
  63. except Profile.DoesNotExist:
  64. projects = None
  65. ctx = {
  66. "check": check,
  67. "ping": check.ping_set.order_by("created").last(),
  68. "projects": projects,
  69. "unsub_link": unsub_link,
  70. }
  71. emails.alert(self.channel.email_value, ctx, headers)
  72. def is_noop(self, check):
  73. if check.status == "down":
  74. return not self.channel.email_notify_down
  75. else:
  76. return not self.channel.email_notify_up
  77. class Shell(Transport):
  78. def prepare(self, template, check):
  79. """ Replace placeholders with actual values. """
  80. ctx = {
  81. "$CODE": str(check.code),
  82. "$STATUS": check.status,
  83. "$NOW": timezone.now().replace(microsecond=0).isoformat(),
  84. "$NAME": check.name,
  85. "$TAGS": check.tags,
  86. }
  87. for i, tag in enumerate(check.tags_list()):
  88. ctx["$TAG%d" % (i + 1)] = tag
  89. return replace(template, ctx)
  90. def is_noop(self, check):
  91. if check.status == "down" and not self.channel.cmd_down:
  92. return True
  93. if check.status == "up" and not self.channel.cmd_up:
  94. return True
  95. return False
  96. def notify(self, check):
  97. if not settings.SHELL_ENABLED:
  98. return "Shell commands are not enabled"
  99. if check.status == "up":
  100. cmd = self.channel.cmd_up
  101. elif check.status == "down":
  102. cmd = self.channel.cmd_down
  103. cmd = self.prepare(cmd, check)
  104. code = os.system(cmd)
  105. if code != 0:
  106. return "Command returned exit code %d" % code
  107. class HttpTransport(Transport):
  108. @classmethod
  109. def get_error(cls, response):
  110. # Override in subclasses: look for a specific error message in the
  111. # response and return it.
  112. return None
  113. @classmethod
  114. def _request(cls, method, url, **kwargs):
  115. try:
  116. options = dict(kwargs)
  117. options["timeout"] = 10
  118. if "headers" not in options:
  119. options["headers"] = {}
  120. if "User-Agent" not in options["headers"]:
  121. options["headers"]["User-Agent"] = "healthchecks.io"
  122. r = requests.request(method, url, **options)
  123. if r.status_code not in (200, 201, 202, 204):
  124. m = cls.get_error(r)
  125. if m:
  126. return f'Received status code {r.status_code} with a message: "{m}"'
  127. return f"Received status code {r.status_code}"
  128. except requests.exceptions.Timeout:
  129. # Well, we tried
  130. return "Connection timed out"
  131. except requests.exceptions.ConnectionError:
  132. return "Connection failed"
  133. @classmethod
  134. def _request_with_retries(cls, method, url, use_retries=True, **kwargs):
  135. start = time.time()
  136. error = cls._request(method, url, **kwargs)
  137. # 2nd try
  138. if error and use_retries:
  139. error = cls._request(method, url, **kwargs)
  140. # 3rd try. Only do the 3rd try if we have spent 10s or less in first two
  141. # tries. Otherwise we risk overshooting the 20s total time budget.
  142. if error and use_retries and time.time() - start < 10:
  143. error = cls._request(method, url, **kwargs)
  144. return error
  145. @classmethod
  146. def get(cls, url, **kwargs):
  147. return cls._request_with_retries("get", url, **kwargs)
  148. @classmethod
  149. def post(cls, url, **kwargs):
  150. return cls._request_with_retries("post", url, **kwargs)
  151. @classmethod
  152. def put(cls, url, **kwargs):
  153. return cls._request_with_retries("put", url, **kwargs)
  154. class Webhook(HttpTransport):
  155. def prepare(self, template, check, urlencode=False, latin1=False):
  156. """ Replace variables with actual values. """
  157. def safe(s):
  158. return quote(s) if urlencode else s
  159. ctx = {
  160. "$CODE": str(check.code),
  161. "$STATUS": check.status,
  162. "$NOW": safe(timezone.now().replace(microsecond=0).isoformat()),
  163. "$NAME": safe(check.name),
  164. "$TAGS": safe(check.tags),
  165. }
  166. for i, tag in enumerate(check.tags_list()):
  167. ctx["$TAG%d" % (i + 1)] = safe(tag)
  168. result = replace(template, ctx)
  169. if latin1:
  170. # Replace non-latin-1 characters with XML character references.
  171. result = result.encode("latin-1", "xmlcharrefreplace").decode("latin-1")
  172. return result
  173. def is_noop(self, check):
  174. if check.status == "down" and not self.channel.url_down:
  175. return True
  176. if check.status == "up" and not self.channel.url_up:
  177. return True
  178. return False
  179. def notify(self, check):
  180. if not settings.WEBHOOKS_ENABLED:
  181. return "Webhook notifications are not enabled."
  182. spec = self.channel.webhook_spec(check.status)
  183. if not spec["url"]:
  184. return "Empty webhook URL"
  185. url = self.prepare(spec["url"], check, urlencode=True)
  186. headers = {}
  187. for key, value in spec["headers"].items():
  188. # Header values should contain ASCII and latin-1 only
  189. headers[key] = self.prepare(value, check, latin1=True)
  190. body = spec["body"]
  191. if body:
  192. body = self.prepare(body, check).encode()
  193. # When sending a test notification, don't retry on failures.
  194. use_retries = False if getattr(check, "is_test") else True
  195. if spec["method"] == "GET":
  196. return self.get(url, use_retries=use_retries, headers=headers)
  197. elif spec["method"] == "POST":
  198. return self.post(url, use_retries=use_retries, data=body, headers=headers)
  199. elif spec["method"] == "PUT":
  200. return self.put(url, use_retries=use_retries, data=body, headers=headers)
  201. class Slack(HttpTransport):
  202. def notify(self, check):
  203. if self.channel.kind == "slack" and not settings.SLACK_ENABLED:
  204. return "Slack notifications are not enabled."
  205. if self.channel.kind == "mattermost" and not settings.MATTERMOST_ENABLED:
  206. return "Mattermost notifications are not enabled."
  207. text = tmpl("slack_message.json", check=check)
  208. payload = json.loads(text)
  209. return self.post(self.channel.slack_webhook_url, json=payload)
  210. class HipChat(HttpTransport):
  211. def is_noop(self, check):
  212. return True
  213. class Opsgenie(HttpTransport):
  214. @classmethod
  215. def get_error(cls, response):
  216. try:
  217. return response.json().get("message")
  218. except ValueError:
  219. pass
  220. def notify(self, check):
  221. if not settings.OPSGENIE_ENABLED:
  222. return "Opsgenie notifications are not enabled."
  223. headers = {
  224. "Conent-Type": "application/json",
  225. "Authorization": "GenieKey %s" % self.channel.opsgenie_key,
  226. }
  227. payload = {"alias": str(check.code), "source": settings.SITE_NAME}
  228. if check.status == "down":
  229. payload["tags"] = check.tags_list()
  230. payload["message"] = tmpl("opsgenie_message.html", check=check)
  231. payload["note"] = tmpl("opsgenie_note.html", check=check)
  232. payload["description"] = tmpl("opsgenie_description.html", check=check)
  233. url = "https://api.opsgenie.com/v2/alerts"
  234. if self.channel.opsgenie_region == "eu":
  235. url = "https://api.eu.opsgenie.com/v2/alerts"
  236. if check.status == "up":
  237. url += "/%s/close?identifierType=alias" % check.code
  238. return self.post(url, json=payload, headers=headers)
  239. class PagerDuty(HttpTransport):
  240. URL = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
  241. def notify(self, check):
  242. if not settings.PD_ENABLED:
  243. return "PagerDuty notifications are not enabled."
  244. description = tmpl("pd_description.html", check=check)
  245. payload = {
  246. "service_key": self.channel.pd_service_key,
  247. "incident_key": str(check.code),
  248. "event_type": "trigger" if check.status == "down" else "resolve",
  249. "description": description,
  250. "client": settings.SITE_NAME,
  251. "client_url": check.details_url(),
  252. }
  253. return self.post(self.URL, json=payload)
  254. class PagerTree(HttpTransport):
  255. def notify(self, check):
  256. if not settings.PAGERTREE_ENABLED:
  257. return "PagerTree notifications are not enabled."
  258. url = self.channel.value
  259. headers = {"Conent-Type": "application/json"}
  260. payload = {
  261. "incident_key": str(check.code),
  262. "event_type": "trigger" if check.status == "down" else "resolve",
  263. "title": tmpl("pagertree_title.html", check=check),
  264. "description": tmpl("pagertree_description.html", check=check),
  265. "client": settings.SITE_NAME,
  266. "client_url": settings.SITE_ROOT,
  267. "tags": ",".join(check.tags_list()),
  268. }
  269. return self.post(url, json=payload, headers=headers)
  270. class PagerTeam(HttpTransport):
  271. def is_noop(self, check):
  272. return True
  273. class Pushbullet(HttpTransport):
  274. def notify(self, check):
  275. text = tmpl("pushbullet_message.html", check=check)
  276. url = "https://api.pushbullet.com/v2/pushes"
  277. headers = {
  278. "Access-Token": self.channel.value,
  279. "Conent-Type": "application/json",
  280. }
  281. payload = {"type": "note", "title": settings.SITE_NAME, "body": text}
  282. return self.post(url, json=payload, headers=headers)
  283. class Pushover(HttpTransport):
  284. URL = "https://api.pushover.net/1/messages.json"
  285. CANCEL_TMPL = "https://api.pushover.net/1/receipts/cancel_by_tag/%s.json"
  286. def notify(self, check):
  287. pieces = self.channel.value.split("|")
  288. user_key, down_prio = pieces[0], pieces[1]
  289. # The third element, if present, is the priority for "up" events
  290. up_prio = down_prio
  291. if len(pieces) == 3:
  292. up_prio = pieces[2]
  293. from hc.api.models import TokenBucket
  294. if not TokenBucket.authorize_pushover(user_key):
  295. return "Rate limit exceeded"
  296. # If down events have the emergency priority,
  297. # send a cancel call first
  298. if check.status == "up" and down_prio == "2":
  299. url = self.CANCEL_TMPL % check.unique_key
  300. payload = {"token": settings.PUSHOVER_API_TOKEN}
  301. self.post(url, data=payload)
  302. others = self.checks().filter(status="down").exclude(code=check.code)
  303. # list() executes the query, to avoid DB access while
  304. # rendering a template
  305. ctx = {"check": check, "down_checks": list(others)}
  306. text = tmpl("pushover_message.html", **ctx)
  307. title = tmpl("pushover_title.html", **ctx)
  308. prio = up_prio if check.status == "up" else down_prio
  309. payload = {
  310. "token": settings.PUSHOVER_API_TOKEN,
  311. "user": user_key,
  312. "message": text,
  313. "title": title,
  314. "html": 1,
  315. "priority": int(prio),
  316. "tags": check.unique_key,
  317. "url": check.cloaked_url(),
  318. "url_title": f"View on {settings.SITE_NAME}",
  319. }
  320. # Emergency notification
  321. if prio == "2":
  322. payload["retry"] = settings.PUSHOVER_EMERGENCY_RETRY_DELAY
  323. payload["expire"] = settings.PUSHOVER_EMERGENCY_EXPIRATION
  324. return self.post(self.URL, data=payload)
  325. class VictorOps(HttpTransport):
  326. def notify(self, check):
  327. if not settings.VICTOROPS_ENABLED:
  328. return "Splunk On-Call notifications are not enabled."
  329. description = tmpl("victorops_description.html", check=check)
  330. mtype = "CRITICAL" if check.status == "down" else "RECOVERY"
  331. payload = {
  332. "entity_id": str(check.code),
  333. "message_type": mtype,
  334. "entity_display_name": check.name_then_code(),
  335. "state_message": description,
  336. "monitoring_tool": settings.SITE_NAME,
  337. }
  338. return self.post(self.channel.value, json=payload)
  339. class Matrix(HttpTransport):
  340. def get_url(self):
  341. s = quote(self.channel.value)
  342. url = settings.MATRIX_HOMESERVER
  343. url += "/_matrix/client/r0/rooms/%s/send/m.room.message?" % s
  344. url += urlencode({"access_token": settings.MATRIX_ACCESS_TOKEN})
  345. return url
  346. def notify(self, check):
  347. plain = tmpl("matrix_description.html", check=check)
  348. formatted = tmpl("matrix_description_formatted.html", check=check)
  349. payload = {
  350. "msgtype": "m.text",
  351. "body": plain,
  352. "format": "org.matrix.custom.html",
  353. "formatted_body": formatted,
  354. }
  355. return self.post(self.get_url(), json=payload)
  356. class Discord(HttpTransport):
  357. def notify(self, check):
  358. text = tmpl("slack_message.json", check=check)
  359. payload = json.loads(text)
  360. url = self.channel.discord_webhook_url + "/slack"
  361. return self.post(url, json=payload)
  362. class Telegram(HttpTransport):
  363. SM = "https://api.telegram.org/bot%s/sendMessage" % settings.TELEGRAM_TOKEN
  364. @classmethod
  365. def get_error(cls, response):
  366. try:
  367. return response.json().get("description")
  368. except ValueError:
  369. pass
  370. @classmethod
  371. def send(cls, chat_id, text):
  372. # Telegram.send is a separate method because it is also used in
  373. # hc.front.views.telegram_bot to send invite links.
  374. return cls.post(
  375. cls.SM, json={"chat_id": chat_id, "text": text, "parse_mode": "html"}
  376. )
  377. def notify(self, check):
  378. from hc.api.models import TokenBucket
  379. if not TokenBucket.authorize_telegram(self.channel.telegram_id):
  380. return "Rate limit exceeded"
  381. text = tmpl("telegram_message.html", check=check)
  382. return self.send(self.channel.telegram_id, text)
  383. class Sms(HttpTransport):
  384. URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json"
  385. def is_noop(self, check):
  386. if check.status == "down":
  387. return not self.channel.sms_notify_down
  388. else:
  389. return not self.channel.sms_notify_up
  390. def notify(self, check):
  391. profile = Profile.objects.for_user(self.channel.project.owner)
  392. if not profile.authorize_sms():
  393. profile.send_sms_limit_notice("SMS")
  394. return "Monthly SMS limit exceeded"
  395. url = self.URL % settings.TWILIO_ACCOUNT
  396. auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
  397. text = tmpl("sms_message.html", check=check, site_name=settings.SITE_NAME)
  398. data = {
  399. "From": settings.TWILIO_FROM,
  400. "To": self.channel.phone_number,
  401. "Body": text,
  402. "StatusCallback": check.status_url,
  403. }
  404. return self.post(url, data=data, auth=auth)
  405. class Call(HttpTransport):
  406. URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Calls.json"
  407. def is_noop(self, check):
  408. return check.status != "down"
  409. def notify(self, check):
  410. profile = Profile.objects.for_user(self.channel.project.owner)
  411. if not profile.authorize_call():
  412. profile.send_call_limit_notice()
  413. return "Monthly phone call limit exceeded"
  414. url = self.URL % settings.TWILIO_ACCOUNT
  415. auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
  416. twiml = tmpl("call_message.html", check=check, site_name=settings.SITE_NAME)
  417. data = {
  418. "From": settings.TWILIO_FROM,
  419. "To": self.channel.phone_number,
  420. "Twiml": twiml,
  421. "StatusCallback": check.status_url,
  422. }
  423. return self.post(url, data=data, auth=auth)
  424. class WhatsApp(HttpTransport):
  425. URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json"
  426. def is_noop(self, check):
  427. if check.status == "down":
  428. return not self.channel.whatsapp_notify_down
  429. else:
  430. return not self.channel.whatsapp_notify_up
  431. def notify(self, check):
  432. profile = Profile.objects.for_user(self.channel.project.owner)
  433. if not profile.authorize_sms():
  434. profile.send_sms_limit_notice("WhatsApp")
  435. return "Monthly message limit exceeded"
  436. url = self.URL % settings.TWILIO_ACCOUNT
  437. auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
  438. text = tmpl("whatsapp_message.html", check=check, site_name=settings.SITE_NAME)
  439. data = {
  440. "From": "whatsapp:%s" % settings.TWILIO_FROM,
  441. "To": "whatsapp:%s" % self.channel.phone_number,
  442. "Body": text,
  443. "StatusCallback": check.status_url,
  444. }
  445. return self.post(url, data=data, auth=auth)
  446. class Trello(HttpTransport):
  447. URL = "https://api.trello.com/1/cards"
  448. def is_noop(self, check):
  449. return check.status != "down"
  450. def notify(self, check):
  451. params = {
  452. "idList": self.channel.trello_list_id,
  453. "name": tmpl("trello_name.html", check=check),
  454. "desc": tmpl("trello_desc.html", check=check),
  455. "key": settings.TRELLO_APP_KEY,
  456. "token": self.channel.trello_token,
  457. }
  458. return self.post(self.URL, params=params)
  459. class Apprise(HttpTransport):
  460. def notify(self, check):
  461. if not settings.APPRISE_ENABLED:
  462. # Not supported and/or enabled
  463. return "Apprise is disabled and/or not installed"
  464. a = apprise.Apprise()
  465. title = tmpl("apprise_title.html", check=check)
  466. body = tmpl("apprise_description.html", check=check)
  467. a.add(self.channel.value)
  468. notify_type = (
  469. apprise.NotifyType.SUCCESS
  470. if check.status == "up"
  471. else apprise.NotifyType.FAILURE
  472. )
  473. return (
  474. "Failed"
  475. if not a.notify(body=body, title=title, notify_type=notify_type)
  476. else None
  477. )
  478. class MsTeams(HttpTransport):
  479. def escape_md(self, s):
  480. # Escape special HTML characters
  481. s = escape(s)
  482. # Escape characters that have special meaning in Markdown
  483. for c in r"\`*_{}[]()#+-.!|":
  484. s = s.replace(c, "\\" + c)
  485. return s
  486. def notify(self, check):
  487. if not settings.MSTEAMS_ENABLED:
  488. return "MS Teams notifications are not enabled."
  489. text = tmpl("msteams_message.json", check=check)
  490. payload = json.loads(text)
  491. # MS Teams escapes HTML special characters in the summary field.
  492. # It does not interpret summary content as Markdown.
  493. name = check.name_then_code()
  494. payload["summary"] = f"“{name}” is {check.status.upper()}."
  495. # MS teams *strips* HTML special characters from the title field.
  496. # To avoid that, we use escape().
  497. # It does not interpret title as Markdown.
  498. safe_name = escape(name)
  499. payload["title"] = f"“{safe_name}” is {check.status.upper()}."
  500. # MS teams allows some HTML in the section text.
  501. # It also interprets the section text as Markdown.
  502. # We want to display the raw content, angle brackets and all,
  503. # so we run escape() and then additionally escape Markdown:
  504. payload["sections"][0]["text"] = self.escape_md(check.desc)
  505. return self.post(self.channel.value, json=payload)
  506. class Zulip(HttpTransport):
  507. @classmethod
  508. def get_error(cls, response):
  509. try:
  510. return response.json().get("msg")
  511. except ValueError:
  512. pass
  513. def notify(self, check):
  514. if not settings.ZULIP_ENABLED:
  515. return "Zulip notifications are not enabled."
  516. url = self.channel.zulip_site + "/api/v1/messages"
  517. auth = (self.channel.zulip_bot_email, self.channel.zulip_api_key)
  518. data = {
  519. "type": self.channel.zulip_type,
  520. "to": self.channel.zulip_to,
  521. "topic": tmpl("zulip_topic.html", check=check),
  522. "content": tmpl("zulip_content.html", check=check),
  523. }
  524. return self.post(url, data=data, auth=auth)
  525. class Spike(HttpTransport):
  526. def notify(self, check):
  527. if not settings.SPIKE_ENABLED:
  528. return "Spike notifications are not enabled."
  529. url = self.channel.value
  530. headers = {"Conent-Type": "application/json"}
  531. payload = {
  532. "check_id": str(check.code),
  533. "title": tmpl("spike_title.html", check=check),
  534. "message": tmpl("spike_description.html", check=check),
  535. "status": check.status,
  536. }
  537. return self.post(url, json=payload, headers=headers)
  538. class LineNotify(HttpTransport):
  539. URL = "https://notify-api.line.me/api/notify"
  540. def notify(self, check):
  541. headers = {
  542. "Content-Type": "application/x-www-form-urlencoded",
  543. "Authorization": "Bearer %s" % self.channel.linenotify_token,
  544. }
  545. payload = {"message": tmpl("linenotify_message.html", check=check)}
  546. return self.post(self.URL, headers=headers, params=payload)
  547. class Signal(Transport):
  548. def is_noop(self, check):
  549. if check.status == "down":
  550. return not self.channel.signal_notify_down
  551. else:
  552. return not self.channel.signal_notify_up
  553. def get_service(self):
  554. bus = dbus.SystemBus()
  555. signal_object = bus.get_object("org.asamk.Signal", "/org/asamk/Signal")
  556. return dbus.Interface(signal_object, "org.asamk.Signal")
  557. def notify(self, check):
  558. if not settings.SIGNAL_CLI_ENABLED:
  559. return "Signal notifications are not enabled"
  560. from hc.api.models import TokenBucket
  561. if not TokenBucket.authorize_signal(self.channel.phone_number):
  562. return "Rate limit exceeded"
  563. text = tmpl("signal_message.html", check=check, site_name=settings.SITE_NAME)
  564. try:
  565. dbus.SystemBus().call_blocking(
  566. "org.asamk.Signal",
  567. "/org/asamk/Signal",
  568. "org.asamk.Signal",
  569. "sendMessage",
  570. "sasas",
  571. (text, [], [self.channel.phone_number]),
  572. timeout=30,
  573. )
  574. except dbus.exceptions.DBusException as e:
  575. if "NotFoundException" in str(e):
  576. return "Recipient not found"
  577. return "signal-cli call failed"