forked from GithubBackups/healthchecks
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.
This commit is contained in:
parent
a80b831eea
commit
74ed15e0aa
33
README.md
33
README.md
@ -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_ENABLED | `"False"`
|
||||||
| SIGNAL_CLI_CMD | `signal-cli` | Path to the signal-cli executable
|
|
||||||
| 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
|
notifications. Healthcecks interacts with signal-cli over DBus.
|
||||||
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:
|
To enable the Signal integration:
|
||||||
|
|
||||||
* Download and install signal-cli in your preferred location
|
* Set up and configure signal-cli to listen on DBus system bus ([instructions](https://github.com/AsamK/signal-cli/wiki/DBus-service)).
|
||||||
(for example, in `/srv/signal-cli-0.7.2/`).
|
Make sure you can send test messages from command line, using the `dbus-send`
|
||||||
* Register and verify phone number, or [link it](https://github.com/AsamK/signal-cli/wiki/Linking-other-devices-(Provisioning))
|
example given in the signal-cli instructions.
|
||||||
to an existing registration.
|
* Set the `SIGNAL_CLI_ENABLED` environment variable to `True`.
|
||||||
* 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
|
### Telegram
|
||||||
|
@ -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")
|
@patch("hc.api.transports.dbus")
|
||||||
def test_it_works(self, mock_run):
|
@patch("hc.api.transports.Signal.get_service")
|
||||||
mock_run.return_value.returncode = 0
|
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)
|
self.assertTrue(mock_get_service.called)
|
||||||
args, kwargs = mock_run.call_args
|
args, kwargs = mock_get_service.return_value.sendMessage.call_args
|
||||||
cmd = " ".join(args[0])
|
self.assertIn("is DOWN", args[0])
|
||||||
|
self.assertEqual(args[2], ["+123456789"])
|
||||||
|
|
||||||
self.assertIn("-u +987654321", cmd)
|
@patch("hc.api.transports.dbus")
|
||||||
self.assertIn("send +123456789", cmd)
|
@patch("hc.api.transports.Signal.get_service")
|
||||||
|
def test_it_obeys_down_flag(self, mock_get_service, mock_dbus):
|
||||||
@patch("hc.api.transports.subprocess.run")
|
|
||||||
def test_it_obeys_down_flag(self, mock_run):
|
|
||||||
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")
|
self.assertFalse(mock_get_service.called)
|
||||||
def test_it_requires_signal_cli_username(self, mock_run):
|
|
||||||
|
|
||||||
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")
|
@patch("hc.api.transports.dbus")
|
||||||
def test_it_does_not_escape_special_characters(self, mock_run):
|
@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_get_service.return_value.sendMessage.call_args
|
||||||
args, kwargs = mock_run.call_args
|
self.assertIn("Foo & Bar", args[0])
|
||||||
cmd = " ".join(args[0])
|
|
||||||
|
|
||||||
self.assertIn("Foo & Bar", cmd)
|
|
||||||
|
|
||||||
@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)
|
||||||
|
@ -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()
|
try:
|
||||||
args.extend(["-u", settings.SIGNAL_CLI_USERNAME])
|
self.get_service().sendMessage(text, [], [self.channel.phone_number])
|
||||||
args.extend(["send", self.channel.phone_number])
|
except dbus.exceptions.DBusException as e:
|
||||||
args.extend(["-m", text])
|
if "NotFoundException" in str(e):
|
||||||
|
return "Recipient not found"
|
||||||
|
|
||||||
# Need a high timeout because sending the first message to a new
|
return "signal-cli call failed"
|
||||||
# recipient sometimes takes 20+ seconds
|
|
||||||
result = subprocess.run(args, timeout=30)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
return "signal-cli returned exit code %d" % result.returncode
|
|
||||||
|
@ -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)
|
@override_settings(SIGNAL_CLI_ENABLED=False)
|
||||||
def test_it_handles_unset_sender_username(self):
|
def test_it_handles_disabled_integration(self):
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", 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)
|
||||||
|
@ -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)
|
||||||
|
@ -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_ENABLED = envbool("SIGNAL_CLI_ENABLED", "False")
|
||||||
SIGNAL_CLI_CMD = os.getenv("SIGNAL_CLI_CMD", "signal-cli")
|
|
||||||
|
|
||||||
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…
x
Reference in New Issue
Block a user