mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 14:37:32 +01:00
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbjfWdAyHbCubac7wJKJZW
150 lines
5.7 KiB
Python
150 lines
5.7 KiB
Python
# 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
|