signal-bot/signal_bot/config.py
Krzysztof kuhy Rudnicki d5c63b423b 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbjfWdAyHbCubac7wJKJZW
2026-08-29 19:01:55 +02:00

230 lines
8.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Runtime configuration, read once from the environment and validated eagerly."""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from typing import Final
from urllib.parse import urlsplit, urlunsplit
from dotenv import load_dotenv
# Re-exported: every caller has always imported these from config, and the
# split is a filing change, not an interface change.
from signal_bot.groups import (
ConfigError,
build_alias_map,
canonical_group_id,
group_id_aliases,
parse_group_ids,
)
from signal_bot.trust import parse_trusted_inviters
__all__ = [
"Config",
"ConfigError",
"canonical_group_id",
"group_id_aliases",
"load_config",
"parse_trusted_inviters",
]
DEFAULT_API_BASE_URL: Final = "http://localhost:9922"
DEFAULT_DATABASE_PATH: Final = "signal_bot.sqlite3"
DEFAULT_REPORT_HOUR: Final = 21
DEFAULT_REPORT_MINUTE: Final = 37
DEFAULT_COOLDOWN_SECONDS: Final = 10
DEFAULT_CAT_API_URL: Final = "https://api.thecatapi.com/v1/images/search"
DEFAULT_DOG_API_URL: Final = "https://dog.ceo/api/breeds/image/random"
DEFAULT_BIND_HOST: Final = "127.0.0.1"
# Not 8000: that port is a magnet for unrelated dev servers and was already
# taken here by a long-running enforcer. 8137 sits below the ephemeral range
# (/proc/sys/net/ipv4/ip_local_port_range, 32768-60999 on Arch), so the kernel
# will never hand it out to an outbound connection and steal it back.
DEFAULT_BIND_PORT: Final = 8137
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"}
@dataclass(frozen=True, slots=True)
class Config:
"""Everything the bot needs to know, resolved and validated."""
phone_number: str
# The optional allowlist. Empty means every group the account is a
# full member of, discovered at runtime -- see signal_bot.discovery.
group_ids: tuple[str, ...] = ()
api_base_url: str = DEFAULT_API_BASE_URL
database_path: str = DEFAULT_DATABASE_PATH
report_hour: int = DEFAULT_REPORT_HOUR
report_minute: int = DEFAULT_REPORT_MINUTE
cooldown_seconds: int = DEFAULT_COOLDOWN_SECONDS
# Overridable so the offline environment can serve fixture images
# without the bot knowing it is not talking to the real APIs.
cat_api_url: str = DEFAULT_CAT_API_URL
dog_api_url: str = DEFAULT_DOG_API_URL
# Where the /healthz readiness probe binds. Read here rather than in
# __main__ so that .env governs it: __main__ runs before anything has
# loaded a .env file, so an os.getenv() there would silently ignore it.
bind_host: str = DEFAULT_BIND_HOST
bind_port: int = DEFAULT_BIND_PORT
# Set LOG_LEVEL=DEBUG to see why a frame was ignored. Without a knob,
# the one log line that explains a silently-dropped message is
# 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:
"""True when GROUP_IDS restricts the bot to a fixed set of groups."""
return bool(self.group_ids)
def allows(self, group_id: str) -> bool:
"""True when a discovered group is permitted by the allowlist.
An empty allowlist permits everything: the bot answers wherever it
has been added. This is only the *permission* check -- whether the
account is actually a member is decided by the live listing, in
signal_bot.discovery.
Args:
group_id: A canonical group id from the group listing.
Returns:
True when the group may be watched.
"""
if not self.group_ids:
return True
return group_id in build_alias_map(self.group_ids)
@property
def receive_url(self) -> str:
"""WebSocket URL the bot subscribes to for incoming envelopes."""
parts = urlsplit(self.api_base_url)
scheme = _WS_SCHEMES.get(parts.scheme, parts.scheme)
path = f"/v1/receive/{self.phone_number}"
query = "send_read_receipts=false"
return urlunsplit((scheme, parts.netloc, path, query, ""))
@property
def send_url(self) -> str:
"""REST endpoint for outgoing messages and attachments."""
return f"{self.api_base_url}/v2/send"
@property
def attachments_url(self) -> str:
"""REST endpoint listing server-side stored attachments."""
return f"{self.api_base_url}/v1/attachments"
@property
def about_url(self) -> str:
"""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."""
return f"{self.api_base_url}/v1/groups/{self.phone_number}"
def _require(env: dict[str, str], key: str) -> str:
"""Return a non-empty environment value or raise ConfigError."""
value = env.get(key, "").strip()
if not value:
msg = f"{key} is required and must not be empty."
raise ConfigError(msg)
return value
def _positive_int(env: dict[str, str], key: str, default: int) -> int:
"""Return an int environment value, falling back to default when unset."""
raw = env.get(key, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
msg = f"{key} must be an integer, got {raw!r}"
raise ConfigError(msg) from None
if value < 0:
msg = f"{key} must not be negative, got {value}"
raise ConfigError(msg)
return value
def load_config(env: dict[str, str] | None = None, *, use_dotenv: bool = True) -> Config:
"""Build a Config from the environment, failing loudly on anything missing.
Args:
env: Mapping to read instead of os.environ. Used by the tests.
use_dotenv: Load a local .env file first. Disabled in tests.
Returns:
A validated, immutable Config.
"""
if env is None:
if use_dotenv:
load_dotenv()
env = dict(os.environ)
hour = _positive_int(env, "REPORT_HOUR", DEFAULT_REPORT_HOUR)
minute = _positive_int(env, "REPORT_MINUTE", DEFAULT_REPORT_MINUTE)
if hour > MAX_HOUR or minute > MAX_MINUTE:
msg = f"REPORT_HOUR/REPORT_MINUTE out of range: {hour:02d}:{minute:02d}"
raise ConfigError(msg)
log_level = env.get("LOG_LEVEL", "").strip().upper() or DEFAULT_LOG_LEVEL
if log_level not in logging.getLevelNamesMapping():
msg = f"LOG_LEVEL must be a logging level name, got {log_level!r}"
raise ConfigError(msg)
bind_port = _positive_int(env, "BIND_PORT", DEFAULT_BIND_PORT)
if not 1 <= bind_port <= MAX_PORT:
msg = f"BIND_PORT must be between 1 and {MAX_PORT}, got {bind_port}"
raise ConfigError(msg)
return Config(
phone_number=_require(env, "PHONE_NUMBER"),
group_ids=parse_group_ids(env),
api_base_url=env.get("API_BASE_URL", "").strip() or DEFAULT_API_BASE_URL,
database_path=env.get("DATABASE_PATH", "").strip() or DEFAULT_DATABASE_PATH,
report_hour=hour,
report_minute=minute,
cooldown_seconds=_positive_int(env, "COOLDOWN_SECONDS", DEFAULT_COOLDOWN_SECONDS),
cat_api_url=env.get("CAT_API_URL", "").strip() or DEFAULT_CAT_API_URL,
dog_api_url=env.get("DOG_API_URL", "").strip() or DEFAULT_DOG_API_URL,
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),
)