From 2422bb3664b9b6d27ddf3f42daceb2281bfb1121 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Tue, 29 Sep 2026 20:01:56 -0400 Subject: [PATCH 1/4] refactor(signal): use native signal-cli service --- common/optional/signal-cli.nix | 46 ++++++++++ common/optional/zfs/snapshot.nix | 6 +- python/signal_alert.py | 58 +++++++------ systems/jeeves/default.nix | 1 + systems/jeeves/docker/signal-cli-rest-api.nix | 18 ---- systems/jeeves/networking/firewall.nix | 2 - tests/test_signal_alert.py | 84 +++++++++++-------- 7 files changed, 129 insertions(+), 86 deletions(-) create mode 100644 common/optional/signal-cli.nix delete mode 100644 systems/jeeves/docker/signal-cli-rest-api.nix diff --git a/common/optional/signal-cli.nix b/common/optional/signal-cli.nix new file mode 100644 index 0000000..96a823f --- /dev/null +++ b/common/optional/signal-cli.nix @@ -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" ]; + }; + }; +} diff --git a/common/optional/zfs/snapshot.nix b/common/optional/zfs/snapshot.nix index 43d018a..ba07f3e 100644 --- a/common/optional/zfs/snapshot.nix +++ b/common/optional/zfs/snapshot.nix @@ -7,11 +7,7 @@ }: let cfg = config.services.snapshot_manager; - snapshotManagerPackages = - ps: with ps; [ - httpx - typer - ]; + snapshotManagerPackages = ps: with ps; [ typer ]; in { options = { diff --git a/python/signal_alert.py b/python/signal_alert.py index 08e85f3..c5723d1 100644 --- a/python/signal_alert.py +++ b/python/signal_alert.py @@ -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") diff --git a/systems/jeeves/default.nix b/systems/jeeves/default.nix index 0996e3d..99e116e 100644 --- a/systems/jeeves/default.nix +++ b/systems/jeeves/default.nix @@ -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" diff --git a/systems/jeeves/docker/signal-cli-rest-api.nix b/systems/jeeves/docker/signal-cli-rest-api.nix deleted file mode 100644 index d2ab6e3..0000000 --- a/systems/jeeves/docker/signal-cli-rest-api.nix +++ /dev/null @@ -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; - }; -} diff --git a/systems/jeeves/networking/firewall.nix b/systems/jeeves/networking/firewall.nix index e085295..542a74f 100644 --- a/systems/jeeves/networking/firewall.nix +++ b/systems/jeeves/networking/firewall.nix @@ -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 ]; }; diff --git a/tests/test_signal_alert.py b/tests/test_signal_alert.py index de056ae..8b78777 100644 --- a/tests/test_signal_alert.py +++ b/tests/test_signal_alert.py @@ -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 -- 2.55.0 From b3d8df0c37b3152043cdd1c574f90d971facae74 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Wed, 30 Sep 2026 07:12:46 -0400 Subject: [PATCH 2/4] Update Signal alert handling --- python/signal_alert.py | 46 +++++++++++++++++------------- python/tools/snapshot_manager.py | 22 +++++++++++--- tests/test_signal_alert.py | 46 +++++++----------------------- tests/test_snapshot_manager.py | 49 ++++++++++++++++++++++++++++---- users/richie/default.nix | 1 + 5 files changed, 99 insertions(+), 65 deletions(-) diff --git a/python/signal_alert.py b/python/signal_alert.py index c5723d1..c66dc77 100644 --- a/python/signal_alert.py +++ b/python/signal_alert.py @@ -5,23 +5,28 @@ from __future__ import annotations import json import logging import socket -from os import getenv logger = logging.getLogger(__name__) -def signal_alert(body: str) -> None: - """Send a signal alert. +class SignalRPCError(RuntimeError): + """signal-cli returned an error or closed without responding.""" + + +def signal_alert(body: str, from_phone: str, to_phone: str, *, timeout: float = 4.0) -> None: + """Send a Signal alert through the local signal-cli daemon. Args: - body (str): The body of the alert. - """ - from_phone = getenv("SIGNAL_ALERT_FROM_PHONE") - to_phone = getenv("SIGNAL_ALERT_TO_PHONE") - if not from_phone or not to_phone: - logger.info("SIGNAL_ALERT_FROM_PHONE or SIGNAL_ALERT_TO_PHONE not set") - return + body: The body of the alert. + from_phone: The Signal account sending the alert. + to_phone: The Signal account receiving the alert. + timeout: Seconds to wait on each socket operation. + Raises: + SignalRPCError: If signal-cli returns an error or closes the + connection before responding. + OSError: If the socket is unreachable or an operation times out. + """ signal_rpc_id = "signal-alert" request = { "jsonrpc": "2.0", @@ -36,7 +41,7 @@ def signal_alert(body: str) -> None: try: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: - client.settimeout(4.0) + client.settimeout(timeout) client.connect("/run/signal-cli/socket") client.sendall(json.dumps(request).encode() + b"\n") @@ -46,11 +51,14 @@ def signal_alert(body: str) -> None: 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") + if response.get("id") != signal_rpc_id: + continue + if rpc_error := response.get("error"): + error = f"Signal RPC returned an error: {rpc_error}" + raise SignalRPCError(error) + return + error = "Signal RPC socket closed before returning a response" + raise SignalRPCError(error) + except (OSError, json.JSONDecodeError) as exc: + error = f"Signal RPC failed: {exc!r}" + raise SignalRPCError(error) from exc diff --git a/python/tools/snapshot_manager.py b/python/tools/snapshot_manager.py index 866c6a5..90eb478 100644 --- a/python/tools/snapshot_manager.py +++ b/python/tools/snapshot_manager.py @@ -6,6 +6,7 @@ import logging import sys import tomllib from functools import cache +from os import getenv from pathlib import Path # noqa: TC003 This is required for the typer CLI from re import compile as re_compile from re import search @@ -24,6 +25,15 @@ def main(config_file: Path) -> None: configure_logger(level="DEBUG") logger.info("Starting snapshot_manager") + from_phone = getenv("SIGNAL_ALERT_FROM_PHONE") + to_phone = getenv("SIGNAL_ALERT_TO_PHONE") + if not from_phone: + error = "SIGNAL_ALERT_FROM_PHONE environment variable is not set" + raise ValueError(error) + if not to_phone: + error = "SIGNAL_ALERT_TO_PHONE environment variable is not set" + raise ValueError(error) + try: time_stamp = get_time_stamp() @@ -33,14 +43,14 @@ def main(config_file: Path) -> None: if status != "snapshot created": msg = f"{dataset.name} failed to create snapshot {time_stamp}" logger.error(msg) - signal_alert(msg) + signal_alert(msg, from_phone, to_phone) continue count_lookup = get_count_lookup(config_file, dataset.name) logger.info(f"using {count_lookup} for {dataset.name}") - get_snapshots_to_delete(dataset, count_lookup) + get_snapshots_to_delete(dataset, count_lookup, from_phone, to_phone) except Exception: logger.exception("snapshot_manager failed") - signal_alert("snapshot_manager failed") + signal_alert("snapshot_manager failed", from_phone, to_phone) sys.exit(1) else: logger.info("snapshot_manager completed") @@ -92,12 +102,16 @@ def load_config_data(config_file: Path) -> dict[str, dict[str, int]]: def get_snapshots_to_delete( dataset: Dataset, count_lookup: dict[str, int], + from_phone: str, + to_phone: str, ) -> None: """Get snapshots to delete. Args: dataset (Dataset): the dataset count_lookup (dict[str, int]): the count lookup + from_phone (str): The Signal account sending alerts. + to_phone (str): The Signal account receiving alerts. """ snapshots = dataset.get_snapshots() @@ -127,7 +141,7 @@ def get_snapshots_to_delete( for snapshot in snapshots_being_deleted: if error := dataset.delete_snapshot(snapshot): error_message = f"{dataset.name}@{snapshot} failed to delete: {error}" - signal_alert(error_message) + signal_alert(error_message, from_phone, to_phone) logger.error(error_message) diff --git a/tests/test_signal_alert.py b/tests/test_signal_alert.py index 8b78777..7b92631 100644 --- a/tests/test_signal_alert.py +++ b/tests/test_signal_alert.py @@ -4,25 +4,23 @@ from __future__ import annotations import json import socket -from os import environ from typing import TYPE_CHECKING -from python.signal_alert import signal_alert +import pytest + +from python.signal_alert import SignalRPCError, signal_alert if TYPE_CHECKING: from pytest_mock import MockerFixture def test_signal_alert(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","result":{},"id":"signal-alert"}\n'] - signal_alert("test") + assert signal_alert("test", "1234567890", "0987654321") is None mock_logger.info.assert_not_called() mock_socket.assert_called_once_with(socket.AF_UNIX, socket.SOCK_STREAM) @@ -41,55 +39,31 @@ def test_signal_alert(mocker: MockerFixture) -> None: } -def test_signal_alert_no_phones(mocker: MockerFixture) -> None: - 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_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") mocker.patch("python.signal_alert.socket.socket", side_effect=ConnectionError("connection failed")) - signal_alert("test") + with pytest.raises(SignalRPCError, match="Signal RPC failed") as exc_info: + signal_alert("test", "1234567890", "0987654321") - mock_logger.exception.assert_called_once_with("Unable to communicate with signal-cli") + assert isinstance(exc_info.value.__cause__, ConnectionError) 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"}, - ) + with pytest.raises(SignalRPCError, match=r"Signal RPC returned an error:.*failed"): + signal_alert("test", "1234567890", "0987654321") def test_signal_alert_ignores_notifications(mocker: MockerFixture) -> None: - environ["SIGNAL_ALERT_FROM_PHONE"] = "1234567890" - environ["SIGNAL_ALERT_TO_PHONE"] = "0987654321" - 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") + assert signal_alert("test", "1234567890", "0987654321") is None assert client.recv.call_count == 1 diff --git a/tests/test_snapshot_manager.py b/tests/test_snapshot_manager.py index a849829..30f6958 100644 --- a/tests/test_snapshot_manager.py +++ b/tests/test_snapshot_manager.py @@ -16,6 +16,17 @@ if TYPE_CHECKING: from pytest_mock import MockerFixture SNAPSHOT_MANAGER = "python.tools.snapshot_manager" +FROM_PHONE = "1234567890" +TO_PHONE = "0987654321" + + +def patch_phone_numbers(mocker: MockerFixture) -> None: + """Patch the Signal phone number environment variables.""" + phone_numbers = { + "SIGNAL_ALERT_FROM_PHONE": FROM_PHONE, + "SIGNAL_ALERT_TO_PHONE": TO_PHONE, + } + mocker.patch(f"{SNAPSHOT_MANAGER}.getenv", side_effect=phone_numbers.get) def patch_utcnow(mocker: MockerFixture, datetime_value: datetime) -> None: @@ -34,6 +45,7 @@ def create_mock_snapshot(mocker: MockerFixture, name: str) -> Snapshot: def test_main(mocker: MockerFixture, fs: FakeFilesystem) -> None: """Test main.""" load_config_data.cache_clear() + patch_phone_numbers(mocker) mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00") @@ -58,12 +70,15 @@ def test_main(mocker: MockerFixture, fs: FakeFilesystem) -> None: "daily": 0, "monthly": 0, }, + FROM_PHONE, + TO_PHONE, ) def test_main_create_snapshot_failure(mocker: MockerFixture, fs: FakeFilesystem) -> None: """Test main.""" load_config_data.cache_clear() + patch_phone_numbers(mocker) mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00") @@ -78,7 +93,11 @@ def test_main_create_snapshot_failure(mocker: MockerFixture, fs: FakeFilesystem) fs.create_file("/mock_snapshot_config.toml", contents=mock_snapshot_config_toml) main(Path("/mock_snapshot_config.toml")) - mock_signal_alert.assert_called_once_with("test_dataset failed to create snapshot 2023-01-01T00:00:00") + mock_signal_alert.assert_called_once_with( + "test_dataset failed to create snapshot 2023-01-01T00:00:00", + FROM_PHONE, + TO_PHONE, + ) mock_get_datasets.assert_called_once() mock_get_snapshots_to_delete.assert_not_called() @@ -86,6 +105,7 @@ def test_main_create_snapshot_failure(mocker: MockerFixture, fs: FakeFilesystem) def test_main_exception(mocker: MockerFixture, fs: FakeFilesystem) -> None: """Test main.""" load_config_data.cache_clear() + patch_phone_numbers(mocker) mocker.patch(f"{SNAPSHOT_MANAGER}.get_time_stamp", return_value="2023-01-01T00:00:00") @@ -103,7 +123,7 @@ def test_main_exception(mocker: MockerFixture, fs: FakeFilesystem) -> None: assert isinstance(pytest_wrapped_e.value, SystemExit) assert pytest_wrapped_e.value.code == 1 - mock_signal_alert.assert_called_once_with("snapshot_manager failed") + mock_signal_alert.assert_called_once_with("snapshot_manager failed", FROM_PHONE, TO_PHONE) mock_get_datasets.assert_called_once() mock_get_snapshots_to_delete.assert_not_called() @@ -120,7 +140,12 @@ def test_get_snapshots_to_delete(mocker: MockerFixture) -> None: mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert") - get_snapshots_to_delete(mock_dataset, {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0}) + get_snapshots_to_delete( + mock_dataset, + {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0}, + FROM_PHONE, + TO_PHONE, + ) mock_signal_alert.assert_not_called() mock_dataset.delete_snapshot.assert_called_once_with("auto_202509150415") @@ -135,7 +160,12 @@ def test_get_snapshots_to_delete_no_snapshot(mocker: MockerFixture) -> None: mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert") - get_snapshots_to_delete(mock_dataset, {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0}) + get_snapshots_to_delete( + mock_dataset, + {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0}, + FROM_PHONE, + TO_PHONE, + ) mock_signal_alert.assert_not_called() mock_dataset.delete_snapshot.assert_not_called() @@ -153,10 +183,17 @@ def test_get_snapshots_to_delete_errored(mocker: MockerFixture) -> None: mock_signal_alert = mocker.patch(f"{SNAPSHOT_MANAGER}.signal_alert") - get_snapshots_to_delete(mock_dataset, {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0}) + get_snapshots_to_delete( + mock_dataset, + {"15_min": 1, "hourly": 0, "daily": 0, "monthly": 0}, + FROM_PHONE, + TO_PHONE, + ) mock_signal_alert.assert_called_once_with( - "test_dataset@auto_202509150415 failed to delete: snapshot has dependent clones" + "test_dataset@auto_202509150415 failed to delete: snapshot has dependent clones", + FROM_PHONE, + TO_PHONE, ) mock_dataset.delete_snapshot.assert_called_once_with("auto_202509150415") diff --git a/users/richie/default.nix b/users/richie/default.nix index c8368ff..787a500 100644 --- a/users/richie/default.nix +++ b/users/richie/default.nix @@ -41,6 +41,7 @@ in "ollama" "plugdev" "scanner" + "signal-cli" "transmission" "uaccess" "uucp" -- 2.55.0 From 6d11fad288733fb290e68be9bbe84bdc0a8de6c9 Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Wed, 30 Sep 2026 07:30:23 -0400 Subject: [PATCH 3/4] added signal-cli to all servers --- systems/bob/default.nix | 1 + systems/brain/default.nix | 1 + systems/jeeves/default.nix | 2 +- systems/portal-1/default.nix | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/systems/bob/default.nix b/systems/bob/default.nix index 99e7a35..65cad4c 100644 --- a/systems/bob/default.nix +++ b/systems/bob/default.nix @@ -8,6 +8,7 @@ "${inputs.self}/common/optional/monitoring-agent.nix" "${inputs.self}/common/optional/nvidia.nix" "${inputs.self}/common/optional/scanner.nix" + "${inputs.self}/common/optional/signal-cli.nix" "${inputs.self}/common/optional/syncthing_base.nix" "${inputs.self}/common/optional/systemd-boot.nix" "${inputs.self}/common/optional/tailscale.nix" diff --git a/systems/brain/default.nix b/systems/brain/default.nix index 5ac143f..a0823fe 100644 --- a/systems/brain/default.nix +++ b/systems/brain/default.nix @@ -4,6 +4,7 @@ "${inputs.self}/users/richie" "${inputs.self}/common/global" "${inputs.self}/common/optional/docker.nix" + "${inputs.self}/common/optional/signal-cli.nix" "${inputs.self}/common/optional/ssh_decrypt.nix" "${inputs.self}/common/optional/syncthing_base.nix" "${inputs.self}/common/optional/systemd-boot.nix" diff --git a/systems/jeeves/default.nix b/systems/jeeves/default.nix index 99e116e..607aae9 100644 --- a/systems/jeeves/default.nix +++ b/systems/jeeves/default.nix @@ -9,8 +9,8 @@ in "${inputs.self}/common/global" "${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/ssh_decrypt.nix" "${inputs.self}/common/optional/syncthing_base.nix" "${inputs.self}/common/optional/tailscale.nix" "${inputs.self}/common/optional/update.nix" diff --git a/systems/portal-1/default.nix b/systems/portal-1/default.nix index a5a20be..0442a6c 100644 --- a/systems/portal-1/default.nix +++ b/systems/portal-1/default.nix @@ -9,6 +9,7 @@ inputs.disko.nixosModules.disko "${inputs.self}/users/richie" "${inputs.self}/common/global" + "${inputs.self}/common/optional/signal-cli.nix" "${inputs.self}/common/optional/tailscale.nix" "${inputs.self}/common/optional/x86-64-v3" ./disk-config.nix -- 2.55.0 From 8c03e57990771149e2212e9a1061fab314a1a34b Mon Sep 17 00:00:00 2001 From: Richie Cahill Date: Wed, 30 Sep 2026 10:01:39 -0400 Subject: [PATCH 4/4] adding linking scripts --- scripts/signal/authorize.sh | 37 +++++++++++++++++++++++ scripts/signal/link.sh | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100755 scripts/signal/authorize.sh create mode 100755 scripts/signal/link.sh diff --git a/scripts/signal/authorize.sh b/scripts/signal/authorize.sh new file mode 100755 index 0000000..d1026d4 --- /dev/null +++ b/scripts/signal/authorize.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +if [[ "$(hostname)" != "jeeves" ]]; then + echo "Signal device authorization must run on jeeves." >&2 + exit 1 +fi +if [[ "$EUID" -ne 0 ]]; then + echo "Signal device authorization must run as root." >&2 + exit 1 +fi +if [[ "$#" -ne 1 ]]; then + echo "Usage: signal_authorize.sh DEVICE_URI" >&2 + exit 2 +fi +if [[ "$1" != sgnl://linkdevice\?* ]]; then + echo "Invalid Signal device link URI." >&2 + exit 2 +fi + +service_name="signal-cli" +data_dir="/var/lib/signal-cli" +device_uri="$1" +restart_service=false + +cleanup() { + if [[ "$restart_service" == true ]]; then + systemctl start "$service_name" + fi +} +trap cleanup EXIT + +restart_service=true +systemctl stop "$service_name" +runuser -u signal-cli -- \ + signal-cli --data-dir "$data_dir" -a +12016554378 addDevice --uri "$device_uri" diff --git a/scripts/signal/link.sh b/scripts/signal/link.sh new file mode 100755 index 0000000..f9b0bd3 --- /dev/null +++ b/scripts/signal/link.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +host_name="$(hostname)" +if [[ "$#" -ne 0 ]]; then + echo "Usage: signal_link.sh" >&2 + exit 2 +fi + +service_name="signal-cli" +data_dir="/var/lib/signal-cli" +link_pid="" +temp_dir="" +restart_service=false + +cleanup() { + if [[ -n "$link_pid" ]] && kill -0 "$link_pid" 2>/dev/null; then + kill "$link_pid" 2>/dev/null || true + wait "$link_pid" 2>/dev/null || true + fi + + if [[ -n "$temp_dir" ]]; then + rm -f -- "$temp_dir/link-uri" + rmdir -- "$temp_dir" + fi + + if [[ "$restart_service" == true ]]; then + sudo systemctl start "$service_name" + fi +} +trap cleanup EXIT + +restart_service=true +sudo systemctl stop "$service_name" + +temp_dir="$(mktemp -d)" +link_fifo="$temp_dir/link-uri" +mkfifo "$link_fifo" + +sudo -u signal-cli \ + signal-cli --data-dir "$data_dir" link --name "$host_name" >"$link_fifo" & +link_pid="$!" + +if ! IFS= read -r device_uri <"$link_fifo"; then + wait "$link_pid" + exit 1 +fi +if [[ "$device_uri" != sgnl://linkdevice\?* ]]; then + echo "signal-cli returned an invalid device link URI." >&2 + exit 1 +fi + +echo "Run this command manually on jeeves in another dotfiles dir:" +printf "sudo ./scripts/signal/authorize.sh %q\n" "$device_uri" +echo "Waiting for jeeves to authorize this device..." + +wait "$link_pid" +link_pid="" +echo "$host_name is now linked to the Signal account on jeeves." -- 2.55.0