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 @@
Get a Signal message when a check goes up or down.
+ Add Integration +Get a Signal message when a check goes up or down.
+ +