refactor(signal): use native signal-cli service
This commit is contained in:
1 parent
fb58bac89d
commit
e4930e5152
7 files changed
+129
-86
No files matched your search
@@ -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" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,11 +7,7 @@
|
|||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
cfg = config.services.snapshot_manager;
|
cfg = config.services.snapshot_manager;
|
||||||
snapshotManagerPackages =
|
snapshotManagerPackages = ps: with ps; [ typer ];
|
||||||
ps: with ps; [
|
|
||||||
httpx
|
|
||||||
typer
|
|
||||||
];
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options = {
|
options = {
|
||||||
|
|||||||
+32
-26
@@ -2,23 +2,19 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
from os import getenv
|
from os import getenv
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SIGNAL_API_URL = "http://localhost:8989/v2/send"
|
|
||||||
SIGNAL_API_TIMEOUT = 4.0
|
|
||||||
|
|
||||||
|
def signal_alert(body: str) -> None:
|
||||||
def signal_alert(body: str, title: str = "") -> None:
|
|
||||||
"""Send a signal alert.
|
"""Send a signal alert.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
body (str): The body of the alert.
|
body (str): The body of the alert.
|
||||||
title (str, optional): The title of the alert. Defaults to "".
|
|
||||||
"""
|
"""
|
||||||
from_phone = getenv("SIGNAL_ALERT_FROM_PHONE")
|
from_phone = getenv("SIGNAL_ALERT_FROM_PHONE")
|
||||||
to_phone = getenv("SIGNAL_ALERT_TO_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")
|
logger.info("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Apprise's Signal integration did not support titles, so preserve that behavior.
|
signal_rpc_id = "signal-alert"
|
||||||
if title:
|
request = {
|
||||||
logger.debug("Signal does not support notification titles; ignoring title")
|
"jsonrpc": "2.0",
|
||||||
|
"method": "send",
|
||||||
|
"params": {
|
||||||
|
"account": from_phone,
|
||||||
|
"recipient": [to_phone],
|
||||||
|
"message": body,
|
||||||
|
},
|
||||||
|
"id": signal_rpc_id,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = httpx.post(
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||||
SIGNAL_API_URL,
|
client.settimeout(4.0)
|
||||||
json={
|
client.connect("/run/signal-cli/socket")
|
||||||
"message": body,
|
client.sendall(json.dumps(request).encode() + b"\n")
|
||||||
"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
|
|
||||||
|
|
||||||
if response.status_code not in {httpx.codes.OK, httpx.codes.CREATED}:
|
buffer = b""
|
||||||
logger.error("Signal API returned HTTP status %d", response.status_code)
|
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")
|
||||||
@@ -10,6 +10,7 @@ in
|
|||||||
"${inputs.self}/common/optional/docker.nix"
|
"${inputs.self}/common/optional/docker.nix"
|
||||||
"${inputs.self}/common/optional/monitoring-agent.nix"
|
"${inputs.self}/common/optional/monitoring-agent.nix"
|
||||||
"${inputs.self}/common/optional/ssh_decrypt.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/syncthing_base.nix"
|
||||||
"${inputs.self}/common/optional/tailscale.nix"
|
"${inputs.self}/common/optional/tailscale.nix"
|
||||||
"${inputs.self}/common/optional/update.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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ let
|
|||||||
open_webui = 8080;
|
open_webui = 8080;
|
||||||
postgresql = 5432;
|
postgresql = 5432;
|
||||||
share = 8091;
|
share = 8091;
|
||||||
signal_cli = 8989;
|
|
||||||
syncthing = 8384;
|
syncthing = 8384;
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
@@ -40,7 +39,6 @@ in
|
|||||||
ports.ollama
|
ports.ollama
|
||||||
ports.open_webui
|
ports.open_webui
|
||||||
ports.postgresql
|
ports.postgresql
|
||||||
ports.signal_cli
|
|
||||||
ports.syncthing
|
ports.syncthing
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|||||||
+49
-35
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
from os import environ
|
from os import environ
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from python.signal_alert import signal_alert
|
from python.signal_alert import signal_alert
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -14,68 +14,82 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
def test_signal_alert(mocker: MockerFixture) -> None:
|
def test_signal_alert(mocker: MockerFixture) -> None:
|
||||||
"""test_signal_alert."""
|
|
||||||
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
||||||
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
||||||
|
|
||||||
mock_logger = mocker.patch("python.signal_alert.logger")
|
mock_logger = mocker.patch("python.signal_alert.logger")
|
||||||
mock_response = mocker.MagicMock(spec=httpx.Response, status_code=httpx.codes.CREATED)
|
mock_socket = mocker.patch("python.signal_alert.socket.socket")
|
||||||
mock_post = mocker.patch("python.signal_alert.httpx.post", return_value=mock_response)
|
client = mock_socket.return_value.__enter__.return_value
|
||||||
|
client.recv.side_effect = [b'{"jsonrpc":"2.0","result":{},"id":"signal-alert"}\n']
|
||||||
|
|
||||||
signal_alert("test")
|
signal_alert("test")
|
||||||
|
|
||||||
mock_logger.info.assert_not_called()
|
mock_logger.info.assert_not_called()
|
||||||
mock_post.assert_called_once_with(
|
mock_socket.assert_called_once_with(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
"http://localhost:8989/v2/send",
|
client.settimeout.assert_called_once_with(4.0)
|
||||||
json={
|
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",
|
"message": "test",
|
||||||
"number": "1234567890",
|
|
||||||
"recipients": ["0987654321"],
|
|
||||||
"text_mode": "normal",
|
|
||||||
},
|
},
|
||||||
timeout=4.0,
|
"id": "signal-alert",
|
||||||
follow_redirects=True,
|
}
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_signal_alert_no_phones(mocker: MockerFixture) -> None:
|
def test_signal_alert_no_phones(mocker: MockerFixture) -> None:
|
||||||
"""test_signal_alert_no_phones."""
|
environ.pop("SIGNAL_ALERT_FROM_PHONE", None)
|
||||||
if "SIGNAL_ALERT_FROM_PHONE" in environ:
|
environ.pop("SIGNAL_ALERT_TO_PHONE", None)
|
||||||
del environ["SIGNAL_ALERT_FROM_PHONE"]
|
|
||||||
if "SIGNAL_ALERT_TO_PHONE" in environ:
|
|
||||||
del environ["SIGNAL_ALERT_TO_PHONE"]
|
|
||||||
mock_logger = mocker.patch("python.signal_alert.logger")
|
mock_logger = mocker.patch("python.signal_alert.logger")
|
||||||
|
|
||||||
signal_alert("test")
|
signal_alert("test")
|
||||||
|
|
||||||
mock_logger.info.assert_called_once_with("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set")
|
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:
|
def test_signal_alert_socket_error(mocker: MockerFixture) -> None:
|
||||||
"""HTTP errors are logged rather than propagated."""
|
|
||||||
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
||||||
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
||||||
|
|
||||||
mock_logger = mocker.patch("python.signal_alert.logger")
|
mock_logger = mocker.patch("python.signal_alert.logger")
|
||||||
request = httpx.Request("POST", "http://localhost:8989/v2/send")
|
mocker.patch("python.signal_alert.socket.socket", side_effect=ConnectionError("connection failed"))
|
||||||
mocker.patch(
|
|
||||||
"python.signal_alert.httpx.post",
|
signal_alert("test")
|
||||||
side_effect=httpx.ConnectError("connection failed", request=request),
|
|
||||||
|
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_ignores_notifications(mocker: MockerFixture) -> None:
|
||||||
|
|
||||||
|
|
||||||
def test_signal_alert_unsuccessful_response(mocker: MockerFixture) -> None:
|
|
||||||
"""Unexpected response statuses are logged."""
|
|
||||||
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890"
|
||||||
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321"
|
||||||
|
|
||||||
mock_logger = mocker.patch("python.signal_alert.logger")
|
mock_socket = mocker.patch("python.signal_alert.socket.socket")
|
||||||
mock_response = mocker.MagicMock(spec=httpx.Response, status_code=httpx.codes.BAD_GATEWAY)
|
client = mock_socket.return_value.__enter__.return_value
|
||||||
mocker.patch("python.signal_alert.httpx.post", return_value=mock_response)
|
client.recv.side_effect = [
|
||||||
|
b'{"jsonrpc":"2.0","method":"receive"}\n{"jsonrpc":"2.0","result":{},"id":"signal-alert"}\n'
|
||||||
|
]
|
||||||
|
|
||||||
signal_alert("test")
|
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
|
||||||
Reference in new issue
Block a user