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.

426 lines
13 KiB

  1. from django.conf import settings
  2. from django.template.loader import render_to_string
  3. from django.utils import timezone
  4. import json
  5. import requests
  6. from urllib.parse import quote
  7. from hc.accounts.models import Profile
  8. from hc.lib import emails
  9. def tmpl(template_name, **ctx):
  10. template_path = "integrations/%s" % template_name
  11. # \xa0 is non-breaking space. It causes SMS messages to use UCS2 encoding
  12. # and cost twice the money.
  13. return render_to_string(template_path, ctx).strip().replace(u"\xa0", " ")
  14. class Transport(object):
  15. def __init__(self, channel):
  16. self.channel = channel
  17. def notify(self, check):
  18. """ Send notification about current status of the check.
  19. This method returns None on success, and error message
  20. on error.
  21. """
  22. raise NotImplementedError()
  23. def is_noop(self, check):
  24. """ Return True if transport will ignore check's current status.
  25. This method is overriden in Webhook subclass where the user can
  26. configure webhook urls for "up" and "down" events, and both are
  27. optional.
  28. """
  29. return False
  30. def checks(self):
  31. return self.channel.user.check_set.order_by("created")
  32. class Email(Transport):
  33. def notify(self, check, bounce_url):
  34. if not self.channel.email_verified:
  35. return "Email not verified"
  36. headers = {"X-Bounce-Url": bounce_url}
  37. try:
  38. # Look up the sorting preference for this email address
  39. p = Profile.objects.get(user__email=self.channel.value)
  40. sort = p.sort
  41. except Profile.DoesNotExist:
  42. # Default sort order is by check's creation time
  43. sort = "created"
  44. # list() executes the query, to avoid DB access while
  45. # rendering a template
  46. ctx = {
  47. "check": check,
  48. "checks": list(self.checks()),
  49. "sort": sort,
  50. "now": timezone.now(),
  51. "unsub_link": self.channel.get_unsub_link()
  52. }
  53. emails.alert(self.channel.value, ctx, headers)
  54. def is_noop(self, check):
  55. return not self.channel.email_verified
  56. class HttpTransport(Transport):
  57. @classmethod
  58. def _request(cls, method, url, **kwargs):
  59. try:
  60. options = dict(kwargs)
  61. options["timeout"] = 5
  62. if "headers" not in options:
  63. options["headers"] = {}
  64. if "User-Agent" not in options["headers"]:
  65. options["headers"]["User-Agent"] = "healthchecks.io"
  66. r = requests.request(method, url, **options)
  67. if r.status_code not in (200, 201, 202, 204):
  68. return "Received status code %d" % r.status_code
  69. except requests.exceptions.Timeout:
  70. # Well, we tried
  71. return "Connection timed out"
  72. except requests.exceptions.ConnectionError:
  73. return "Connection failed"
  74. @classmethod
  75. def get(cls, url, **kwargs):
  76. # Make 3 attempts--
  77. for x in range(0, 3):
  78. error = cls._request("get", url, **kwargs)
  79. if error is None:
  80. break
  81. return error
  82. @classmethod
  83. def post(cls, url, **kwargs):
  84. # Make 3 attempts--
  85. for x in range(0, 3):
  86. error = cls._request("post", url, **kwargs)
  87. if error is None:
  88. break
  89. return error
  90. @classmethod
  91. def put(cls, url, **kwargs):
  92. # Make 3 attempts--
  93. for x in range(0, 3):
  94. error = cls._request("put", url, **kwargs)
  95. if error is None:
  96. break
  97. return error
  98. class Webhook(HttpTransport):
  99. def prepare(self, template, check, urlencode=False):
  100. """ Replace variables with actual values.
  101. There should be no bad translations if users use $ symbol in
  102. check's name or tags, because $ gets urlencoded to %24
  103. """
  104. def safe(s):
  105. return quote(s) if urlencode else s
  106. result = template
  107. if "$CODE" in result:
  108. result = result.replace("$CODE", str(check.code))
  109. if "$STATUS" in result:
  110. result = result.replace("$STATUS", check.status)
  111. if "$NOW" in result:
  112. s = timezone.now().replace(microsecond=0).isoformat()
  113. result = result.replace("$NOW", safe(s))
  114. if "$NAME" in result:
  115. result = result.replace("$NAME", safe(check.name))
  116. if "$TAG" in result:
  117. for i, tag in enumerate(check.tags_list()):
  118. placeholder = "$TAG%d" % (i + 1)
  119. result = result.replace(placeholder, safe(tag))
  120. return result
  121. def is_noop(self, check):
  122. if check.status == "down" and not self.channel.url_down:
  123. return True
  124. if check.status == "up" and not self.channel.url_up:
  125. return True
  126. return False
  127. def notify(self, check):
  128. url = self.channel.url_down
  129. if check.status == "up":
  130. url = self.channel.url_up
  131. assert url
  132. url = self.prepare(url, check, urlencode=True)
  133. headers = {}
  134. for key, value in self.channel.headers.items():
  135. headers[key] = self.prepare(value, check)
  136. if self.channel.post_data:
  137. payload = self.prepare(self.channel.post_data, check)
  138. return self.post(url, data=payload.encode(), headers=headers)
  139. else:
  140. return self.get(url, headers=headers)
  141. class Slack(HttpTransport):
  142. def notify(self, check):
  143. text = tmpl("slack_message.json", check=check)
  144. payload = json.loads(text)
  145. return self.post(self.channel.slack_webhook_url, json=payload)
  146. class HipChat(HttpTransport):
  147. def notify(self, check):
  148. text = tmpl("hipchat_message.json", check=check)
  149. payload = json.loads(text)
  150. self.channel.refresh_hipchat_access_token()
  151. return self.post(self.channel.hipchat_webhook_url, json=payload)
  152. class OpsGenie(HttpTransport):
  153. def notify(self, check):
  154. headers = {
  155. "Conent-Type": "application/json",
  156. "Authorization": "GenieKey %s" % self.channel.value
  157. }
  158. payload = {
  159. "alias": str(check.code),
  160. "source": settings.SITE_NAME
  161. }
  162. if check.status == "down":
  163. payload["tags"] = check.tags_list()
  164. payload["message"] = tmpl("opsgenie_message.html", check=check)
  165. payload["note"] = tmpl("opsgenie_note.html", check=check)
  166. payload["description"] = \
  167. tmpl("opsgenie_description.html", check=check)
  168. url = "https://api.opsgenie.com/v2/alerts"
  169. if check.status == "up":
  170. url += "/%s/close?identifierType=alias" % check.code
  171. return self.post(url, json=payload, headers=headers)
  172. class PagerDuty(HttpTransport):
  173. URL = "https://events.pagerduty.com/generic/2010-04-15/create_event.json"
  174. def notify(self, check):
  175. description = tmpl("pd_description.html", check=check)
  176. payload = {
  177. "vendor": settings.PD_VENDOR_KEY,
  178. "service_key": self.channel.pd_service_key,
  179. "incident_key": str(check.code),
  180. "event_type": "trigger" if check.status == "down" else "resolve",
  181. "description": description,
  182. "client": settings.SITE_NAME,
  183. "client_url": settings.SITE_ROOT
  184. }
  185. return self.post(self.URL, json=payload)
  186. class PagerTree(HttpTransport):
  187. def notify(self, check):
  188. url = self.channel.value
  189. headers = {
  190. "Conent-Type": "application/json"
  191. }
  192. payload = {
  193. "incident_key": str(check.code),
  194. "event_type": "trigger" if check.status == "down" else "resolve",
  195. "title": tmpl("pagertree_title.html", check=check),
  196. "description": tmpl("pagertree_description.html", check=check),
  197. "client": settings.SITE_NAME,
  198. "client_url": settings.SITE_ROOT,
  199. "tags": ",".join(check.tags_list())
  200. }
  201. return self.post(url, json=payload, headers=headers)
  202. class Pushbullet(HttpTransport):
  203. def notify(self, check):
  204. text = tmpl("pushbullet_message.html", check=check)
  205. url = "https://api.pushbullet.com/v2/pushes"
  206. headers = {
  207. "Access-Token": self.channel.value,
  208. "Conent-Type": "application/json"
  209. }
  210. payload = {
  211. "type": "note",
  212. "title": settings.SITE_NAME,
  213. "body": text
  214. }
  215. return self.post(url, json=payload, headers=headers)
  216. class Pushover(HttpTransport):
  217. URL = "https://api.pushover.net/1/messages.json"
  218. def notify(self, check):
  219. others = self.checks().filter(status="down").exclude(code=check.code)
  220. # list() executes the query, to avoid DB access while
  221. # rendering a template
  222. ctx = {
  223. "check": check,
  224. "down_checks": list(others),
  225. }
  226. text = tmpl("pushover_message.html", **ctx)
  227. title = tmpl("pushover_title.html", **ctx)
  228. user_key, prio = self.channel.value.split("|")
  229. payload = {
  230. "token": settings.PUSHOVER_API_TOKEN,
  231. "user": user_key,
  232. "message": text,
  233. "title": title,
  234. "html": 1,
  235. "priority": int(prio),
  236. }
  237. # Emergency notification
  238. if prio == "2":
  239. payload["retry"] = settings.PUSHOVER_EMERGENCY_RETRY_DELAY
  240. payload["expire"] = settings.PUSHOVER_EMERGENCY_EXPIRATION
  241. return self.post(self.URL, data=payload)
  242. class VictorOps(HttpTransport):
  243. def notify(self, check):
  244. description = tmpl("victorops_description.html", check=check)
  245. mtype = "CRITICAL" if check.status == "down" else "RECOVERY"
  246. payload = {
  247. "entity_id": str(check.code),
  248. "message_type": mtype,
  249. "entity_display_name": check.name_then_code(),
  250. "state_message": description,
  251. "monitoring_tool": settings.SITE_NAME,
  252. }
  253. return self.post(self.channel.value, json=payload)
  254. class Discord(HttpTransport):
  255. def notify(self, check):
  256. text = tmpl("slack_message.json", check=check)
  257. payload = json.loads(text)
  258. url = self.channel.discord_webhook_url + "/slack"
  259. return self.post(url, json=payload)
  260. class Telegram(HttpTransport):
  261. SM = "https://api.telegram.org/bot%s/sendMessage" % settings.TELEGRAM_TOKEN
  262. @classmethod
  263. def send(cls, chat_id, text):
  264. return cls.post(cls.SM, json={
  265. "chat_id": chat_id,
  266. "text": text,
  267. "parse_mode": "html"
  268. })
  269. def notify(self, check):
  270. text = tmpl("telegram_message.html", check=check)
  271. return self.send(self.channel.telegram_id, text)
  272. class Sms(HttpTransport):
  273. URL = 'https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json'
  274. def is_noop(self, check):
  275. return check.status != "down"
  276. def notify(self, check):
  277. profile = Profile.objects.for_user(self.channel.user)
  278. if not profile.authorize_sms():
  279. return "Monthly SMS limit exceeded"
  280. url = self.URL % settings.TWILIO_ACCOUNT
  281. auth = (settings.TWILIO_ACCOUNT, settings.TWILIO_AUTH)
  282. text = tmpl("sms_message.html", check=check,
  283. site_name=settings.SITE_NAME)
  284. data = {
  285. 'From': settings.TWILIO_FROM,
  286. 'To': self.channel.sms_number,
  287. 'Body': text,
  288. }
  289. return self.post(url, data=data, auth=auth)
  290. class Zendesk(HttpTransport):
  291. TMPL = "https://%s.zendesk.com/api/v2/requests.json"
  292. def get_payload(self, check):
  293. return {
  294. "request": {
  295. "subject": tmpl("zendesk_title.html", check=check),
  296. "type": "incident",
  297. "comment": {
  298. "body": tmpl("zendesk_description.html", check=check)
  299. }
  300. }
  301. }
  302. def notify_down(self, check):
  303. headers = {"Authorization": "Bearer %s" % self.channel.zendesk_token}
  304. url = self.TMPL % self.channel.zendesk_subdomain
  305. return self.post(url, headers=headers, json=self.get_payload(check))
  306. def notify_up(self, check):
  307. # Get the list of requests made by us, in newest-to-oldest order
  308. url = self.TMPL % self.channel.zendesk_subdomain
  309. url += "?sort_by=created_at&sort_order=desc"
  310. headers = {"Authorization": "Bearer %s" % self.channel.zendesk_token}
  311. r = requests.get(url, headers=headers, timeout=10)
  312. if r.status_code != 200:
  313. return "Received status code %d" % r.status_code
  314. # Update the first request that has check.code in its description
  315. doc = r.json()
  316. if "requests" in doc:
  317. for obj in doc["requests"]:
  318. if str(check.code) in obj["description"]:
  319. payload = self.get_payload(check)
  320. return self.put(obj["url"], headers=headers, json=payload)
  321. return "Could not find a ticket to update"
  322. def notify(self, check):
  323. if check.status == "down":
  324. return self.notify_down(check)
  325. if check.status == "up":
  326. return self.notify_up(check)