diff --git a/CHANGELOG.md b/CHANGELOG.md index 020a58d8..706bef88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - Update OpsGenie instructions (#450) - Update the email notification template to include more check and last ping details - Improve the crontab snippet in the "Check Details" page (#465) +- Add Signal integration (#428) ## v1.18.0 - 2020-12-09 diff --git a/README.md b/README.md index 4752e091..3009c9f1 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ Healthchecks reads configuration from the following environment variables: | PUSHOVER_SUBSCRIPTION_URL | `None` | REMOTE_USER_HEADER | `None` | See [External Authentication](#external-authentication) for details. | SHELL_ENABLED | `"False"` +| SIGNAL_CLI_USERNAME | `None` +| SIGNAL_CLI_CMD | `signal-cli` | Path to the signal-cli executable | SLACK_CLIENT_ID | `None` | SLACK_CLIENT_SECRET | `None` | TELEGRAM_BOT_NAME | `"ExampleBot"` @@ -407,6 +409,39 @@ To enable the Pushover integration, you will need to: variables. The Pushover subscription URL should look similar to `https://pushover.net/subscribe/yourAppName-randomAlphaNumericData`. +### Signal + +Healthchecks uses [signal-cli](https://github.com/AsamK/signal-cli) to send Signal +notifications. It requires the `signal-cli` program to be installed and available on +the local machine. + +To send notifications, healthchecks executes "signal-cli send" calls. +It does not handle phone number registration and verification. You must do that +manually, before using the integration. + +To enable the Signal integration: + +* Download and install signal-cli in your preferred location + (for example, in `/srv/signal-cli-0.7.2/`). +* Register and verify phone number, or [link it](https://github.com/AsamK/signal-cli/wiki/Linking-other-devices-(Provisioning)) + to an existing registration. +* Test your signal-cli configuration by sending a message manually from command line. +* Put the sender phone number in the `SIGNAL_CLI_USERNAME` environment variable. + Example: `SIGNAL_CLI_USERNAME=+123456789`. +* If `signal-cli` is not in the system path, specify its path in `SIGNAL_CLI_CMD`. + Example: `SIGNAL_CLI_CMD=/srv/signal-cli-0.7.2/bin/signal-cli` + +It is possible to use a separate system user for running signal-cli: + +* Create a separate system user, (for example, "signal-user"). +* Configure signal-cli while logged in as signal-user. +* Change `SIGNAL_CLI_CMD` to run signal-cli through sudo: + `sudo -u signal-user /srv/signal-cli-0.7.2/bin/signal-cli`. +* Configure sudo to not require password. For example, if healthchecks + runs under the www-data system user, the sudoers rule would be: + `www-data ALL=(signal-user) NOPASSWD: /srv/signal-cli-0.7.2/bin/signal-cli`. + + ### Telegram * Create a Telegram bot by talking to the diff --git a/hc/api/models.py b/hc/api/models.py index 4cab92e9..6c1f202a 100644 --- a/hc/api/models.py +++ b/hc/api/models.py @@ -52,6 +52,7 @@ CHANNEL_KINDS = ( ("spike", "Spike"), ("call", "Phone Call"), ("linenotify", "LINE Notify"), + ("signal", "Signal"), ) PO_PRIORITIES = {-2: "lowest", -1: "low", 0: "normal", 1: "high", 2: "emergency"} @@ -471,6 +472,8 @@ class Channel(models.Model): return transports.Call(self) elif self.kind == "linenotify": return transports.LineNotify(self) + elif self.kind == "signal": + return transports.Signal(self) else: raise NotImplementedError("Unknown channel kind: %s" % self.kind) @@ -649,7 +652,7 @@ class Channel(models.Model): @property def phone_number(self): - assert self.kind in ("call", "sms", "whatsapp") + assert self.kind in ("call", "sms", "whatsapp", "signal") if self.value.startswith("{"): doc = json.loads(self.value) return doc["value"] @@ -714,6 +717,18 @@ class Channel(models.Model): doc = json.loads(self.value) return doc["down"] + @property + def signal_notify_up(self): + assert self.kind == "signal" + doc = json.loads(self.value) + return doc["up"] + + @property + def signal_notify_down(self): + assert self.kind == "signal" + doc = json.loads(self.value) + return doc["down"] + @property def opsgenie_key(self): assert self.kind == "opsgenie" diff --git a/hc/api/tests/test_notify_email.py b/hc/api/tests/test_notify_email.py index cbcc4408..ae989868 100644 --- a/hc/api/tests/test_notify_email.py +++ b/hc/api/tests/test_notify_email.py @@ -9,7 +9,7 @@ from hc.api.models import Channel, Check, Notification, Ping from hc.test import BaseTestCase -class NotifyTestCase(BaseTestCase): +class NotifyEmailTestCase(BaseTestCase): def setUp(self): super().setUp() diff --git a/hc/api/tests/test_notify_signal.py b/hc/api/tests/test_notify_signal.py new file mode 100644 index 00000000..46f25f58 --- /dev/null +++ b/hc/api/tests/test_notify_signal.py @@ -0,0 +1,82 @@ +# coding: utf-8 + +from datetime import timedelta as td +import json +from unittest.mock import patch + +from django.utils.timezone import now +from django.test.utils import override_settings +from hc.api.models import Channel, Check, Notification +from hc.test import BaseTestCase + + +@override_settings(SIGNAL_CLI_USERNAME="+987654321") +class NotifySignalTestCase(BaseTestCase): + def setUp(self): + super().setUp() + + self.check = Check(project=self.project) + self.check.name = "Daily Backup" + self.check.status = "down" + self.check.last_ping = now() - td(minutes=61) + self.check.save() + + payload = {"value": "+123456789", "up": True, "down": True} + self.channel = Channel(project=self.project) + self.channel.kind = "signal" + self.channel.value = json.dumps(payload) + self.channel.save() + self.channel.checks.add(self.check) + + @patch("hc.api.transports.subprocess.run") + def test_it_works(self, mock_run): + mock_run.return_value.returncode = 0 + + self.channel.notify(self.check) + + n = Notification.objects.get() + self.assertEqual(n.error, "") + + self.assertTrue(mock_run.called) + args, kwargs = mock_run.call_args + cmd = " ".join(args[0]) + + self.assertIn("-u +987654321", cmd) + self.assertIn("send +123456789", cmd) + + @patch("hc.api.transports.subprocess.run") + def test_it_obeys_down_flag(self, mock_run): + payload = {"value": "+123456789", "up": True, "down": False} + self.channel.value = json.dumps(payload) + self.channel.save() + + self.channel.notify(self.check) + + # This channel should not notify on "down" events: + self.assertEqual(Notification.objects.count(), 0) + self.assertFalse(mock_run.called) + + @patch("hc.api.transports.subprocess.run") + def test_it_requires_signal_cli_username(self, mock_run): + + with override_settings(SIGNAL_CLI_USERNAME=None): + self.channel.notify(self.check) + + n = Notification.objects.get() + self.assertEqual(n.error, "Signal notifications are not enabled") + + self.assertFalse(mock_run.called) + + @patch("hc.api.transports.subprocess.run") + def test_it_does_not_escape_special_characters(self, mock_run): + self.check.name = "Foo & Bar" + self.check.save() + + mock_run.return_value.returncode = 0 + self.channel.notify(self.check) + + self.assertTrue(mock_run.called) + args, kwargs = mock_run.call_args + cmd = " ".join(args[0]) + + self.assertIn("Foo & Bar", cmd) diff --git a/hc/api/transports.py b/hc/api/transports.py index ca5ca3ac..734c7ace 100644 --- a/hc/api/transports.py +++ b/hc/api/transports.py @@ -6,6 +6,7 @@ from django.utils import timezone from django.utils.html import escape import json import requests +import subprocess from urllib.parse import quote, urlencode from hc.accounts.models import Profile @@ -659,3 +660,27 @@ class LineNotify(HttpTransport): } payload = {"message": tmpl("linenotify_message.html", check=check)} return self.post(self.URL, headers=headers, params=payload) + + +class Signal(Transport): + def is_noop(self, check): + if check.status == "down": + return not self.channel.signal_notify_down + else: + return not self.channel.signal_notify_up + + def notify(self, check): + if not settings.SIGNAL_CLI_USERNAME: + return "Signal notifications are not enabled" + + text = tmpl("signal_message.html", check=check, site_name=settings.SITE_NAME) + + args = settings.SIGNAL_CLI_CMD.split() + args.extend(["-u", settings.SIGNAL_CLI_USERNAME]) + args.extend(["send", self.channel.phone_number]) + args.extend(["-m", text]) + + result = subprocess.run(args, timeout=10) + + if result.returncode != 0: + return "signal-cli returned exit code %d" % result.returncode diff --git a/hc/front/tests/test_add_signal.py b/hc/front/tests/test_add_signal.py new file mode 100644 index 00000000..de6e8a0a --- /dev/null +++ b/hc/front/tests/test_add_signal.py @@ -0,0 +1,60 @@ +from django.test.utils import override_settings +from hc.api.models import Channel +from hc.test import BaseTestCase + + +@override_settings(SIGNAL_CLI_USERNAME="+123456789") +class AddSignalTestCase(BaseTestCase): + def setUp(self): + super().setUp() + self.url = "/projects/%s/add_signal/" % self.project.code + + def test_instructions_work(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertContains(r, "Get a Signal message") + + def test_it_creates_channel(self): + form = { + "label": "My Phone", + "value": "+1234567890", + "down": "true", + "up": "true", + } + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertRedirects(r, self.channels_url) + + c = Channel.objects.get() + self.assertEqual(c.kind, "signal") + self.assertEqual(c.phone_number, "+1234567890") + self.assertEqual(c.name, "My Phone") + self.assertTrue(c.signal_notify_down) + self.assertTrue(c.signal_notify_up) + self.assertEqual(c.project, self.project) + + def test_it_obeys_up_down_flags(self): + form = {"label": "My Phone", "value": "+1234567890"} + + self.client.login(username="alice@example.org", password="password") + r = self.client.post(self.url, form) + self.assertRedirects(r, self.channels_url) + + c = Channel.objects.get() + self.assertFalse(c.signal_notify_down) + self.assertFalse(c.signal_notify_up) + + @override_settings(SIGNAL_CLI_USERNAME=None) + def test_it_handles_unset_username(self): + self.client.login(username="alice@example.org", password="password") + r = self.client.get(self.url) + self.assertEqual(r.status_code, 404) + + def test_it_requires_rw_access(self): + self.bobs_membership.rw = False + self.bobs_membership.save() + + self.client.login(username="bob@example.org", password="password") + r = self.client.get(self.url) + self.assertEqual(r.status_code, 403) diff --git a/hc/front/urls.py b/hc/front/urls.py index 7b585b26..0f20f490 100644 --- a/hc/front/urls.py +++ b/hc/front/urls.py @@ -77,6 +77,7 @@ project_urls = [ path("add_zulip/", views.add_zulip, name="hc-add-zulip"), path("add_spike/", views.add_spike, name="hc-add-spike"), path("add_linenotify/", views.add_linenotify, name="hc-add-linenotify"), + path("add_signal/", views.add_signal, name="hc-add-signal"), path("badges/", views.badges, name="hc-badges"), path("checks/", views.my_checks, name="hc-checks"), path("checks/add/", views.add_check, name="hc-add-check"), diff --git a/hc/front/views.py b/hc/front/views.py index 7d045a8a..9be6f049 100644 --- a/hc/front/views.py +++ b/hc/front/views.py @@ -299,6 +299,7 @@ def index(request): "enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None, "enable_pushover": settings.PUSHOVER_API_TOKEN is not None, "enable_shell": settings.SHELL_ENABLED is True, + "enable_signal": settings.SIGNAL_CLI_USERNAME is not None, "enable_slack_btn": settings.SLACK_CLIENT_ID is not None, "enable_sms": settings.TWILIO_AUTH is not None, "enable_telegram": settings.TELEGRAM_TOKEN is not None, @@ -762,6 +763,7 @@ def channels(request, code): "enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None, "enable_pushover": settings.PUSHOVER_API_TOKEN is not None, "enable_shell": settings.SHELL_ENABLED is True, + "enable_signal": settings.SIGNAL_CLI_USERNAME is not None, "enable_slack_btn": settings.SLACK_CLIENT_ID is not None, "enable_sms": settings.TWILIO_AUTH is not None, "enable_telegram": settings.TELEGRAM_TOKEN is not None, @@ -1632,6 +1634,38 @@ def add_whatsapp(request, code): return render(request, "integrations/add_whatsapp.html", ctx) +@require_setting("SIGNAL_CLI_USERNAME") +@login_required +def add_signal(request, code): + project = _get_rw_project_for_user(request, code) + if request.method == "POST": + form = forms.AddSmsForm(request.POST) + if form.is_valid(): + channel = Channel(project=project, kind="signal") + channel.name = form.cleaned_data["label"] + channel.value = json.dumps( + { + "value": form.cleaned_data["value"], + "up": form.cleaned_data["up"], + "down": form.cleaned_data["down"], + } + ) + channel.save() + + channel.assign_all_checks() + return redirect("hc-channels", project.code) + else: + form = forms.AddSmsForm() + + ctx = { + "page": "channels", + "project": project, + "form": form, + "profile": project.owner_profile, + } + return render(request, "integrations/add_signal.html", ctx) + + @require_setting("TRELLO_APP_KEY") @login_required def add_trello(request, code): diff --git a/hc/settings.py b/hc/settings.py index ab40ea9d..daa9664a 100644 --- a/hc/settings.py +++ b/hc/settings.py @@ -230,6 +230,10 @@ SHELL_ENABLED = envbool("SHELL_ENABLED", "False") LINENOTIFY_CLIENT_ID = os.getenv("LINENOTIFY_CLIENT_ID") LINENOTIFY_CLIENT_SECRET = os.getenv("LINENOTIFY_CLIENT_SECRET") +# Signal +SIGNAL_CLI_USERNAME = os.getenv("SIGNAL_CLI_USERNAME") +SIGNAL_CLI_CMD = os.getenv("SIGNAL_CLI_CMD", "signal-cli") + if os.path.exists(os.path.join(BASE_DIR, "hc/local_settings.py")): from .local_settings import * else: diff --git a/static/css/icomoon.css b/static/css/icomoon.css index 0b18c9fc..5e1e896b 100644 --- a/static/css/icomoon.css +++ b/static/css/icomoon.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icomoon'; - src: url('../fonts/icomoon.eot?qka09c'); - src: url('../fonts/icomoon.eot?qka09c#iefix') format('embedded-opentype'), - url('../fonts/icomoon.ttf?qka09c') format('truetype'), - url('../fonts/icomoon.woff?qka09c') format('woff'), - url('../fonts/icomoon.svg?qka09c#icomoon') format('svg'); + src: url('../fonts/icomoon.eot?y9u69e'); + src: url('../fonts/icomoon.eot?y9u69e#iefix') format('embedded-opentype'), + url('../fonts/icomoon.ttf?y9u69e') format('truetype'), + url('../fonts/icomoon.woff?y9u69e') format('woff'), + url('../fonts/icomoon.svg?y9u69e#icomoon') format('svg'); font-weight: normal; font-style: normal; } @@ -24,6 +24,10 @@ -moz-osx-font-smoothing: grayscale; } +.icon-signal:before { + content: "\e91c"; + color: #2592e9; +} .icon-linenotify:before { content: "\e91b"; color: #00c300; diff --git a/static/fonts/icomoon.eot b/static/fonts/icomoon.eot index 23dc6233..a5439705 100644 Binary files a/static/fonts/icomoon.eot and b/static/fonts/icomoon.eot differ diff --git a/static/fonts/icomoon.svg b/static/fonts/icomoon.svg index 6c7ea3a0..f4270f68 100644 --- a/static/fonts/icomoon.svg +++ b/static/fonts/icomoon.svg @@ -46,4 +46,5 @@ + \ No newline at end of file diff --git a/static/fonts/icomoon.ttf b/static/fonts/icomoon.ttf index c1742fa5..06389143 100644 Binary files a/static/fonts/icomoon.ttf and b/static/fonts/icomoon.ttf differ diff --git a/static/fonts/icomoon.woff b/static/fonts/icomoon.woff index c13e08bf..6b419e8f 100644 Binary files a/static/fonts/icomoon.woff and b/static/fonts/icomoon.woff differ diff --git a/static/img/integrations/signal.png b/static/img/integrations/signal.png new file mode 100644 index 00000000..b78512bf Binary files /dev/null and b/static/img/integrations/signal.png differ diff --git a/templates/front/channels.html b/templates/front/channels.html index c4ba7d06..8535680e 100644 --- a/templates/front/channels.html +++ b/templates/front/channels.html @@ -91,6 +91,14 @@ {% elif ch.zulip_type == "private" %} user {{ ch.zulip_to}} {% endif %} + {% elif ch.kind == "signal" %} + Signal to {{ ch.phone_number }} + {% if ch.signal_notify_down and not ch.signal_notify_up %} + (down only) + {% endif %} + {% if ch.signal_notify_up and not ch.signal_notify_down %} + (up only) + {% endif %} {% else %} {{ ch.get_kind_display }} {% endif %} @@ -355,6 +363,17 @@ {% endif %} + {% if enable_signal %} +
  • + Signal icon + +

    Signal

    +

    Get a Signal message when a check goes up or down.

    + Add Integration +
  • + {% endif %} + {% if enable_sms %}
  • {% endif %} + {% if enable_signal %} +
    +
    + +

    + {% trans "Signal" %}
    + {% trans "Chat" %} +

    +
    +
    + {% endif %} + {% if enable_sms %}
    diff --git a/templates/integrations/add_signal.html b/templates/integrations/add_signal.html new file mode 100644 index 00000000..461759d8 --- /dev/null +++ b/templates/integrations/add_signal.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% load humanize static hc_extras %} + +{% block title %}Add Signal Integration - {{ site_name }}{% endblock %} + + +{% block content %} +
    +
    +

    Signal

    + +

    Get a Signal message when a check goes up or down.

    + +

    Integration Settings

    + +
    + {% csrf_token %} +
    + +
    + + + {% if form.label.errors %} +
    + {{ form.label.errors|join:"" }} +
    + {% else %} + + Optional. If you add multiple phone numbers, + the labels will help you tell them apart. + + {% endif %} +
    +
    + +
    + +
    + + + {% if form.value.errors %} +
    + {{ form.value.errors|join:"" }} +
    + {% else %} + + Make sure the phone number starts with "+" and has the + country code. + + {% endif %} +
    +
    + +
    + +
    + + +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/templates/integrations/signal_message.html b/templates/integrations/signal_message.html new file mode 100644 index 00000000..253c8dac --- /dev/null +++ b/templates/integrations/signal_message.html @@ -0,0 +1,7 @@ +{% load humanize %}{% spaceless %} +{% if check.status == "down" %} + The check “{{ check.name_then_code|safe }}” is DOWN. Last ping was {{ check.last_ping|naturaltime }}. +{% else %} + The check “{{ check.name_then_code|safe }}” is now UP. +{% endif %} +{% endspaceless %}