From d5c63b423be024b8db1ccbc53cdded91f7053887 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 29 Aug 2026 19:01:55 +0200 Subject: [PATCH] feat: refuse to look healthy while deaf, and heal the container by itself Today the bot was up, healthy and answering nothing for three hours. A host firewall change (fixed in testsAndMisc 334c0cd9) cut every Docker container off the internet; signal-cli lost Signal and reconnected in a loop from 15:32:50 onward. Nothing noticed: - the container's own healthcheck answered 204 - /v1/groups answered 200 out of local state, so discovery saw no change - the bot's WebSocket to the container stayed OPEN, so the transport's reconnect logic never fired - /healthz answered {"status": "ok"} the entire time The only symptom was silence, which is also what a quiet evening looks like. signal_bot/liveness.py asks the one question that cannot be answered from a cache -- GET /v1/devices, which needs a live round trip -- but only after SILENCE_SECONDS (120) with no frame of any kind, so a busy group costs nothing and an idle one costs one request per window. Three consecutive failures and /healthz answers 503 with the reason. Verified end to end in scripts/e2e.sh against the real process: 503 after 84s with the fake's connection cut, back to 200 on its own 30s after restoring it. scripts/signal_api_watchdog.sh + a two-minute user timer do the recovery the bot cannot do for itself, since a wedged bot cannot restart the container it depends on. Two tiers, so the steady state costs no Signal round trip at all: the bot's own /healthz first, the authenticated probe only when that is not 200. It stops after 3 restarts in 6 hours and escalates instead -- a deregistered account or a blocked network is not fixable by restarting, and a restart loop would hide it. Its own bash suite stubs curl and docker and drives every branch including the give-up cap. signal_bot/trust.py answers the other half: TRUSTED_INVITERS decides who may put this bot in a group. The gate sits in GroupDirectory._permitted, which both _join_pending and _adopt already route through -- gating only invites would gate the door nobody uses, since prime_profile.sh exists precisely so that an add becomes a direct join. Evidence is either the group-update envelope, which is the only frame naming an inviter, or a trusted identity being a full member in the listing. A refusal is logged once, with that group's members, so the operator can see which string to add. verify_allowlist moved to membership.py to keep discovery.py under the 250-line gate; it was already the module that owns MembershipError. Verified live, by stopping the container by hand and touching nothing else: 18:54:35 docker stop signal-api 18:56:36 liveness: failure 1 of 3 after 123s of silence 18:57:36 failure 3 of 3 -> /healthz answers 503 from 18:57:45 18:58:18 watchdog: probe failed (1/2) 19:00:28 watchdog: probe failed (2/2) -> docker restart signal-api 19:00:36 liveness: receive path recovered after 8 failed probe(s) 19:01:13 watchdog: signal-api is back Six minutes, unattended, against three hours of silence this afternoon. All four real groups are still adopted with TRUSTED_INVITERS=+48123456789 set, and no group was refused. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KbjfWdAyHbCubac7wJKJZW --- .env.sample | 16 ++ .pre-commit-config.yaml | 9 ++ DOCS-liveness-and-trust.md | 49 ++++++ DOCS-signal-envelopes.md | 31 ++++ README.md | 8 + fake_signal/routes_api.py | 17 +++ fake_signal/routes_control.py | 7 + fake_signal/state.py | 3 + scripts/e2e.sh | 54 ++++++- scripts/install_service.sh | 27 ++++ scripts/signal-api-watchdog.service | 22 +++ scripts/signal-api-watchdog.timer | 15 ++ scripts/signal_api_watchdog.sh | 179 ++++++++++++++++++++++ scripts/test_watchdog.sh | 229 ++++++++++++++++++++++++++++ signal_bot/app.py | 37 ++++- signal_bot/config.py | 26 ++++ signal_bot/discovery.py | 56 ++++--- signal_bot/dispatcher.py | 4 + signal_bot/liveness.py | 172 +++++++++++++++++++++ signal_bot/membership.py | 29 ++++ signal_bot/models.py | 18 +++ signal_bot/trust.py | 149 ++++++++++++++++++ tests/conftest.py | 38 ++++- tests/test_allowlist.py | 9 +- tests/test_app.py | 11 +- tests/test_discovery.py | 13 +- tests/test_fake_account.py | 30 ++++ tests/test_healthz.py | 52 +++++++ tests/test_liveness.py | 177 +++++++++++++++++++++ tests/test_trust.py | 177 +++++++++++++++++++++ 30 files changed, 1622 insertions(+), 42 deletions(-) create mode 100644 DOCS-liveness-and-trust.md create mode 100644 scripts/signal-api-watchdog.service create mode 100644 scripts/signal-api-watchdog.timer create mode 100755 scripts/signal_api_watchdog.sh create mode 100755 scripts/test_watchdog.sh create mode 100644 signal_bot/liveness.py create mode 100644 signal_bot/trust.py create mode 100644 tests/test_healthz.py create mode 100644 tests/test_liveness.py create mode 100644 tests/test_trust.py diff --git a/.env.sample b/.env.sample index a6da578..3de6ac9 100644 --- a/.env.sample +++ b/.env.sample @@ -38,6 +38,22 @@ REPORT_MINUTE= # Optional. Per-user, per-command cooldown in seconds. Default: 10 COOLDOWN_SECONDS= +# Optional but recommended. Comma-separated +E.164 numbers -- or member +# UUIDs, which is how /v1/groups spells anybody the account has never +# messaged -- allowed to add this bot to a group. +# +# Empty means anyone who knows the number can pull the bot into a group and +# have it answer there. With it set, "add the bot" is still the whole setup +# for you, and a stranger's group is refused with one WARNING naming that +# group's members, so you can see exactly which string to add here. +TRUSTED_INVITERS= + +# Optional. How long the bot may hear nothing at all -- no message, no +# receipt, no typing indicator -- before it probes /v1/devices to find out +# whether it has gone deaf. Three failed probes and /healthz turns 503. +# Default: 120. scripts/e2e.sh sets it to 0 to prove the gate really fires. +SILENCE_SECONDS= + # Optional. Image APIs. Overridden by scripts/e2e.sh to serve local fixtures. CAT_API_URL= DOG_API_URL= diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f53eaca..976358d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -98,6 +98,15 @@ repos: language: system types: [shell] + # The watchdog is bash, so pytest cannot reach it. Its own suite stubs + # curl and docker and drives every branch, including the give-up cap. + - id: watchdog-tests + name: watchdog behaviour (bash) + entry: scripts/test_watchdog.sh + language: system + pass_filenames: false + files: ^scripts/(signal_api_watchdog|test_watchdog)\.sh$ + # 100% branch coverage, no omits. Runs on every commit: coverage is the # whole point of the suite, and a partial run cannot prove it. - id: pytest diff --git a/DOCS-liveness-and-trust.md b/DOCS-liveness-and-trust.md new file mode 100644 index 0000000..3ede1c4 --- /dev/null +++ b/DOCS-liveness-and-trust.md @@ -0,0 +1,49 @@ +# Going deaf, and who may add the bot + +Two gates that exist because of one afternoon. Both are referenced from +the README; this is the detail. + +## When it cannot hear + +On 2026-08-29 the bot was up, healthy and deaf for three hours. A host +firewall change cut every Docker container off the internet, `signal-cli` +lost its connection to Signal and reconnected in a loop, and **every** health +signal stayed green: the container's own healthcheck answered 204, +`/v1/groups` answered 200 from local state, the bot's WebSocket to the +container stayed open, and `/healthz` answered `{"status": "ok"}`. The only +symptom was silence, which is also what a quiet evening looks like. + +Two things now make that impossible to miss: + +- **`signal_bot/liveness.py`** — after `SILENCE_SECONDS` (120 by default) + with no frame of any kind, the bot probes `/v1/devices/`, the one + endpoint that needs a live round trip to Signal. Three consecutive + failures and `/healthz` answers **503** with the reason, and every failure + is a `WARNING` in the journal. Worst case from the last envelope to a red + `/healthz` is 120 + 3×30 ≈ 3.5 minutes. +- **`scripts/signal_api_watchdog.sh`**, on a two-minute user timer — probes + the same endpoint from outside the process, restarts the container after + two consecutive failures, and **stops after 3 restarts in 6 hours**, + escalating with `notify-send` and a journald `ERROR` instead. A + deregistered account or a blocked network is not fixable by restarting, + and a forever-restart loop would hide it. `scripts/install_service.sh` + installs and arms it beside the bot's own unit. + +```bash +systemctl --user list-timers signal-api-watchdog.timer +journalctl --user -u signal-api-watchdog.service -n 20 +curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8137/healthz +``` + +## Who may add the bot to a group + +`TRUSTED_INVITERS` in `.env` is a comma-separated list of `+E.164` numbers, +or the member UUIDs `/v1/groups` uses for anybody the account has never +messaged. Empty means anyone who knows the number can pull the bot into a +group and be answered there, and the bot says so in a `WARNING` at startup. + +With it set, adding the bot is still the whole setup for you: a group is +adopted when a group-update envelope says a trusted identity added the bot, +or when a trusted identity is a full member of it in the listing. Anything +else is refused once, loudly, with that group's member list in the log so +you can see which string to add. diff --git a/DOCS-signal-envelopes.md b/DOCS-signal-envelopes.md index c93ff91..c19c5ad 100644 --- a/DOCS-signal-envelopes.md +++ b/DOCS-signal-envelopes.md @@ -28,6 +28,37 @@ Reference for the two JSON frames `signal-cli-rest-api` pushes down } ``` +## Somebody changed the membership: `groupInfo.type` is `UPDATE` + +The only frame that names *who* added the bot to a group, which is what +`signal_bot/trust.py` needs. It carries no `message` at all, so it counts +toward nothing and answers nothing; `source`/`sourceNumber` is the person +who made the change. Captured live on 2026-08-29: + +```json +{ + "envelope": { + "source": "+48123456789", + "sourceNumber": "+48123456789", + "sourceUuid": "b1c2d3e4-…", + "sourceName": "kuhy", + "sourceDevice": 1, + "timestamp": 1788006851503, + "dataMessage": { + "timestamp": 1788006851503, + "message": null, + "groupInfo": { "groupId": "Y29uZmlnLWdy/…", "groupName": "testbot", "type": "UPDATE" } + } + }, + "account": "+420111222333" +} +``` + +Note `source` here is a **phone number**, not the display name this document +used to show for `dataMessage`. Both spellings have been observed, which is +why `models.py` reads the sender's number from `sourceNumber` and nothing +trusts `source`. + ## From the bot's own account: `syncMessage.sentMessage` Because the bot runs as a *linked device*, the account's own outgoing messages diff --git a/README.md b/README.md index 314ccc2..aa5c1dc 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,14 @@ Two independent reasons, both verified rather than assumed: `Cannot add user: no_add_managed_profile is enabled`. This is the Android 11 COMP deprecation and is unchanged through Android 16. +## When it cannot hear, and who may add it + +The bot was up, healthy and deaf for three hours on 2026-08-29. `/healthz` +now turns **503** when silence plus three failed `/v1/devices` probes say the +connection to Signal is dead, a two-minute timer restarts the container, and +`TRUSTED_INVITERS` decides who may put this bot in a group: +[DOCS-liveness-and-trust.md](DOCS-liveness-and-trust.md). + ## Commands | Command | Aliases | diff --git a/fake_signal/routes_api.py b/fake_signal/routes_api.py index e48a2ad..911132b 100644 --- a/fake_signal/routes_api.py +++ b/fake_signal/routes_api.py @@ -35,6 +35,23 @@ def build_api_router() -> APIRouter: headers={"X-Device-Name": device_name}, ) + @router.get("/v1/devices/{number}") + async def devices(request: Request, number: str) -> JSONResponse: + """The endpoint the deafness gate probes. + + Answers from Signal, never from local state, which is exactly why + the bot uses it to tell "quiet" apart from "deaf". + """ + state = _state(request) + if number not in state.accounts: + return JSONResponse({"error": f"{number} is not registered"}, status_code=400) + if not state.connection_alive: + return JSONResponse( + {"error": "Failed to get linked devices: Closed unexpectedly"}, + status_code=400, + ) + return JSONResponse([{"id": 1, "name": "", "created": 0, "last_seen": 0}]) + @router.post("/v2/send") async def send(request: Request, payload: dict[str, Any]) -> JSONResponse: _state(request).record_send(payload) diff --git a/fake_signal/routes_control.py b/fake_signal/routes_control.py index 9357b5a..6eb7251 100644 --- a/fake_signal/routes_control.py +++ b/fake_signal/routes_control.py @@ -29,6 +29,13 @@ def build_control_router() -> APIRouter: delivered += 1 return {"delivered": delivered} + @router.post("/connection") + async def set_connection(request: Request, body: dict[str, Any]) -> dict[str, bool]: + """Cut or restore signal-cli's connection to Signal.""" + state = _state(request) + state.connection_alive = bool(body.get("alive", True)) + return {"alive": state.connection_alive} + @router.post("/groups") async def set_groups(request: Request, groups: list[dict[str, Any]]) -> dict[str, int]: """Replace the group list, so the group picker can be rehearsed offline.""" diff --git a/fake_signal/state.py b/fake_signal/state.py index 7724a43..11c89ad 100644 --- a/fake_signal/state.py +++ b/fake_signal/state.py @@ -31,6 +31,9 @@ class State: accounts: list[str] = field(default_factory=list) pending: dict[str, str] = field(default_factory=dict) groups: list[dict[str, Any]] = field(default_factory=list) + # Whether signal-cli can reach Signal. False reproduces the 2026-08-29 + # outage: local state still answers, anything needing a round trip 400s. + connection_alive: bool = True def reset(self) -> None: """Forget captured sends and attachments.""" diff --git a/scripts/e2e.sh b/scripts/e2e.sh index dbe219e..2a8be33 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -58,6 +58,11 @@ cleanup() { } trap cleanup EXIT +die() { + echo "Error: $1" >&2 + exit 1 +} + wait_for_url() { local url="$1" name="$2" attempt=0 until curl -sf "$url" >/dev/null 2>&1; do @@ -109,19 +114,23 @@ main() { -H 'Content-Type: application/json' \ -d "[{\"id\":\"${WATCHED_GROUP}\",\"internal_id\":\"${WATCHED_GROUP_INTERNAL}\",\"name\":\"e2e\",\"member\":true,\"joinable\":true},{\"id\":\"${SECOND_GROUP}\",\"internal_id\":\"${SECOND_GROUP_INTERNAL}\",\"name\":\"e2e-second\",\"member\":true,\"joinable\":true}]" >/dev/null - # GROUP_IDS deliberately empty: the bot discovers its groups from the - # account, which is the configuration real deployments now use. Set - # rather than merely unset, because load_dotenv() would otherwise let - # the developer's own .env decide what this test run watches. + # GROUP_IDS and TRUSTED_INVITERS deliberately empty: the bot discovers + # its groups from the account, which is the configuration real + # deployments use. Set rather than merely unset, because load_dotenv() + # would otherwise let the developer's own .env decide what this run + # watches -- and a TRUSTED_INVITERS there would refuse every fixture + # group. The gate itself is covered in tests/test_trust.py. echo "== booting the bot on :${BOT_PORT}, pointed at the fake" env -u CAT_API \ PHONE_NUMBER="+15550000000" \ GROUP_IDS="" \ + TRUSTED_INVITERS="" \ API_BASE_URL="$FAKE_URL" \ DATABASE_PATH="${TEMP_DIR}/e2e.sqlite3" \ CAT_API_URL="${FAKE_URL}/_fixtures/cat-api" \ DOG_API_URL="${FAKE_URL}/_fixtures/dog-api" \ BIND_PORT="$BOT_PORT" \ + SILENCE_SECONDS=0 \ "$PYTHON" -m signal_bot & BOT_PID=$! wait_for_url "http://127.0.0.1:${BOT_PORT}/healthz" "bot" @@ -140,6 +149,43 @@ main() { --group "fourth=${FOURTH_GROUP_INTERNAL}" \ --group "fourth_reply=${FOURTH_GROUP}" \ --group "other=${OTHER_GROUP}" + + prove_the_deafness_gate +} + +# The gate that did not exist on 2026-08-29, proven against the real process: +# with signal-cli unable to reach Signal, /healthz must go red rather than +# keep answering 200 while nothing arrives. +prove_the_deafness_gate() { + local health="http://127.0.0.1:${BOT_PORT}/healthz" + echo "== cutting the fake's connection to Signal" + curl -sf -X POST "${FAKE_URL}/_control/connection" \ + -H 'Content-Type: application/json' -d '{"alive": false}' >/dev/null + + local code="" waited=0 + while ((waited < 150)); do + code="$(curl -s -o /dev/null -w '%{http_code}' "$health")" + [[ "$code" == "503" ]] && break + sleep 1 + waited=$((waited + 1)) + done + [[ "$code" == "503" ]] || die "/healthz still answers ${code} with the connection cut" + curl -s "$health" | grep -q "Closed unexpectedly" || + die "/healthz went red without saying why" + echo "== /healthz turned 503 and named the reason after ${waited}s" + + echo "== restoring the connection" + curl -sf -X POST "${FAKE_URL}/_control/connection" \ + -H 'Content-Type: application/json' -d '{"alive": true}' >/dev/null + waited=0 + while ((waited < 150)); do + code="$(curl -s -o /dev/null -w '%{http_code}' "$health")" + [[ "$code" == "200" ]] && break + sleep 1 + waited=$((waited + 1)) + done + [[ "$code" == "200" ]] || die "/healthz stayed ${code} after the connection came back" + echo "== /healthz recovered on its own after ${waited}s" } main "$@" diff --git a/scripts/install_service.sh b/scripts/install_service.sh index 75661b4..704a5c9 100755 --- a/scripts/install_service.sh +++ b/scripts/install_service.sh @@ -18,6 +18,8 @@ readonly SCRIPT_NAME readonly UNIT_NAME="signal-bot.service" readonly UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" readonly TEMPLATE="${REPO_ROOT}/scripts/${UNIT_NAME}" +readonly WATCHDOG_UNITS=("signal-api-watchdog.service" "signal-api-watchdog.timer") +readonly WATCHDOG_TIMER="signal-api-watchdog.timer" readonly DEFAULT_PORT=8137 VERIFY_ONLY="false" @@ -51,6 +53,12 @@ validate_requirements() { die "no ${REPO_ROOT}/.env -- run scripts/register_bot.sh first" [[ -x "${REPO_ROOT}/run.sh" ]] || die "no executable ${REPO_ROOT}/run.sh" [[ -f "$TEMPLATE" ]] || die "no unit template at $TEMPLATE" + [[ -x "${REPO_ROOT}/scripts/signal_api_watchdog.sh" ]] || + die "no executable ${REPO_ROOT}/scripts/signal_api_watchdog.sh" + local unit + for unit in "${WATCHDOG_UNITS[@]}"; do + [[ -f "${REPO_ROOT}/scripts/${unit}" ]] || die "no unit template for ${unit}" + done } # Without linger, a user unit dies at logout and never starts at boot. @@ -79,6 +87,18 @@ install_unit() { echo "== installed and started ${UNIT_DIR}/${UNIT_NAME}" } +# The bot cannot restart the container it depends on from inside a wedged +# event loop, so recovery from a dead Signal connection lives in a timer. +install_watchdog() { + local unit + for unit in "${WATCHDOG_UNITS[@]}"; do + sed "s|@REPO_ROOT@|${REPO_ROOT}|g" "${REPO_ROOT}/scripts/${unit}" >"${UNIT_DIR}/${unit}" + done + systemctl --user daemon-reload + systemctl --user enable --now "$WATCHDOG_TIMER" >/dev/null + echo "== installed and armed ${UNIT_DIR}/${WATCHDOG_TIMER}" +} + # Report what actually happened, not that the commands returned 0. verify() { local port="$1" @@ -90,6 +110,12 @@ verify() { signal_wait_for_url "http://127.0.0.1:${port}/healthz" "signal-bot" 60 echo "== active, and answering on http://127.0.0.1:${port}/healthz" echo "== enabled at boot: $(systemctl --user is-enabled "$UNIT_NAME")" + if ! systemctl --user is-active --quiet "$WATCHDOG_TIMER"; then + echo "Error: ${WATCHDOG_TIMER} is not active; nothing will recover a dead" >&2 + echo " connection to Signal automatically." >&2 + return 1 + fi + systemctl --user list-timers --no-pager "$WATCHDOG_TIMER" | sed -n '2p' } main() { @@ -100,6 +126,7 @@ main() { if [[ "$VERIFY_ONLY" != "true" ]]; then require_linger install_unit + install_watchdog fi verify "$port" diff --git a/scripts/signal-api-watchdog.service b/scripts/signal-api-watchdog.service new file mode 100644 index 0000000..9dd9325 --- /dev/null +++ b/scripts/signal-api-watchdog.service @@ -0,0 +1,22 @@ +# Installed to ~/.config/systemd/user/ by scripts/install_service.sh, which +# substitutes @REPO_ROOT@. Edit this template, never the installed copy. + +[Unit] +Description=Restart signal-cli-rest-api when it can no longer reach Signal +Documentation=file://@REPO_ROOT@/README.md +After=docker.service +Wants=docker.service + +[Service] +Type=oneshot +WorkingDirectory=@REPO_ROOT@ +# PHONE_NUMBER, API_BASE_URL and BIND_PORT come from the same file the bot +# reads, so the watchdog can never probe a different account than the bot. +EnvironmentFile=@REPO_ROOT@/.env +ExecStart=@REPO_ROOT@/scripts/signal_api_watchdog.sh +# A oneshot under a timer has no memory of its own: the failure counter and +# the restart history have to live somewhere that survives the exit. +StateDirectory=signal-api-watchdog +# Deliberately NOT ProtectHome: StateDirectory for a user unit writes under +# $HOME/.local/state, which a read-only home would make fail. +NoNewPrivileges=true diff --git a/scripts/signal-api-watchdog.timer b/scripts/signal-api-watchdog.timer new file mode 100644 index 0000000..b821df4 --- /dev/null +++ b/scripts/signal-api-watchdog.timer @@ -0,0 +1,15 @@ +# Installed to ~/.config/systemd/user/ by scripts/install_service.sh. + +[Unit] +Description=Probe signal-cli's connection to Signal every two minutes + +[Timer] +# Two minutes: the bot's own gate needs ~2 min of silence plus three probes +# to admit it is deaf, so checking faster would only ask before it knows. +OnBootSec=2min +OnUnitActiveSec=2min +AccuracySec=10s +Unit=signal-api-watchdog.service + +[Install] +WantedBy=timers.target diff --git a/scripts/signal_api_watchdog.sh b/scripts/signal_api_watchdog.sh new file mode 100755 index 0000000..b302cb8 --- /dev/null +++ b/scripts/signal_api_watchdog.sh @@ -0,0 +1,179 @@ +#!/bin/bash + +# ============================================================================ +# Restart signal-cli-rest-api when it can no longer reach Signal. +# +# Nothing else notices this failure. On 2026-08-29 the container's own +# healthcheck answered 204, /v1/groups answered 200 out of local state, and +# the bot's WebSocket to it stayed open, while signal-cli reconnected to +# Signal in a loop for three hours and not one message arrived. Docker cannot +# see it, so a timer asks the one question that needs a real round trip. +# +# Runs from signal-api-watchdog.timer as a oneshot, so every counter lives on +# disk: the process has no memory of the previous firing. +# ============================================================================ + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME + +readonly API="${API_BASE_URL:-http://localhost:9922}" +readonly BOT="http://127.0.0.1:${BIND_PORT:-8137}/healthz" +readonly CONTAINER="${SIGNAL_CONTAINER:-signal-api}" +readonly STATE_DIR="${STATE_DIRECTORY:-${XDG_STATE_HOME:-$HOME/.local/state}/signal-api-watchdog}" +# Overridable so the tests can drive the whole ladder without waiting. +readonly PROBE_TIMEOUT="${PROBE_TIMEOUT:-15}" +readonly FAILURES_BEFORE_RESTART="${FAILURES_BEFORE_RESTART:-2}" +readonly MAX_RESTARTS="${MAX_RESTARTS:-3}" +readonly RESTART_WINDOW="${RESTART_WINDOW:-21600}" # 6 hours +readonly SETTLE_SECONDS="${SETTLE_SECONDS:-45}" + +readonly FAILURE_FILE="${STATE_DIR}/consecutive_failures" +readonly RESTART_FILE="${STATE_DIR}/restarts" + +usage() { + echo "Usage: $SCRIPT_NAME" + echo " Probes signal-cli's connection to Signal and restarts ${CONTAINER}" + echo " when it has been dead for ${FAILURES_BEFORE_RESTART} consecutive checks." + echo " Reads PHONE_NUMBER, API_BASE_URL and BIND_PORT from the environment." + exit 0 +} + +die() { + echo "Error: $1" >&2 + exit 1 +} + +# journald and the desktop, always both: the broken path IS Signal, so the +# bot cannot tell anybody about this itself. +alarm() { + logger --priority user.warning --tag signal-api-watchdog -- "$1" || true + echo "== $1" + notify-send --urgency=critical --app-name=signal-bot "signal-bot" "$1" 2>/dev/null || true +} + +validate_requirements() { + command -v curl >/dev/null || die "curl not found" + command -v docker >/dev/null || die "docker not found" + [[ -n "${PHONE_NUMBER:-}" ]] || + die "PHONE_NUMBER is unset -- the unit must load the repo's .env" + mkdir -p "$STATE_DIR" +} + +read_count() { + local value=0 + [[ -f "$FAILURE_FILE" ]] && read -r value <"$FAILURE_FILE" + printf '%s' "${value:-0}" +} + +# --max-time on every probe: an unbounded curl would outlive the timer +# interval and overlap the next firing. +http_code() { + curl -s -o /dev/null -m "$2" -w '%{http_code}' "$1" || true +} + +# Two tiers. The bot's own /healthz is free and already knows whether it can +# hear; the authenticated probe is what confirms it independently, and what +# still works when the bot process itself is down. +connection_is_alive() { + if [[ "$(http_code "$BOT" 5)" == "200" ]]; then + return 0 + fi + local code + code="$(http_code "${API}/v1/devices/${PHONE_NUMBER}" "$PROBE_TIMEOUT")" + if [[ "$code" == "200" ]]; then + return 0 + fi + echo "== ${API}/v1/devices answered ${code}" + return 1 +} + +record_success() { + local previous + previous="$(read_count)" + printf '0\n' >"$FAILURE_FILE" + if ((previous >= FAILURES_BEFORE_RESTART)); then + alarm "signal-api is reachable again after ${previous} failed probe(s)." + fi +} + +# Restart history is durable and pruned to the window, so a fault that +# recurs every few hours cannot be papered over by a counter that resets. +recent_restarts() { + local now cutoff stamp count=0 pruned + now="$(date +%s)" + cutoff=$((now - RESTART_WINDOW)) + if [[ ! -f "$RESTART_FILE" ]]; then + printf '0' + return 0 + fi + pruned="${RESTART_FILE}.pruned" + : >"$pruned" + while read -r stamp; do + [[ "$stamp" =~ ^[0-9]+$ ]] || continue + ((stamp > cutoff)) || continue + printf '%s\n' "$stamp" >>"$pruned" + count=$((count + 1)) + done <"$RESTART_FILE" + mv "$pruned" "$RESTART_FILE" + printf '%s' "$count" +} + +give_up() { + alarm "signal-api has been restarted ${MAX_RESTARTS} time(s) and is still deaf. \ +This is probably not transient: the account may be deregistered, or the host \ +firewall may be blocking the container. Check 'docker exec ${CONTAINER} head -5 \ +/var/log/signal-cli-json-rpc-1/out.log' and 'docker exec ${CONTAINER} getent hosts \ +chat.signal.org'. No further restarts until the window passes." +} + +restart_container() { + date +%s >>"$RESTART_FILE" + alarm "signal-api cannot reach Signal; restarting ${CONTAINER}." + docker restart "$CONTAINER" >/dev/null || die "docker restart ${CONTAINER} failed" + sleep "$SETTLE_SECONDS" + if connection_is_alive; then + printf '0\n' >"$FAILURE_FILE" + alarm "signal-api is back: the connection to Signal is alive again." + return 0 + fi + alarm "signal-api restarted but is still deaf; will retry on the next check." +} + +main() { + validate_requirements + + if connection_is_alive; then + record_success + exit 0 + fi + + local failures + failures=$(($(read_count) + 1)) + printf '%s\n' "$failures" >"$FAILURE_FILE" + logger --priority user.warning --tag signal-api-watchdog -- \ + "signal-api probe failed (${failures}/${FAILURES_BEFORE_RESTART})" || true + echo "== probe failed (${failures}/${FAILURES_BEFORE_RESTART})" + + if ((failures < FAILURES_BEFORE_RESTART)); then + exit 0 + fi + if (($(recent_restarts) >= MAX_RESTARTS)); then + give_up + exit 0 + fi + restart_container +} + +while [[ $# -gt 0 ]]; do + case $1 in + -h | --help) usage ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +main diff --git a/scripts/test_watchdog.sh b/scripts/test_watchdog.sh new file mode 100755 index 0000000..afca9f3 --- /dev/null +++ b/scripts/test_watchdog.sh @@ -0,0 +1,229 @@ +#!/bin/bash + +# ============================================================================ +# Tests for scripts/signal_api_watchdog.sh. +# +# Hermetic: curl and docker are both stubbed on PATH, so nothing here talks to +# a network or a container. The stub docker can "fix" the stub curl's answers, +# which is how the restart-then-recover path is exercised without a restart. +# ============================================================================ + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly REPO_ROOT +readonly WATCHDOG="${REPO_ROOT}/scripts/signal_api_watchdog.sh" + +WORK="" +FAILURES=0 + +cleanup() { + if [[ -n "$WORK" && -d "$WORK" ]]; then + rm -rf "$WORK" + fi +} +trap cleanup EXIT + +fail() { + echo " FAIL: $1" >&2 + FAILURES=$((FAILURES + 1)) +} + +ok() { + echo " ok: $1" +} + +# Rebuild a pristine sandbox: stub binaries, stub answers, empty state. +setup() { + rm -rf "$WORK" + mkdir -p "$WORK/bin" "$WORK/state" + printf '200' >"$WORK/bot_code" + printf '200' >"$WORK/api_code" + printf '0' >"$WORK/fix_on_restart" + + cat >"$WORK/bin/curl" <"$WORK/bin/docker" <> "$WORK/docker.log" +if [[ "\$(cat "$WORK/fix_on_restart")" == "1" ]]; then + printf '200' > "$WORK/bot_code" + printf '200' > "$WORK/api_code" +fi +EOF + chmod +x "$WORK/bin/curl" "$WORK/bin/docker" +} + +run_watchdog() { + PATH="$WORK/bin:$PATH" \ + PHONE_NUMBER="+15550000000" \ + STATE_DIRECTORY="$WORK/state" \ + SETTLE_SECONDS=0 \ + FAILURES_BEFORE_RESTART=2 \ + MAX_RESTARTS=2 \ + RESTART_WINDOW=3600 \ + "$WATCHDOG" 2>&1 +} + +expect_contains() { + if [[ "$1" == *"$2"* ]]; then + ok "$3" + else + fail "$3 -- got: $1" + fi +} + +expect_missing() { + if [[ "$1" != *"$2"* ]]; then + ok "$3" + else + fail "$3 -- unexpectedly found $2" + fi +} + +expect_file() { + local actual + actual="$(cat "$1" 2>/dev/null || echo MISSING)" + if [[ "$actual" == "$2" ]]; then + ok "$3" + else + fail "$3 -- $1 is '$actual', expected '$2'" + fi +} + +test_healthy_is_free() { + echo "== a healthy bot needs no restart and no authenticated probe" + setup + run_watchdog >/dev/null + expect_file "$WORK/state/consecutive_failures" "0" "failure counter stays at zero" + if [[ -f "$WORK/docker.log" ]]; then + fail "docker was touched while everything was healthy" + else + ok "docker was never called" + fi +} + +test_one_failure_waits() { + echo "== one failed probe is not enough to restart anything" + setup + printf '400' >"$WORK/bot_code" + printf '400' >"$WORK/api_code" + local out + out="$(run_watchdog)" + expect_contains "$out" "probe failed (1/2)" "the failure is counted and logged" + expect_file "$WORK/state/consecutive_failures" "1" "counter persisted for the next firing" + if [[ -f "$WORK/docker.log" ]]; then + fail "docker was restarted after a single failure" + else + ok "docker was not restarted" + fi +} + +test_confirmed_failure_restarts_and_recovers() { + echo "== two failures restart the container, and recovery is announced" + setup + printf '400' >"$WORK/bot_code" + printf '400' >"$WORK/api_code" + printf '1' >"$WORK/fix_on_restart" + run_watchdog >/dev/null + local out + out="$(run_watchdog)" + expect_contains "$(cat "$WORK/docker.log")" "restart signal-api" "the container was restarted" + expect_contains "$out" "is back" "the recovery is announced" + expect_file "$WORK/state/consecutive_failures" "0" "the counter was cleared" +} + +test_restart_that_does_not_help() { + echo "== a restart that does not help says so instead of claiming success" + setup + printf '400' >"$WORK/bot_code" + printf '400' >"$WORK/api_code" + run_watchdog >/dev/null + local out + out="$(run_watchdog)" + expect_contains "$out" "still deaf" "the failed recovery is reported" +} + +test_gives_up_instead_of_looping() { + echo "== it gives up loudly rather than restarting forever" + setup + printf '400' >"$WORK/bot_code" + printf '400' >"$WORK/api_code" + local out="" attempt=0 + while ((attempt < 6)); do + out="$(run_watchdog)" + attempt=$((attempt + 1)) + done + local restarts + restarts="$(grep -c "restart signal-api" "$WORK/docker.log")" + if [[ "$restarts" == "2" ]]; then + ok "restarts stopped at the cap (2)" + else + fail "expected 2 restarts, got $restarts" + fi + expect_contains "$out" "still deaf" "the give-up message explains what to check" + expect_contains "$out" "out.log" "the give-up message names the log to read" +} + +test_requires_a_number() { + echo "== it refuses to run without PHONE_NUMBER" + setup + local out status=0 + out="$(PATH="$WORK/bin:$PATH" PHONE_NUMBER="" STATE_DIRECTORY="$WORK/state" \ + "$WATCHDOG" 2>&1)" || status=$? + if [[ "$status" == "1" ]]; then + ok "exited non-zero" + else + fail "expected exit 1, got $status" + fi + expect_contains "$out" "PHONE_NUMBER is unset" "it says which value is missing" +} + +test_bot_down_but_account_alive() { + echo "== the bot being down is not the container's fault" + setup + printf '000' >"$WORK/bot_code" + local out + out="$(run_watchdog)" + expect_missing "$out" "probe failed" "the authenticated probe carried the check" + if [[ -f "$WORK/docker.log" ]]; then + fail "docker was restarted although the account was reachable" + else + ok "docker was not restarted" + fi +} + +main() { + WORK="$(mktemp -d)" + [[ -d "$WORK" ]] || { + echo "Error: failed to create a temporary directory" >&2 + exit 1 + } + + echo "============================================================" + echo "signal_api_watchdog.sh" + echo "============================================================" + test_healthy_is_free + test_bot_down_but_account_alive + test_one_failure_waits + test_confirmed_failure_restarts_and_recovers + test_restart_that_does_not_help + test_gives_up_instead_of_looping + test_requires_a_number + + echo "============================================================" + if ((FAILURES > 0)); then + echo "FAILED: $FAILURES check(s)" + exit 1 + fi + echo "all checks passed" +} + +main "$@" diff --git a/signal_bot/app.py b/signal_bot/app.py index 606ae06..1de839b 100644 --- a/signal_bot/app.py +++ b/signal_bot/app.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from signal_bot.transport_base import Transport import httpx -from fastapi import FastAPI +from fastapi import FastAPI, Response, status from signal_bot.animals import register_animal_commands from signal_bot.config import Config, load_config @@ -25,10 +25,12 @@ from signal_bot.decorators import cooldown from signal_bot.discovery import GroupDirectory from signal_bot.dispatcher import Dispatcher from signal_bot.http_client import ImageFetcher +from signal_bot.liveness import Liveness from signal_bot.registry import CommandRegistry from signal_bot.scheduler import DailyScheduler from signal_bot.store import Store from signal_bot.transport_rest import RestTransport +from signal_bot.trust import InviteTrust logger = logging.getLogger(__name__) @@ -50,6 +52,7 @@ class Bot: transport: Transport directory: GroupDirectory dispatcher: Dispatcher + liveness: Liveness async def publish_report(self) -> None: """Post each group's own tally into that group, and reset it. @@ -78,6 +81,10 @@ class Bot: async def listen(self) -> None: """Consume frames forever, handing each to the dispatcher.""" async for frame in self.transport.receive(): + # Every frame counts, including receipts and typing indicators: + # the question this answers is "can anything still reach us?", + # not "did anybody address the bot?". + self.liveness.note_envelope() await self.dispatcher.handle(frame) @@ -87,6 +94,7 @@ def build_bot( store: Store, transport: Transport, directory: GroupDirectory, + liveness: Liveness, ) -> Bot: """Assemble the object graph from its injected dependencies.""" registry = CommandRegistry() @@ -110,6 +118,7 @@ def build_bot( transport=transport, directory=directory, dispatcher=dispatcher, + liveness=liveness, ) @@ -137,6 +146,9 @@ class Runtime: # Third loop: without it, a group added later is only noticed # when somebody happens to speak in it. asyncio.create_task(self.bot.directory.run()), + # Fourth loop: without it, a dead connection to Signal is + # indistinguishable from a quiet evening, for hours. + asyncio.create_task(self.bot.liveness.run()), ] async def stop(self) -> None: @@ -155,11 +167,15 @@ async def create_runtime(config: Config, client: httpx.AsyncClient) -> Runtime: store = Store(config.database_path) transport = RestTransport(config, client) await transport.verify_mode() - directory = GroupDirectory(config, client, store) + directory = GroupDirectory(config, client, store, InviteTrust(config.trusted_inviters)) await directory.refresh() await directory.verify_allowlist() + # No probe here: /v1/devices has been seen to hang past 20s, and a + # startup that can hang is a startup that can burn the unit's restart + # limit. The gate starts healthy and earns its verdict at runtime. + liveness = Liveness(config, client) return Runtime( - bot=build_bot(config, client, store, transport, directory), + bot=build_bot(config, client, store, transport, directory, liveness), client=client, store=store, ) @@ -194,6 +210,15 @@ app = FastAPI(lifespan=lifespan) @app.get("/healthz") -async def healthz() -> dict[str, str]: - """Liveness probe. The old app defined no routes at all.""" - return {"status": "ok"} +async def healthz(response: Response) -> dict[str, str]: + """Liveness probe, and the only place the bot admits it has gone deaf. + + Answering 200 while nothing can reach Signal is what let a three-hour + outage look like a quiet evening, so this must go red before anybody + thinks to look at the logs. + """ + liveness: Liveness = app.state.runtime.bot.liveness + if liveness.healthy: + return {"status": "ok"} + response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE + return {"status": "deaf", "reason": liveness.reason} diff --git a/signal_bot/config.py b/signal_bot/config.py index 63d6462..4460d15 100644 --- a/signal_bot/config.py +++ b/signal_bot/config.py @@ -20,6 +20,7 @@ from signal_bot.groups import ( group_id_aliases, parse_group_ids, ) +from signal_bot.trust import parse_trusted_inviters __all__ = [ "Config", @@ -27,6 +28,7 @@ __all__ = [ "canonical_group_id", "group_id_aliases", "load_config", + "parse_trusted_inviters", ] DEFAULT_API_BASE_URL: Final = "http://localhost:9922" @@ -46,6 +48,10 @@ MAX_HOUR: Final = 23 MAX_MINUTE: Final = 59 MAX_PORT: Final = 65535 DEFAULT_LOG_LEVEL: Final = "INFO" +# How long the bot may hear nothing at all before it stops believing its own +# health. Silence is normal -- a group can be quiet all evening -- so this is +# only the point at which silence becomes worth *asking* about. +DEFAULT_SILENCE_SECONDS: Final = 120 _WS_SCHEMES: Final = {"http": "ws", "https": "wss"} @@ -77,6 +83,11 @@ class Config: # unreachable in production, which is how a scoping mismatch turns # into a bot that looks healthy and answers nothing. log_level: str = DEFAULT_LOG_LEVEL + # Who may add this bot to a group. Empty means anyone. See signal_bot.trust. + trusted_inviters: tuple[str, ...] = () + # Lowered in the e2e run so the deafness gate can be proven against the + # real process rather than only in unit tests. + silence_seconds: int = DEFAULT_SILENCE_SECONDS @property def is_pinned(self) -> bool: @@ -125,6 +136,19 @@ class Config: """REST endpoint reporting the container build and its mode.""" return f"{self.api_base_url}/v1/about" + @property + def devices_url(self) -> str: + """The one endpoint that fails when signal-cli cannot reach Signal. + + Verified on 2026-08-29, in both directions: 200 with a device list + while healthy, and 400 "Failed to get linked devices: Closed + unexpectedly" during the outage -- while /v1/groups and /v1/about + kept answering 200 from local state and the container's own + healthcheck stayed green. Local state is why the bot could look + perfectly healthy for three hours with a dead connection. + """ + return f"{self.api_base_url}/v1/devices/{self.phone_number}" + @property def groups_url(self) -> str: """REST endpoint listing the groups this account knows about.""" @@ -200,4 +224,6 @@ def load_config(env: dict[str, str] | None = None, *, use_dotenv: bool = True) - bind_host=env.get("BIND_HOST", "").strip() or DEFAULT_BIND_HOST, bind_port=bind_port, log_level=log_level, + trusted_inviters=parse_trusted_inviters(env), + silence_seconds=_positive_int(env, "SILENCE_SECONDS", DEFAULT_SILENCE_SECONDS), ) diff --git a/signal_bot/discovery.py b/signal_bot/discovery.py index 01166fa..8d4bb9c 100644 --- a/signal_bot/discovery.py +++ b/signal_bot/discovery.py @@ -23,11 +23,13 @@ from typing import TYPE_CHECKING, Any, Final import httpx from signal_bot.groups import build_alias_map, canonical_group_id -from signal_bot.membership import JoinAttempts, MembershipError, attempt_join +from signal_bot.membership import JoinAttempts, attempt_join, verify_allowlist if TYPE_CHECKING: from signal_bot.config import Config + from signal_bot.models import IncomingMessage from signal_bot.store import Store + from signal_bot.trust import InviteTrust logger = logging.getLogger(__name__) @@ -44,6 +46,7 @@ class GroupDirectory: config: Config, client: httpx.AsyncClient, store: Store, + trust: InviteTrust, *, sleeper: Sleeper | None = None, ) -> None: @@ -54,11 +57,14 @@ class GroupDirectory: client: HTTP client for the REST API. store: Where per-group state lives, so a group the bot is removed from can have its rows dropped. + trust: Decides whether a discovered group is one the operator + asked for. sleeper: Async sleep used by run(). Injected by the tests. """ self._config = config self._client = client self._store = store + self._trust = trust self._sleep = sleeper if sleeper is not None else asyncio.sleep self._aliases: dict[str, str] = {} self._groups: tuple[str, ...] = () @@ -92,6 +98,20 @@ class GroupDirectory: return None return self._aliases.get(group_id) + def note_group_update(self, message: IncomingMessage) -> None: + """Record who changed a group's membership. + + Called before the scoping gate on purpose: the frame that names the + inviter arrives while the group it describes is still unknown, and + it is the only authoritative answer to "who added this bot?". + + Args: + message: Any parsed inbound message. + """ + if message.group_id is None or not message.is_group_update: + return + self._trust.note_update(message.group_id, message.source_number, message.source_uuid) + async def refresh(self) -> None: """Rebuild the watched set from the account's own group listing. @@ -125,28 +145,8 @@ class GroupDirectory: return self.resolve(group_id) async def verify_allowlist(self) -> None: - """Refuse to start when GROUP_IDS names a group the bot cannot use. - - Only applies when the operator pinned the bot to specific groups. An - unpinned bot watches whatever it has been added to, including - nothing at all, and that is not an error -- it is a bot waiting to - be added. - - Raises: - MembershipError: A pinned group is missing or unjoinable. - """ - if not self._config.is_pinned: - logger.info("watching every group this account is a member of") - return - missing = [group_id for group_id in self._config.group_ids if group_id not in self._groups] - if missing: - details = ", ".join(f"{gid} ({self._joins.reason(gid)})" for gid in missing) - msg = ( - f"GROUP_IDS pins the bot to {len(self._config.group_ids)} group(s) but it is " - f"not a joined member of: {details}. Have an admin add the bot again now that " - "it has a profile, or drop the id from GROUP_IDS." - ) - raise MembershipError(msg) + """Refuse to start when GROUP_IDS names a group the bot cannot use.""" + verify_allowlist(self._config, self._groups, self._joins) async def run(self, iterations: int | None = None) -> None: """Refresh forever, so a group added later needs no restart. @@ -189,7 +189,15 @@ class GroupDirectory: if not isinstance(raw, str) or not raw: return None group_id = canonical_group_id(raw) - return group_id if self._config.allows(group_id) else None + if not self._config.allows(group_id): + return None + # A pinned id was named by the operator in .env, which is already the + # strongest possible vouch; running it past the trust gate as well + # would make verify_allowlist fail with a message about joins that + # says nothing about trust. + if self._config.is_pinned or self._trust.allows(group_id, entry): + return group_id + return None async def _join_pending(self, entries: list[dict[str, Any]]) -> bool: """Try to accept every invitation worth retrying. diff --git a/signal_bot/dispatcher.py b/signal_bot/dispatcher.py index 30fa018..df81a79 100644 --- a/signal_bot/dispatcher.py +++ b/signal_bot/dispatcher.py @@ -41,6 +41,10 @@ class Dispatcher: if message is None: return + # Before the scoping gate: a group-update frame arrives while its + # group is still unknown, and it is what vouches for that group. + self.directory.note_group_update(message) + # The scoping gate. Direct messages and every other group the account # belongs to stop here, before counting and before any handler runs. # The canonical id it yields is the reply address and the key for diff --git a/signal_bot/liveness.py b/signal_bot/liveness.py new file mode 100644 index 0000000..962457c --- /dev/null +++ b/signal_bot/liveness.py @@ -0,0 +1,172 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Whether the bot can still hear, and the gate that stops it saying yes anyway. + +On 2026-08-29 signal-cli's connection to Signal died and reconnected in a +loop for three hours. Every surface said the bot was fine: the container's +own healthcheck answered 204, ``/v1/groups`` answered 200 out of local +state, ``/healthz`` answered 200, and the bot's WebSocket to the container +stayed *open* -- the container keeps client sockets alive even when its own +upstream is dead, so the transport's reconnect logic never fired. The only +symptom was silence, and silence is also what a quiet evening looks like. + +So this module answers "is the silence real?" with a question nobody can +answer from a cache: ``/v1/devices``, which needs a live round trip to +Signal. It is asked only after the silence has gone on long enough to be +worth asking about, so a busy group costs nothing and an idle one costs one +request per silence window. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Final + +import httpx + +if TYPE_CHECKING: + from signal_bot.config import Config + +logger = logging.getLogger(__name__) + +TICK_SECONDS: Final = 30.0 +# Measured against the real failure: the broken endpoint answered in 3.9s +# once and hung past 20s another time. 15s is above the observed failure +# latency and below TICK_SECONDS, so probes can never pile up. +PROBE_TIMEOUT_SECONDS: Final = 15.0 +# signal-cli's own reconnect loop backs off in 100..3200ms, so the failure is +# stable within a second. Three probes 30s apart is not "waiting it out", it +# is refusing to trip on one bad round trip. +FAILURES_BEFORE_DEAF: Final = 3 + +Ticker = Callable[[], float] +Sleeper = Callable[[float], Awaitable[None]] + + +class Liveness: + """Tracks proof that messages can still arrive, and reports when they cannot.""" + + def __init__( + self, + config: Config, + client: httpx.AsyncClient, + *, + ticker: Ticker | None = None, + sleeper: Sleeper | None = None, + failures_before_deaf: int = FAILURES_BEFORE_DEAF, + ) -> None: + """Wire the gate to its config and its injectable I/O. + + Args: + config: Resolved runtime configuration. + client: HTTP client used for the probe. + ticker: Monotonic clock. Injected by the tests. + sleeper: Async sleep used by run(). Injected by the tests. + failures_before_deaf: Consecutive failed probes before the bot + stops calling itself healthy. + """ + self._config = config + self._client = client + self._now = ticker if ticker is not None else time.monotonic + self._sleep = sleeper if sleeper is not None else asyncio.sleep + self._limit = failures_before_deaf + # Healthy until proven otherwise: the installer waits on /healthz + # with a 60s budget, and a cold "unknown means 503" would fail the + # install and burn the unit's restart limit for no reason. + self._last_alive = self._now() + self._failures = 0 + self._detail = "" + + @property + def healthy(self) -> bool: + """True while the bot has no evidence that it has gone deaf.""" + return self._failures < self._limit + + @property + def silent_for(self) -> float: + """Seconds since the last envelope or successful probe.""" + return self._now() - self._last_alive + + @property + def reason(self) -> str: + """Why the bot believes it is deaf, in one line for /healthz.""" + return ( + f"nothing received for {self.silent_for:.0f}s and {self._failures} " + f"consecutive failed probes of {self._config.devices_url}: {self._detail}" + ) + + def note_envelope(self) -> None: + """Record proof of life: any frame at all, or a successful probe.""" + self._last_alive = self._now() + if self._failures: + logger.warning("receive path recovered after %d failed probe(s)", self._failures) + self._failures = 0 + self._detail = "" + + async def probe(self) -> bool: + """Ask signal-cli something it cannot answer from local state. + + Returns: + True when the account's connection to Signal is alive. + """ + try: + response = await self._client.get( + self._config.devices_url, timeout=PROBE_TIMEOUT_SECONDS + ) + except httpx.HTTPError as exc: + self._detail = f"{type(exc).__name__}: {exc}" + return False + if response.status_code != httpx.codes.OK: + self._detail = f"HTTP {response.status_code}: {response.text.strip()[:200]}" + return False + return True + + async def run(self, iterations: int | None = None) -> None: + """Check for deafness forever. + + Args: + iterations: Stop after this many ticks. None means forever. + """ + completed = 0 + while iterations is None or completed < iterations: + await self._sleep(TICK_SECONDS) + await self.check() + completed += 1 + + async def check(self) -> None: + """Probe once, but only when the silence has lasted long enough. + + Public because it is the whole gate in one call: the tests, and any + future hand-run diagnostic, must be able to ask the same question + the loop asks without reaching into private state. + """ + silent = self.silent_for + if silent < self._config.silence_seconds: + return + if await self.probe(): + # A live probe is proof of life exactly like an envelope, and + # resetting the clock here is what bounds the cost: an idle + # account probes once per silence window, not once per tick. + self.note_envelope() + return + self._failures += 1 + if self._failures <= self._limit: + logger.warning( + "signal-cli cannot reach Signal (failure %d of %d) after %.0fs of silence: %s", + self._failures, + self._limit, + silent, + self._detail, + ) + return + # Past the limit the verdict is already in, and "failure 8 of 3" + # reads like a bug in the counter rather than a bot that is still + # deaf. Keep saying it, but say it truthfully. + logger.warning( + "still deaf: %d failed probe(s) and %.0fs of silence: %s", + self._failures, + silent, + self._detail, + ) diff --git a/signal_bot/membership.py b/signal_bot/membership.py index 2c8c555..639e093 100644 --- a/signal_bot/membership.py +++ b/signal_bot/membership.py @@ -104,3 +104,32 @@ async def attempt_join(client: httpx.AsyncClient, config: Config, group_id: str) except httpx.HTTPError as exc: return f"join request failed: {exc}" return f"join returned HTTP {response.status_code}: {response.text.strip()[:200]}" + + +def verify_allowlist(config: Config, groups: tuple[str, ...], joins: JoinAttempts) -> None: + """Refuse to start when GROUP_IDS names a group the bot is not in. + + Only applies when the operator pinned the bot to specific groups. An + unpinned bot watches whatever it has been added to, including nothing at + all, and that is not an error -- it is a bot waiting to be added. + + Args: + config: Resolved runtime configuration. + groups: The groups the bot actually discovered. + joins: Why a join failed, for the error message. + + Raises: + MembershipError: A pinned group is missing or unjoinable. + """ + if not config.is_pinned: + logger.info("watching every group this account is a member of") + return + missing = [group_id for group_id in config.group_ids if group_id not in groups] + if missing: + details = ", ".join(f"{gid} ({joins.reason(gid)})" for gid in missing) + msg = ( + f"GROUP_IDS pins the bot to {len(config.group_ids)} group(s) but it is " + f"not a joined member of: {details}. Have an admin add the bot again now that " + "it has a profile, or drop the id from GROUP_IDS." + ) + raise MembershipError(msg) diff --git a/signal_bot/models.py b/signal_bot/models.py index 5d316e0..d10c77b 100644 --- a/signal_bot/models.py +++ b/signal_bot/models.py @@ -24,6 +24,13 @@ class IncomingMessage: text: str | None has_sticker: bool is_reaction: bool + # The sender's +E.164 number. "source" carries it too, but only + # sometimes: live envelopes have shown a display name there and the + # number here, so this is the field a trust decision may rely on. + source_number: str = "" + # A group membership change rather than a message: this is the only + # frame that names who added the bot to a group. + is_group_update: bool = False @property def is_direct_message(self) -> bool: @@ -59,6 +66,15 @@ def _group_id(payload: dict[str, Any]) -> str | None: return group_id +def _group_type(payload: dict[str, Any]) -> str: + """Return the groupInfo type ("DELIVER", "UPDATE", ...), or "".""" + info = payload.get("groupInfo") + if not isinstance(info, dict): + return "" + group_type = info.get("type") + return group_type if isinstance(group_type, str) else "" + + def _text(payload: dict[str, Any]) -> str | None: """Return the message body, or None when there is no text.""" message = payload.get("message") @@ -102,4 +118,6 @@ def parse_envelope(raw: str | bytes) -> IncomingMessage | None: # filtered. Reading it off the resolved payload fixes both shapes. has_sticker=isinstance(payload.get("sticker"), dict), is_reaction=isinstance(payload.get("reaction"), dict), + source_number=str(envelope.get("sourceNumber", "")), + is_group_update=_group_type(payload) == "UPDATE", ) diff --git a/signal_bot/trust.py b/signal_bot/trust.py new file mode 100644 index 0000000..951f878 --- /dev/null +++ b/signal_bot/trust.py @@ -0,0 +1,149 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Who is allowed to put this bot in a group. + +The bot adopts every group its account is a member of, which is what makes +"add the bot" the whole setup. The cost is that *anybody* who knows the +number can pull it into a group and have it answer there. TRUSTED_INVITERS +puts a name on the door without taking the convenience away: a group still +needs no config edit and no restart, it just has to involve somebody the +operator listed. + +Two independent pieces of evidence, because neither covers the other: + +- the **group-update envelope**, which is the only frame that names who + changed the membership. It is authoritative, and it arrives before the + first message in a new group -- but only if the bot was running. +- the **listing row**, used when the bot was down when the group appeared. + ``/v1/groups`` never says who invited anyone, so the proxy is "a trusted + identity is a full member of this group". Deliberately a proxy: it stops + a stranger's private group, and it does not stop a stranger's group that + a trusted person genuinely joined. + +Identities are matched as the listing spells them. A row lists a phone +number for contacts the account knows and a bare UUID for everybody else, +so TRUSTED_INVITERS accepts either form and a refusal logs the row's own +members, which is what tells the operator which string to add. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Final + +from signal_bot.groups import ConfigError, canonical_group_id + +logger = logging.getLogger(__name__) + +TRUSTED_INVITERS_KEY: Final = "TRUSTED_INVITERS" +# +E.164, or the UUID the listing uses for a member the account has never +# exchanged messages with. +_E164: Final = re.compile(r"^\+[1-9]\d{6,14}$") +_UUID: Final = re.compile(r"^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$") + + +def parse_trusted_inviters(env: dict[str, str]) -> tuple[str, ...]: + """Return the identities allowed to add this bot to a group. + + Args: + env: The environment mapping to read. + + Returns: + The listed identities, de-duplicated and in order. Empty means the + gate is off and any group the account is in will be watched. + + Raises: + ConfigError: An entry is neither +E.164 nor a UUID. + """ + raw = env.get(TRUSTED_INVITERS_KEY, "").strip() + if not raw: + return () + trusted: list[str] = [] + for item in raw.split(","): + identity = item.strip() + if not identity: + continue + if not _E164.match(identity) and not _UUID.match(identity): + msg = ( + f"{TRUSTED_INVITERS_KEY} entries must be +E.164 numbers or member " + f"UUIDs, got {identity!r}" + ) + raise ConfigError(msg) + if identity not in trusted: + trusted.append(identity) + return tuple(trusted) + + +def _identities(entry: dict[str, Any], key: str) -> set[str]: + """Return one listing field as a set of strings, tolerating anything.""" + values = entry.get(key) + if not isinstance(values, list): + return set() + return {value for value in values if isinstance(value, str)} + + +class InviteTrust: + """Decides whether a group in the listing is one the operator asked for.""" + + def __init__(self, trusted: tuple[str, ...]) -> None: + """Wire the gate to its allowlist. + + Args: + trusted: Identities permitted to add the bot to a group. Empty + turns the gate off, which is said out loud rather than + assumed, because the difference is who can make this bot + talk in their group. + """ + self._trusted = frozenset(trusted) + self._vouched: set[str] = set() + self._refused: set[str] = set() + if not self._trusted: + logger.warning( + "%s is unset: every group this account is added to will be watched, " + "whoever added it", + TRUSTED_INVITERS_KEY, + ) + + def note_update(self, group_id: str, source_number: str, source_uuid: str) -> None: + """Record a membership change, vouching for the group when it is trusted. + + Args: + group_id: The group id as the envelope spelled it. + source_number: The +E.164 number that made the change, if known. + source_uuid: The uuid that made the change. + """ + if source_number not in self._trusted and source_uuid not in self._trusted: + return + canonical = canonical_group_id(group_id) + if canonical not in self._vouched: + self._vouched.add(canonical) + self._refused.discard(canonical) + logger.info("a trusted inviter added the bot to %s", canonical) + + def allows(self, group_id: str, entry: dict[str, Any]) -> bool: + """True when this group may be watched. + + Args: + group_id: The canonical group id. + entry: The group's row from ``/v1/groups``. + + Returns: + True when the gate is off, the group was vouched for by an + update envelope, or a trusted identity is a full member. + """ + if not self._trusted or group_id in self._vouched: + return True + members = _identities(entry, "members") - _identities(entry, "pending_invites") + if self._trusted & members: + return True + if group_id not in self._refused: + self._refused.add(group_id) + logger.warning( + "refusing group %r (%s): no trusted inviter. Its members are %s; " + "add one of them to %s if this group is yours.", + entry.get("name"), + group_id, + sorted(members), + TRUSTED_INVITERS_KEY, + ) + return False diff --git a/tests/conftest.py b/tests/conftest.py index 4362f52..333a764 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,7 @@ from signal_bot.config import Config from signal_bot.discovery import GroupDirectory from signal_bot.store import Store from signal_bot.transport_fake import FakeTransport +from signal_bot.trust import InviteTrust # Canonical ``group.`` ids, the spelling /v1/groups reports and the # only one /v2/send accepts. The *_INTERNAL forms are what an envelope @@ -24,6 +25,10 @@ SECOND_GROUP = "group.YzJWamIyNWtMV2R5YjNWd0xXbGtMVE15TFdKNWRHVnpJU0VoSVNFaA==" SECOND_INTERNAL = "c2Vjb25kLWdyb3VwLWlkLTMyLWJ5dGVzISEhISEh" OTHER_GROUP = "group.YjNSb1pYSXRaM0p2ZFhBdGFXUXRNekl0WW5sMFpYTWhJU0VoSVNFPQ==" +# The operator, and somebody who is not. +TRUSTED_NUMBER = "+48123456789" +STRANGER_NUMBER = "+15555550123" + @pytest.fixture def config() -> Config: @@ -52,7 +57,7 @@ def listing_client(entries: list[dict[str, Any]]) -> httpx.AsyncClient: async def directory(config: Config, store: Store) -> GroupDirectory: """A directory that has already discovered both test groups.""" client = listing_client([group_entry(WATCHED_GROUP), group_entry(SECOND_GROUP)]) - found = GroupDirectory(config, client, store) + found = GroupDirectory(config, client, store, InviteTrust(())) await found.refresh() return found @@ -96,6 +101,7 @@ def envelope( body: dict[str, Any] = { "source": name, + "sourceNumber": STRANGER_NUMBER, "sourceUuid": uuid, "sourceName": name, "sourceDevice": 69, @@ -106,3 +112,33 @@ def envelope( else: body["dataMessage"] = payload return json.dumps({"envelope": body, "account": "+15550000000"}) + + +def update_envelope( + *, + group_id: str = WATCHED_GROUP, + number: str = TRUSTED_NUMBER, + uuid: str = "uuid-operator", +) -> str: + """Build a group-membership-change frame: no text, groupInfo type UPDATE. + + This is the shape signal-cli emits when somebody adds the bot to a + group, and the only frame that names who did it. + """ + return json.dumps( + { + "envelope": { + "source": number, + "sourceNumber": number, + "sourceUuid": uuid, + "sourceName": "kuhy", + "timestamp": 1719177917717, + "dataMessage": { + "timestamp": 1719177917717, + "message": None, + "groupInfo": {"groupId": group_id, "type": "UPDATE"}, + }, + }, + "account": "+15550000000", + } + ) diff --git a/tests/test_allowlist.py b/tests/test_allowlist.py index 2ab4f56..13f759d 100644 --- a/tests/test_allowlist.py +++ b/tests/test_allowlist.py @@ -11,6 +11,7 @@ import pytest from signal_bot.discovery import GroupDirectory from signal_bot.membership import MembershipError +from signal_bot.trust import InviteTrust from tests.conftest import SECOND_GROUP, WATCHED_GROUP, group_entry if TYPE_CHECKING: @@ -19,7 +20,12 @@ if TYPE_CHECKING: def _directory(config: Config, store: Store, handler: object) -> GroupDirectory: - return GroupDirectory(config, httpx.AsyncClient(transport=httpx.MockTransport(handler)), store) + return GroupDirectory( + config, + httpx.AsyncClient(transport=httpx.MockTransport(handler)), + store, + InviteTrust(()), + ) async def test_the_allowlist_restricts_what_is_adopted(config: Config, store: Store) -> None: @@ -85,6 +91,7 @@ async def test_run_refreshes_on_a_schedule(config: Config, store: Store) -> None transport=httpx.MockTransport(lambda r: httpx.Response(200, json=next(listings))) ), store, + InviteTrust(()), sleeper=sleeper, ) await found.run(iterations=2) diff --git a/tests/test_app.py b/tests/test_app.py index 012b71c..d494e6a 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -14,9 +14,11 @@ from fastapi.testclient import TestClient from signal_bot import app as app_module from signal_bot.app import Bot, Runtime, build_bot, create_runtime, lifespan, now_local from signal_bot.discovery import GroupDirectory +from signal_bot.liveness import Liveness from signal_bot.membership import MembershipError from signal_bot.transport_fake import FakeTransport from signal_bot.transport_rest import TransportError +from signal_bot.trust import InviteTrust from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope, group_entry, listing_client if TYPE_CHECKING: @@ -52,7 +54,8 @@ def _signal(*group_ids: str, mode: str = "json-rpc", member: bool = True) -> Han def _bot(config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory) -> Bot: - return build_bot(config, _client(lambda r: httpx.Response(200)), store, transport, directory) + client = _client(lambda r: httpx.Response(200)) + return build_bot(config, client, store, transport, directory, Liveness(config, client)) def test_now_local_is_timezone_aware() -> None: @@ -118,8 +121,8 @@ async def test_runtime_start_and_stop_cancels_tasks( client = _client(lambda r: httpx.Response(200)) runtime = Runtime(bot=_bot(config, store, transport, directory), client=client, store=store) runtime.start() - # listen, the daily report, and the group refresh loop. - assert len(runtime.tasks) == 3 + # listen, the daily report, the group refresh loop, the deafness gate. + assert len(runtime.tasks) == 4 await asyncio.sleep(0) await runtime.stop() assert all(task.done() for task in runtime.tasks) @@ -214,7 +217,7 @@ async def test_the_report_follows_the_live_group_set( ) -> None: """Groups come from the directory, not from .env, so a new one is included.""" client = listing_client([group_entry(WATCHED_GROUP), group_entry(SECOND_GROUP)]) - directory = GroupDirectory(config, client, store) + directory = GroupDirectory(config, client, store, InviteTrust(())) await directory.refresh() store.increment(WATCHED_GROUP, "u1", "Alice") store.increment(SECOND_GROUP, "u2", "Bob") diff --git a/tests/test_discovery.py b/tests/test_discovery.py index ee642ae..a19e14e 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -9,6 +9,7 @@ import httpx import pytest from signal_bot.discovery import GroupDirectory +from signal_bot.trust import InviteTrust from tests.conftest import ( OTHER_GROUP, SECOND_GROUP, @@ -26,13 +27,21 @@ Listing = list[dict[str, Any]] def _directory(config: Config, store: Store, handler: object) -> GroupDirectory: - return GroupDirectory(config, httpx.AsyncClient(transport=httpx.MockTransport(handler)), store) + return GroupDirectory( + config, + httpx.AsyncClient(transport=httpx.MockTransport(handler)), + store, + InviteTrust(()), + ) async def test_discovers_every_group_the_account_is_in(config: Config, store: Store) -> None: """No GROUP_IDS at all: whatever the account is a member of is watched.""" found = GroupDirectory( - config, listing_client([group_entry(WATCHED_GROUP), group_entry(SECOND_GROUP)]), store + config, + listing_client([group_entry(WATCHED_GROUP), group_entry(SECOND_GROUP)]), + store, + InviteTrust(()), ) await found.refresh() assert found.groups == (WATCHED_GROUP, SECOND_GROUP) diff --git a/tests/test_fake_account.py b/tests/test_fake_account.py index 73af42c..d4eced2 100644 --- a/tests/test_fake_account.py +++ b/tests/test_fake_account.py @@ -111,3 +111,33 @@ def test_control_replaces_accounts_rather_than_appending(client: TestClient) -> client.post("/_control/accounts", json=["+15550000001"]) client.post("/_control/accounts", json=["+15550000002"]) assert client.get("/v1/accounts").json() == ["+15550000002"] + + +def test_devices_needs_a_registered_account(client: TestClient) -> None: + response = client.get(f"/v1/devices/{NUMBER}") + assert response.status_code == 400 + assert "not registered" in response.json()["error"] + + +def test_devices_answers_while_the_connection_is_alive(client: TestClient, state: State) -> None: + state.accounts.append(NUMBER) + response = client.get(f"/v1/devices/{NUMBER}") + assert response.status_code == 200 + assert response.json()[0]["id"] == 1 + + +def test_devices_reproduces_the_outage_when_the_connection_is_cut( + client: TestClient, state: State +) -> None: + """The 2026-08-29 shape: local state fine, anything upstream 400s.""" + state.accounts.append(NUMBER) + assert client.post("/_control/connection", json={"alive": False}).json() == {"alive": False} + + response = client.get(f"/v1/devices/{NUMBER}") + assert response.status_code == 400 + assert response.json()["error"] == "Failed to get linked devices: Closed unexpectedly" + # Local state keeps answering, which is what made this invisible. + assert client.get("/v1/accounts").status_code == 200 + + assert client.post("/_control/connection", json={"alive": True}).json() == {"alive": True} + assert client.get(f"/v1/devices/{NUMBER}").status_code == 200 diff --git a/tests/test_healthz.py b/tests/test_healthz.py new file mode 100644 index 0000000..ed89274 --- /dev/null +++ b/tests/test_healthz.py @@ -0,0 +1,52 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""/healthz must go red when the bot cannot hear, not just when it is down.""" + +from __future__ import annotations + +from dataclasses import replace +from types import SimpleNamespace +from typing import TYPE_CHECKING + +import httpx +from fastapi import Response + +from signal_bot import app as app_module +from signal_bot.app import healthz +from signal_bot.liveness import Liveness + +if TYPE_CHECKING: + from signal_bot.config import Config + +DEAD_BODY = {"error": "Failed to get linked devices: Closed unexpectedly"} + + +def _gate(config: Config, response: httpx.Response) -> Liveness: + """A liveness gate whose probe always gets this answer.""" + client = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: response)) + return Liveness(config, client) + + +def _install(liveness: Liveness) -> None: + """Point the app at a runtime carrying this gate.""" + app_module.app.state.runtime = SimpleNamespace(bot=SimpleNamespace(liveness=liveness)) + + +async def test_a_healthy_bot_answers_ok(config: Config) -> None: + _install(_gate(config, httpx.Response(200, json=[]))) + response = Response() + assert await healthz(response) == {"status": "ok"} + assert response.status_code == httpx.codes.OK + + +async def test_a_deaf_bot_answers_503_and_says_why(config: Config) -> None: + """The failure this exists for: up, listening, and hearing nothing.""" + live = _gate(replace(config, silence_seconds=0), httpx.Response(400, json=DEAD_BODY)) + for _ in range(3): + await live.check() + _install(live) + + response = Response() + body = await healthz(response) + assert response.status_code == httpx.codes.SERVICE_UNAVAILABLE + assert body["status"] == "deaf" + assert "Closed unexpectedly" in body["reason"] diff --git a/tests/test_liveness.py b/tests/test_liveness.py new file mode 100644 index 0000000..adb0f04 --- /dev/null +++ b/tests/test_liveness.py @@ -0,0 +1,177 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The deafness gate: silence plus a failing probe is not health.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import httpx + +from signal_bot.liveness import Liveness + +if TYPE_CHECKING: + from collections.abc import Callable + + import pytest + + from signal_bot.config import Config + +DEAD_BODY = {"error": "Failed to get linked devices: Closed unexpectedly"} + + +class Clock: + """A monotonic clock the test moves by hand.""" + + def __init__(self) -> None: + self.seconds = 0.0 + + def __call__(self) -> float: + return self.seconds + + +def gate( + config: Config, + handler: Callable[[httpx.Request], httpx.Response], + clock: Clock, + *, + slept: list[float] | None = None, +) -> Liveness: + """A Liveness wired to a mock API and a hand-cranked clock.""" + + async def sleeper(seconds: float) -> None: + if slept is not None: + slept.append(seconds) + + return Liveness( + config, + httpx.AsyncClient(transport=httpx.MockTransport(handler)), + ticker=clock, + sleeper=sleeper, + ) + + +async def test_it_starts_healthy_without_asking_anybody(config: Config) -> None: + """A cold start must not 503: the installer waits on /healthz.""" + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(str(request.url)) + return httpx.Response(200, json=[]) + + assert gate(config, handler, Clock()).healthy is True + assert calls == [] + + +async def test_a_live_account_answers_the_probe(config: Config) -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v1/devices/+15550000000" + return httpx.Response(200, json=[{"id": 1}]) + + assert await gate(config, handler, Clock()).probe() is True + + +async def test_the_real_outage_shape_fails_the_probe(config: Config) -> None: + live = gate(config, lambda r: httpx.Response(400, json=DEAD_BODY), Clock()) + assert await live.probe() is False + assert "Closed unexpectedly" in live.reason + + +async def test_a_hanging_api_fails_the_probe_rather_than_the_loop(config: Config) -> None: + """/v1/devices was seen to hang past 20s during the outage.""" + + timed_out = "timed out" + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.TimeoutException(timed_out, request=request) + + live = gate(config, handler, Clock()) + assert await live.probe() is False + assert "TimeoutException" in live.reason + + +async def test_silence_alone_never_probes(config: Config) -> None: + """A quiet group is normal; only long silence is worth a round trip.""" + calls: list[str] = [] + clock = Clock() + slept: list[float] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(str(request.url)) + return httpx.Response(200, json=[]) + + live = gate(config, handler, clock, slept=slept) + await live.run(iterations=2) + assert slept == [30.0, 30.0] + assert calls == [] + assert live.healthy is True + + +async def test_a_successful_probe_resets_the_silence_clock(config: Config) -> None: + """One probe per silence window, not one per tick.""" + calls: list[str] = [] + clock = Clock() + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(str(request.url)) + clock.seconds += 30.0 + return httpx.Response(200, json=[]) + + live = gate(config, handler, clock) + + async def tick() -> None: + clock.seconds += 30.0 + await live.run(iterations=1) + + for _ in range(8): + await tick() + assert len(calls) == 2 + assert live.healthy is True + + +async def test_three_failures_make_the_bot_admit_it_is_deaf( + config: Config, caplog: pytest.LogCaptureFixture +) -> None: + clock = Clock() + live = gate(config, lambda r: httpx.Response(400, json=DEAD_BODY), clock) + with caplog.at_level(logging.WARNING): + for _ in range(3): + clock.seconds += 200.0 + await live.run(iterations=1) + if live.healthy: + assert "cannot reach Signal" in caplog.text + assert live.healthy is False + assert "nothing received for" in live.reason + assert caplog.text.count("cannot reach Signal") == 3 + + # A fourth failure must not say "failure 4 of 3": the verdict is in. + with caplog.at_level(logging.WARNING): + clock.seconds += 200.0 + await live.run(iterations=1) + assert caplog.text.count("cannot reach Signal") == 3 + assert "still deaf: 4 failed probe(s)" in caplog.text + + +async def test_one_frame_clears_the_alarm(config: Config, caplog: pytest.LogCaptureFixture) -> None: + clock = Clock() + live = gate(config, lambda r: httpx.Response(400, json=DEAD_BODY), clock) + for _ in range(3): + clock.seconds += 200.0 + await live.run(iterations=1) + assert live.healthy is False + + with caplog.at_level(logging.WARNING): + live.note_envelope() + assert live.healthy is True + assert "recovered after 3 failed probe(s)" in caplog.text + assert live.silent_for == 0.0 + + +async def test_a_frame_while_healthy_says_nothing( + config: Config, caplog: pytest.LogCaptureFixture +) -> None: + """The recovery line must mean something, so it cannot fire per frame.""" + live = gate(config, lambda r: httpx.Response(200, json=[]), Clock()) + with caplog.at_level(logging.WARNING): + live.note_envelope() + assert caplog.text == "" diff --git a/tests/test_trust.py b/tests/test_trust.py new file mode 100644 index 0000000..6c891a4 --- /dev/null +++ b/tests/test_trust.py @@ -0,0 +1,177 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Who may add this bot to a group, and what happens to everybody else.""" + +from __future__ import annotations + +import logging +from dataclasses import replace +from typing import TYPE_CHECKING, Any + +import pytest + +from signal_bot.discovery import GroupDirectory +from signal_bot.groups import ConfigError +from signal_bot.models import parse_envelope +from signal_bot.trust import InviteTrust, parse_trusted_inviters +from tests.conftest import ( + SECOND_GROUP, + STRANGER_NUMBER, + TRUSTED_NUMBER, + WATCHED_GROUP, + WATCHED_INTERNAL, + envelope, + group_entry, + listing_client, + update_envelope, +) + +if TYPE_CHECKING: + from signal_bot.config import Config + from signal_bot.store import Store + +OPERATOR_UUID = "b1c2d3e4-0000-4a00-8a00-000000000001" + + +def row(group_id: str, *, members: list[str], pending: list[str] | None = None) -> dict[str, Any]: + """A /v1/groups row with the membership fields the gate reads.""" + entry = group_entry(group_id) + entry["members"] = members + entry["pending_invites"] = pending if pending is not None else [] + return entry + + +def test_parsing_accepts_numbers_uuids_and_nothing() -> None: + assert parse_trusted_inviters({}) == () + assert parse_trusted_inviters({"TRUSTED_INVITERS": " "}) == () + assert parse_trusted_inviters({"TRUSTED_INVITERS": f" {TRUSTED_NUMBER} , ,"}) == ( + TRUSTED_NUMBER, + ) + assert parse_trusted_inviters({"TRUSTED_INVITERS": f"{TRUSTED_NUMBER},{OPERATOR_UUID}"}) == ( + TRUSTED_NUMBER, + OPERATOR_UUID, + ) + + +def test_parsing_de_duplicates_and_rejects_junk() -> None: + same = f"{TRUSTED_NUMBER},{TRUSTED_NUMBER}" + assert parse_trusted_inviters({"TRUSTED_INVITERS": same}) == (TRUSTED_NUMBER,) + with pytest.raises(ConfigError, match="must be"): + parse_trusted_inviters({"TRUSTED_INVITERS": "kuhy"}) + + +def test_an_empty_allowlist_trusts_everyone_but_says_so( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): + trust = InviteTrust(()) + assert "will be watched" in caplog.text + assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[STRANGER_NUMBER])) is True + + +def test_a_trusted_member_vouches_for_the_group() -> None: + trust = InviteTrust((TRUSTED_NUMBER,)) + assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[TRUSTED_NUMBER])) is True + + +def test_a_trusted_uuid_vouches_too() -> None: + """The listing spells an unknown contact as a uuid, not a number.""" + trust = InviteTrust((OPERATOR_UUID,)) + assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[OPERATOR_UUID])) is True + + +def test_a_trusted_number_that_is_only_invited_does_not_vouch() -> None: + """Bait: a stranger adds the operator, who has not accepted.""" + trust = InviteTrust((TRUSTED_NUMBER,)) + entry = row(WATCHED_GROUP, members=[STRANGER_NUMBER, TRUSTED_NUMBER], pending=[TRUSTED_NUMBER]) + assert trust.allows(WATCHED_GROUP, entry) is False + + +def test_a_refusal_is_logged_once_and_names_the_members( + caplog: pytest.LogCaptureFixture, +) -> None: + trust = InviteTrust((TRUSTED_NUMBER,)) + entry = row(WATCHED_GROUP, members=[STRANGER_NUMBER]) + with caplog.at_level(logging.WARNING): + assert trust.allows(WATCHED_GROUP, entry) is False + assert trust.allows(WATCHED_GROUP, entry) is False + assert caplog.text.count("refusing group") == 1 + assert STRANGER_NUMBER in caplog.text + + +def test_a_malformed_membership_field_refuses_rather_than_crashes() -> None: + trust = InviteTrust((TRUSTED_NUMBER,)) + entry = group_entry(WATCHED_GROUP) + entry["members"] = "not-a-list" + assert trust.allows(WATCHED_GROUP, entry) is False + + +def test_an_update_from_a_trusted_number_vouches_in_either_spelling( + caplog: pytest.LogCaptureFixture, +) -> None: + """The envelope carries the internal id; the listing carries group..""" + trust = InviteTrust((TRUSTED_NUMBER,)) + with caplog.at_level(logging.INFO): + trust.note_update(WATCHED_INTERNAL, TRUSTED_NUMBER, "uuid-operator") + trust.note_update(WATCHED_INTERNAL, TRUSTED_NUMBER, "uuid-operator") + assert caplog.text.count("trusted inviter added the bot") == 1 + assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[STRANGER_NUMBER])) is True + + +def test_an_update_from_a_stranger_vouches_for_nothing() -> None: + trust = InviteTrust((TRUSTED_NUMBER,)) + trust.note_update(WATCHED_INTERNAL, STRANGER_NUMBER, "uuid-stranger") + assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[STRANGER_NUMBER])) is False + + +def test_an_update_by_a_trusted_uuid_vouches() -> None: + trust = InviteTrust((OPERATOR_UUID,)) + trust.note_update(WATCHED_INTERNAL, "", OPERATOR_UUID) + assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[STRANGER_NUMBER])) is True + + +async def test_the_directory_adopts_only_the_trusted_group(config: Config, store: Store) -> None: + client = listing_client( + [ + row(WATCHED_GROUP, members=[TRUSTED_NUMBER]), + row(SECOND_GROUP, members=[STRANGER_NUMBER]), + ] + ) + found = GroupDirectory(config, client, store, InviteTrust((TRUSTED_NUMBER,))) + await found.refresh() + assert found.groups == (WATCHED_GROUP,) + + +async def test_a_pinned_group_bypasses_the_trust_gate(config: Config, store: Store) -> None: + """GROUP_IDS is the operator naming a group by hand: vouch enough.""" + pinned = replace(config, group_ids=(WATCHED_GROUP,)) + client = listing_client([row(WATCHED_GROUP, members=[STRANGER_NUMBER])]) + found = GroupDirectory(pinned, client, store, InviteTrust((TRUSTED_NUMBER,))) + await found.refresh() + assert found.groups == (WATCHED_GROUP,) + + +async def test_an_update_frame_lets_the_next_refresh_adopt_the_group( + config: Config, store: Store +) -> None: + """The whole point: being added by the operator is still the whole setup.""" + client = listing_client([row(WATCHED_GROUP, members=[STRANGER_NUMBER])]) + found = GroupDirectory(config, client, store, InviteTrust((TRUSTED_NUMBER,))) + await found.refresh() + assert found.groups == () + + message = parse_envelope(update_envelope(group_id=WATCHED_INTERNAL)) + assert message is not None + found.note_group_update(message) + await found.refresh() + assert found.groups == (WATCHED_GROUP,) + + +def test_ordinary_frames_vouch_for_nothing(config: Config, store: Store) -> None: + """Only an UPDATE names an inviter; a chat message and a DM must not.""" + trust = InviteTrust((TRUSTED_NUMBER,)) + found = GroupDirectory(config, listing_client([]), store, trust) + for frame in (envelope(text="!cat"), envelope(text="hi", group_id=None)): + message = parse_envelope(frame) + assert message is not None + found.note_group_update(message) + assert trust.allows(WATCHED_GROUP, row(WATCHED_GROUP, members=[STRANGER_NUMBER])) is False