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.

643 lines
19 KiB

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