refactor(signal): use native signal-cli service

This commit is contained in:
2026-09-29 20:03:12 -04:00
parent 8196d6fc98
commit 932e6c9ab1
7 changed files with 129 additions and 86 deletions
+46
View File
@@ -0,0 +1,46 @@
{ pkgs, ... }:
{
environment.systemPackages = [
pkgs.signal-cli
];
users = {
groups.signal-cli = { };
users.signal-cli = {
isSystemUser = true;
group = "signal-cli";
home = "/var/lib/signal-cli";
};
};
systemd.tmpfiles.rules = [
"d /var/lib/signal-cli 0700 signal-cli signal-cli - -"
"Z /var/lib/signal-cli - signal-cli signal-cli - -"
];
systemd.services.signal-cli = {
description = "Signal CLI JSON-RPC service";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
unitConfig.RequiresMountsFor = [ "/var/lib/signal-cli" ];
serviceConfig = {
Type = "simple";
User = "signal-cli";
Group = "signal-cli";
ExecStart = "${pkgs.signal-cli}/bin/signal-cli --data-dir /var/lib/signal-cli daemon --socket /run/signal-cli/socket";
Restart = "on-failure";
RestartSec = "5s";
SuccessExitStatus = 143;
RuntimeDirectory = "signal-cli";
RuntimeDirectoryMode = "0750";
UMask = "0007";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectHome = true;
ProtectSystem = "strict";
ReadWritePaths = [ "/var/lib/signal-cli" ];
};
};
}
+1 -5
View File
@@ -7,11 +7,7 @@
}:
let
cfg = config.services.snapshot_manager;
snapshotManagerPackages =
ps: with ps; [
httpx
typer
];
snapshotManagerPackages = ps: with ps; [ typer ];
in
{
options = {
+32 -26
View File
@@ -2,23 +2,19 @@
from __future__ import annotations
import json
import logging
import socket
from os import getenv
import httpx
logger = logging.getLogger(__name__)
SIGNAL_API_URL = "http://localhost:8989/v2/send"
SIGNAL_API_TIMEOUT = 4.0
def signal_alert(body: str, title: str = "") -> None:
def signal_alert(body: str) -> None:
"""Send a signal alert.
Args:
body (str): The body of the alert.
title (str, optional): The title of the alert. Defaults to "".
"""
from_phone = getenv("SIGNAL_ALERT_FROM_PHONE")
to_phone = getenv("SIGNAL_ALERT_TO_PHONE")
@@ -26,25 +22,35 @@ def signal_alert(body: str, title: str = "") -> None:
logger.info("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
return
# Apprise's Signal integration did not support titles, so preserve that behavior.
if title:
logger.debug("Signal does not support notification titles; ignoring title")
signal_rpc_id = "signal-alert"
request = {
"jsonrpc": "2.0",
"method": "send",
"params": {
"account": from_phone,
"recipient": [to_phone],
"message": body,
},
"id": signal_rpc_id,
}
try:
response = httpx.post(
SIGNAL_API_URL,
json={
"message": body,
"number": from_phone,
"recipients": [to_phone],
"text_mode": "normal",
},
timeout=SIGNAL_API_TIMEOUT,
follow_redirects=True,
)
except httpx.HTTPError:
logger.exception("Unable to contact the Signal API")
return
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
client.settimeout(4.0)
client.connect("/run/signal-cli/socket")
client.sendall(json.dumps(request).encode() + b"\n")
if response.status_code not in {httpx.codes.OK, httpx.codes.CREATED}:
logger.error("Signal API returned HTTP status %d", response.status_code)
buffer = b""
while chunk := client.recv(65536):
buffer += chunk
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", maxsplit=1)
response = json.loads(line)
if response.get("id") == signal_rpc_id:
if error := response.get("error"):
logger.error("Signal RPC returned an error: %s", error)
return
logger.error("Signal RPC socket closed before returning a response")
except OSError, json.JSONDecodeError:
logger.exception("Unable to communicate with signal-cli")
+1
View File
@@ -10,6 +10,7 @@ in
"${inputs.self}/common/optional/docker.nix"
"${inputs.self}/common/optional/monitoring-agent.nix"
"${inputs.self}/common/optional/ssh_decrypt.nix"
"${inputs.self}/common/optional/signal-cli.nix"
"${inputs.self}/common/optional/syncthing_base.nix"
"${inputs.self}/common/optional/tailscale.nix"
"${inputs.self}/common/optional/update.nix"
@@ -1,18 +0,0 @@
let
vars = import ../vars.nix;
in
{
virtualisation.oci-containers.containers.signal_cli_rest_api = {
image = "bbernhard/signal-cli-rest-api:0.199-dev";
ports = [
"8989:8080"
];
volumes = [
"${vars.docker_configs}/signal-cli-config:/home/.local/share/signal-cli"
];
environment = {
MODE = "json-rpc";
};
autoStart = true;
};
}
-2
View File
@@ -13,7 +13,6 @@ let
open_webui = 8080;
postgresql = 5432;
share = 8091;
signal_cli = 8989;
syncthing = 8384;
};
in
@@ -40,7 +39,6 @@ in
ports.ollama
ports.open_webui
ports.postgresql
ports.signal_cli
ports.syncthing
];
};
+49 -35
View File
@@ -2,11 +2,11 @@
from __future__ import annotations
import json
import socket
from os import environ
from typing import TYPE_CHECKING
import httpx
from python.signal_alert import signal_alert
if TYPE_CHECKING:
@@ -14,68 +14,82 @@ if TYPE_CHECKING:
def test_signal_alert(mocker: MockerFixture) -> None:
"""test_signal_alert."""
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
mock_logger = mocker.patch("python.signal_alert.logger")
mock_response = mocker.MagicMock(spec=httpx.Response, status_code=httpx.codes.CREATED)
mock_post = mocker.patch("python.signal_alert.httpx.post", return_value=mock_response)
mock_socket = mocker.patch("python.signal_alert.socket.socket")
client = mock_socket.return_value.__enter__.return_value
client.recv.side_effect = [b'{"jsonrpc":"2.0","result":{},"id":"signal-alert"}\n']
signal_alert("test")
mock_logger.info.assert_not_called()
mock_post.assert_called_once_with(
"http://localhost:8989/v2/send",
json={
mock_socket.assert_called_once_with(socket.AF_UNIX, socket.SOCK_STREAM)
client.settimeout.assert_called_once_with(4.0)
client.connect.assert_called_once_with("/run/signal-cli/socket")
request = json.loads(client.sendall.call_args.args[0])
assert request == {
"jsonrpc": "2.0",
"method": "send",
"params": {
"account": "1234567890",
"recipient": ["0987654321"],
"message": "test",
"number": "1234567890",
"recipients": ["0987654321"],
"text_mode": "normal",
},
timeout=4.0,
follow_redirects=True,
)
"id": "signal-alert",
}
def test_signal_alert_no_phones(mocker: MockerFixture) -> None:
"""test_signal_alert_no_phones."""
if "SIGNAL_ALERT_FROM_PHONE" in environ:
del environ["SIGNAL_ALERT_FROM_PHONE"]
if "SIGNAL_ALERT_TO_PHONE" in environ:
del environ["SIGNAL_ALERT_TO_PHONE"]
environ.pop("SIGNAL_ALERT_FROM_PHONE", None)
environ.pop("SIGNAL_ALERT_TO_PHONE", None)
mock_logger = mocker.patch("python.signal_alert.logger")
signal_alert("test")
mock_logger.info.assert_called_once_with("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
def test_signal_alert_http_error(mocker: MockerFixture) -> None:
"""HTTP errors are logged rather than propagated."""
def test_signal_alert_socket_error(mocker: MockerFixture) -> None:
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
mock_logger = mocker.patch("python.signal_alert.logger")
request = httpx.Request("POST", "http://localhost:8989/v2/send")
mocker.patch(
"python.signal_alert.httpx.post",
side_effect=httpx.ConnectError("connection failed", request=request),
mocker.patch("python.signal_alert.socket.socket", side_effect=ConnectionError("connection failed"))
signal_alert("test")
mock_logger.exception.assert_called_once_with("Unable to communicate with signal-cli")
def test_signal_alert_rpc_error(mocker: MockerFixture) -> None:
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
mock_logger = mocker.patch("python.signal_alert.logger")
mock_socket = mocker.patch("python.signal_alert.socket.socket")
client = mock_socket.return_value.__enter__.return_value
client.recv.side_effect = [b'{"jsonrpc":"2.0","error":{"code":-1,"message":"failed"},"id":"signal-alert"}\n']
signal_alert("test")
mock_logger.error.assert_called_once_with(
"Signal RPC returned an error: %s",
{"code": -1, "message": "failed"},
)
signal_alert("test")
mock_logger.exception.assert_called_once_with("Unable to contact the Signal API")
def test_signal_alert_unsuccessful_response(mocker: MockerFixture) -> None:
"""Unexpected response statuses are logged."""
def test_signal_alert_ignores_notifications(mocker: MockerFixture) -> None:
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
mock_logger = mocker.patch("python.signal_alert.logger")
mock_response = mocker.MagicMock(spec=httpx.Response, status_code=httpx.codes.BAD_GATEWAY)
mocker.patch("python.signal_alert.httpx.post", return_value=mock_response)
mock_socket = mocker.patch("python.signal_alert.socket.socket")
client = mock_socket.return_value.__enter__.return_value
client.recv.side_effect = [
b'{"jsonrpc":"2.0","method":"receive"}\n{"jsonrpc":"2.0","result":{},"id":"signal-alert"}\n'
]
signal_alert("test")
mock_logger.error.assert_called_once_with("Signal API returned HTTP status %d", httpx.codes.BAD_GATEWAY)
assert client.recv.call_count == 1