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.

707 lines
22 KiB

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