Browse Source

Update the signal integration to use DBus

The initial implementation was just calling signal-cli directly
using `subprocess.run`.

Going with DBus makes it easier to shield signal-cli from the
rest of the system. It also makes sure the signal-cli daemon is
running in the background and receiving messages. This is important
when a recipient does the "Reset secure connection" from the app. We
must receive their new keys, otherwise our future messages will
appear as "bad encrypted message" for them.
pull/468/head
Pēteris Caune 4 years ago
parent
commit
74ed15e0aa
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
6 changed files with 56 additions and 72 deletions
  1. +6
    -27
      README.md
  2. +26
    -26
      hc/api/tests/test_notify_signal.py
  3. +18
    -12
      hc/api/transports.py
  4. +2
    -2
      hc/front/tests/test_add_signal.py
  5. +3
    -3
      hc/front/views.py
  6. +1
    -2
      hc/settings.py

+ 6
- 27
README.md View File

@ -136,8 +136,7 @@ Healthchecks reads configuration from the following environment variables:
| PUSHOVER_SUBSCRIPTION_URL | `None` | PUSHOVER_SUBSCRIPTION_URL | `None`
| REMOTE_USER_HEADER | `None` | See [External Authentication](#external-authentication) for details. | REMOTE_USER_HEADER | `None` | See [External Authentication](#external-authentication) for details.
| SHELL_ENABLED | `"False"` | SHELL_ENABLED | `"False"`
| SIGNAL_CLI_USERNAME | `None`
| SIGNAL_CLI_CMD | `signal-cli` | Path to the signal-cli executable
| SIGNAL_CLI_ENABLED | `"False"`
| SLACK_CLIENT_ID | `None` | SLACK_CLIENT_ID | `None`
| SLACK_CLIENT_SECRET | `None` | SLACK_CLIENT_SECRET | `None`
| TELEGRAM_BOT_NAME | `"ExampleBot"` | TELEGRAM_BOT_NAME | `"ExampleBot"`
@ -412,34 +411,14 @@ To enable the Pushover integration, you will need to:
### Signal ### Signal
Healthchecks uses [signal-cli](https://github.com/AsamK/signal-cli) to send 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.
notifications. Healthcecks interacts with signal-cli over DBus.
To enable the Signal 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`.
* Set up and configure signal-cli to listen on DBus system bus ([instructions](https://github.com/AsamK/signal-cli/wiki/DBus-service)).
Make sure you can send test messages from command line, using the `dbus-send`
example given in the signal-cli instructions.
* Set the `SIGNAL_CLI_ENABLED` environment variable to `True`.
### Telegram ### Telegram


+ 26
- 26
hc/api/tests/test_notify_signal.py View File

@ -28,24 +28,22 @@ class NotifySignalTestCase(BaseTestCase):
self.channel.save() self.channel.save()
self.channel.checks.add(self.check) 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
@patch("hc.api.transports.dbus")
@patch("hc.api.transports.Signal.get_service")
def test_it_works(self, mock_get_service, mock_dbus):
self.channel.notify(self.check) self.channel.notify(self.check)
n = Notification.objects.get() n = Notification.objects.get()
self.assertEqual(n.error, "") 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)
self.assertTrue(mock_get_service.called)
args, kwargs = mock_get_service.return_value.sendMessage.call_args
self.assertIn("is DOWN", args[0])
self.assertEqual(args[2], ["+123456789"])
@patch("hc.api.transports.subprocess.run")
def test_it_obeys_down_flag(self, mock_run):
@patch("hc.api.transports.dbus")
@patch("hc.api.transports.Signal.get_service")
def test_it_obeys_down_flag(self, mock_get_service, mock_dbus):
payload = {"value": "+123456789", "up": True, "down": False} payload = {"value": "+123456789", "up": True, "down": False}
self.channel.value = json.dumps(payload) self.channel.value = json.dumps(payload)
self.channel.save() self.channel.save()
@ -54,35 +52,35 @@ class NotifySignalTestCase(BaseTestCase):
# This channel should not notify on "down" events: # This channel should not notify on "down" events:
self.assertEqual(Notification.objects.count(), 0) 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):
self.assertFalse(mock_get_service.called)
with override_settings(SIGNAL_CLI_USERNAME=None):
@patch("hc.api.transports.dbus")
@patch("hc.api.transports.Signal.get_service")
def test_it_requires_signal_cli_enabled(self, mock_get_service, mock_dbus):
with override_settings(SIGNAL_CLI_ENABLED=False):
self.channel.notify(self.check) self.channel.notify(self.check)
n = Notification.objects.get() n = Notification.objects.get()
self.assertEqual(n.error, "Signal notifications are not enabled") self.assertEqual(n.error, "Signal notifications are not enabled")
self.assertFalse(mock_run.called)
self.assertFalse(mock_get_service.called)
@patch("hc.api.transports.subprocess.run")
def test_it_does_not_escape_special_characters(self, mock_run):
@patch("hc.api.transports.dbus")
@patch("hc.api.transports.Signal.get_service")
def test_it_does_not_escape_special_characters(self, mock_get_service, mock_dbus):
self.check.name = "Foo & Bar" self.check.name = "Foo & Bar"
self.check.save() self.check.save()
mock_run.return_value.returncode = 0
self.channel.notify(self.check) 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)
args, kwargs = mock_get_service.return_value.sendMessage.call_args
self.assertIn("Foo & Bar", args[0])
@override_settings(SECRET_KEY="test-secret") @override_settings(SECRET_KEY="test-secret")
def test_it_obeys_rate_limit(self):
@patch("hc.api.transports.dbus")
@patch("hc.api.transports.Signal.get_service")
def test_it_obeys_rate_limit(self, mock_get_service, mock_dbus):
# "2862..." is sha1("+123456789test-secret") # "2862..." is sha1("+123456789test-secret")
obj = TokenBucket(value="signal-2862991ccaa15c8856e7ee0abaf3448fb3c292e0") obj = TokenBucket(value="signal-2862991ccaa15c8856e7ee0abaf3448fb3c292e0")
obj.tokens = 0 obj.tokens = 0
@ -91,3 +89,5 @@ class NotifySignalTestCase(BaseTestCase):
self.channel.notify(self.check) self.channel.notify(self.check)
n = Notification.objects.first() n = Notification.objects.first()
self.assertEqual(n.error, "Rate limit exceeded") self.assertEqual(n.error, "Rate limit exceeded")
self.assertFalse(mock_get_service.called)

+ 18
- 12
hc/api/transports.py View File

@ -6,7 +6,6 @@ from django.utils import timezone
from django.utils.html import escape from django.utils.html import escape
import json import json
import requests import requests
import subprocess
from urllib.parse import quote, urlencode from urllib.parse import quote, urlencode
from hc.accounts.models import Profile from hc.accounts.models import Profile
@ -19,6 +18,12 @@ except ImportError:
# Enforce # Enforce
settings.APPRISE_ENABLED = False settings.APPRISE_ENABLED = False
try:
import dbus
except ImportError:
# Enforce
settings.SIGNAL_CLI_ENABLED = False
def tmpl(template_name, **ctx): def tmpl(template_name, **ctx):
template_path = "integrations/%s" % template_name template_path = "integrations/%s" % template_name
@ -669,8 +674,13 @@ class Signal(Transport):
else: else:
return not self.channel.signal_notify_up return not self.channel.signal_notify_up
def get_service(self):
bus = dbus.SystemBus()
signal_object = bus.get_object("org.asamk.Signal", "/org/asamk/Signal")
return dbus.Interface(signal_object, "org.asamk.Signal")
def notify(self, check): def notify(self, check):
if not settings.SIGNAL_CLI_USERNAME:
if not settings.SIGNAL_CLI_ENABLED:
return "Signal notifications are not enabled" return "Signal notifications are not enabled"
from hc.api.models import TokenBucket from hc.api.models import TokenBucket
@ -680,14 +690,10 @@ class Signal(Transport):
text = tmpl("signal_message.html", check=check, site_name=settings.SITE_NAME) 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])
# Need a high timeout because sending the first message to a new
# recipient sometimes takes 20+ seconds
result = subprocess.run(args, timeout=30)
try:
self.get_service().sendMessage(text, [], [self.channel.phone_number])
except dbus.exceptions.DBusException as e:
if "NotFoundException" in str(e):
return "Recipient not found"
if result.returncode != 0:
return "signal-cli returned exit code %d" % result.returncode
return "signal-cli call failed"

+ 2
- 2
hc/front/tests/test_add_signal.py View File

@ -45,8 +45,8 @@ class AddSignalTestCase(BaseTestCase):
self.assertFalse(c.signal_notify_down) self.assertFalse(c.signal_notify_down)
self.assertFalse(c.signal_notify_up) self.assertFalse(c.signal_notify_up)
@override_settings(SIGNAL_CLI_USERNAME=None)
def test_it_handles_unset_sender_username(self):
@override_settings(SIGNAL_CLI_ENABLED=False)
def test_it_handles_disabled_integration(self):
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
r = self.client.get(self.url) r = self.client.get(self.url)
self.assertEqual(r.status_code, 404) self.assertEqual(r.status_code, 404)


+ 3
- 3
hc/front/views.py View File

@ -299,7 +299,7 @@ def index(request):
"enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None, "enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None,
"enable_pushover": settings.PUSHOVER_API_TOKEN is not None, "enable_pushover": settings.PUSHOVER_API_TOKEN is not None,
"enable_shell": settings.SHELL_ENABLED is True, "enable_shell": settings.SHELL_ENABLED is True,
"enable_signal": settings.SIGNAL_CLI_USERNAME is not None,
"enable_signal": settings.SIGNAL_CLI_ENABLED is True,
"enable_slack_btn": settings.SLACK_CLIENT_ID is not None, "enable_slack_btn": settings.SLACK_CLIENT_ID is not None,
"enable_sms": settings.TWILIO_AUTH is not None, "enable_sms": settings.TWILIO_AUTH is not None,
"enable_telegram": settings.TELEGRAM_TOKEN is not None, "enable_telegram": settings.TELEGRAM_TOKEN is not None,
@ -763,7 +763,7 @@ def channels(request, code):
"enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None, "enable_pushbullet": settings.PUSHBULLET_CLIENT_ID is not None,
"enable_pushover": settings.PUSHOVER_API_TOKEN is not None, "enable_pushover": settings.PUSHOVER_API_TOKEN is not None,
"enable_shell": settings.SHELL_ENABLED is True, "enable_shell": settings.SHELL_ENABLED is True,
"enable_signal": settings.SIGNAL_CLI_USERNAME is not None,
"enable_signal": settings.SIGNAL_CLI_ENABLED is True,
"enable_slack_btn": settings.SLACK_CLIENT_ID is not None, "enable_slack_btn": settings.SLACK_CLIENT_ID is not None,
"enable_sms": settings.TWILIO_AUTH is not None, "enable_sms": settings.TWILIO_AUTH is not None,
"enable_telegram": settings.TELEGRAM_TOKEN is not None, "enable_telegram": settings.TELEGRAM_TOKEN is not None,
@ -1628,7 +1628,7 @@ def add_whatsapp(request, code):
return render(request, "integrations/add_whatsapp.html", ctx) return render(request, "integrations/add_whatsapp.html", ctx)
@require_setting("SIGNAL_CLI_USERNAME")
@require_setting("SIGNAL_CLI_ENABLED")
@login_required @login_required
def add_signal(request, code): def add_signal(request, code):
project = _get_rw_project_for_user(request, code) project = _get_rw_project_for_user(request, code)


+ 1
- 2
hc/settings.py View File

@ -231,8 +231,7 @@ LINENOTIFY_CLIENT_ID = os.getenv("LINENOTIFY_CLIENT_ID")
LINENOTIFY_CLIENT_SECRET = os.getenv("LINENOTIFY_CLIENT_SECRET") LINENOTIFY_CLIENT_SECRET = os.getenv("LINENOTIFY_CLIENT_SECRET")
# Signal # Signal
SIGNAL_CLI_USERNAME = os.getenv("SIGNAL_CLI_USERNAME")
SIGNAL_CLI_CMD = os.getenv("SIGNAL_CLI_CMD", "signal-cli")
SIGNAL_CLI_ENABLED = envbool("SIGNAL_CLI_ENABLED", "False")
if os.path.exists(os.path.join(BASE_DIR, "hc/local_settings.py")): if os.path.exists(os.path.join(BASE_DIR, "hc/local_settings.py")):
from .local_settings import * from .local_settings import *


Loading…
Cancel
Save