signal-bot/signal_bot/portcheck.py
Krzysztof kuhy Rudnicki b48c945d89 feat: make the bot actually reply on Signal, and gate every way it went silent
First live reply, as its own registered account. `!cat` and `!dog` from the
phone return images -- verified in the production shape (the bot *invited to
an already-existing group*) and under a systemd user unit.

Four failures, each of which left the bot looking perfectly healthy:

- Invited is not joined. Signal only adds an account outright if the adder
  holds its profile key; with no profile the add becomes a pending invite,
  and signal-cli cannot accept one addressed to a phone number ("Cannot find
  service ID for self"). Group listed, websocket up, /healthz OK, zero
  messages. register_bot.sh now sets the profile *before* the add, and
  startup refuses on `member: false`.
- MODE=json-rpc-native is a GraalVM build with no AWT: text sends work,
  every attachment 400s with "Can't load library: awt". Both mode guards now
  demand exactly json-rpc, and start_container replaces a wrong-mode
  container instead of reusing it.
- The API spells one group two ways -- /v1/groups returns `group.<base64>`,
  envelopes carry what that decodes to -- so the scoping gate rejected the
  bot's own group. e2e.sh now models both spellings; reverting the fix fails
  4 scenarios, which is why the offline suite missed it before.
- BIND_PORT in .env was dead: __main__ read os.getenv before anything loaded
  a .env. The bind address comes from Config now.

A taken port used to be uvicorn logging the collision and exiting 0, which
every supervisor reads as success. portcheck.py refuses to start, exits
non-zero, and names the holder by reading /proc directly -- no subprocess, no
PATH assumptions, testable against a fake proc tree. Default port is 8137:
not 8000, and below the ephemeral range so the kernel cannot take it.

The bot now runs as signal-bot.service (Restart=always, linger checked rather
than assumed, StartLimitBurst so a config error fails visibly instead of
looping). Verified by SIGKILL and by re-running a command under the unit.

100% branch coverage, 230 tests, all gates green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 14:37:27 +02:00

120 lines
4.3 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Refuse to start on a taken port, and name whatever is already holding it.
uvicorn logs "address already in use" and then shuts down *cleanly*: the
process exits 0, so a supervisor records a successful run and a human reads a
tidy "Application shutdown complete" as if nothing were wrong. That is a
fail-open, and it is exactly how this bot silently failed to start.
Attribution reads /proc directly rather than forking `ss` or `lsof`: no
subprocess, no PATH assumptions, and the whole thing is testable against a
fake proc tree passed in as `proc_root`.
"""
from __future__ import annotations
import socket
from pathlib import Path
from typing import Final
PROC_ROOT: Final = Path("/proc")
_TCP_TABLES: Final = ("net/tcp", "net/tcp6")
_LISTEN_STATE: Final = "0A"
_INODE_FIELD: Final = 9
class PortInUseError(RuntimeError):
"""Raised when the bot's bind address is already taken."""
def _listening_inodes(port: int, proc_root: Path) -> set[str]:
"""Return the socket inodes of every LISTEN socket on the given port."""
inodes: set[str] = set()
for table in _TCP_TABLES:
try:
# Skip the header row; every remaining line is one socket.
lines = (proc_root / table).read_text().splitlines()[1:]
except OSError:
continue
for line in lines:
fields = line.split()
if len(fields) <= _INODE_FIELD or fields[3] != _LISTEN_STATE:
continue
_, _, hex_port = fields[1].rpartition(":")
try:
listening_on = int(hex_port, 16)
except ValueError:
continue
if listening_on == port:
inodes.add(fields[_INODE_FIELD])
return inodes
def _command_of(pid_dir: Path) -> str:
"""Return the full command line of a process, or a placeholder."""
try:
raw = (pid_dir / "cmdline").read_bytes()
except OSError:
return "(command unavailable)"
argv = raw.decode(errors="replace").rstrip("\0").split("\0")
return " ".join(argv).strip() or "(command unavailable)"
def _owner_of(inodes: set[str], proc_root: Path) -> tuple[int, str] | None:
"""Find the pid holding any of these socket inodes, with its command line."""
targets = {f"socket:[{inode}]" for inode in inodes}
for pid_dir in sorted(proc_root.iterdir()):
if not pid_dir.name.isdigit():
continue
try:
descriptors = sorted((pid_dir / "fd").iterdir())
except OSError:
# Another user's process, or one that exited mid-scan.
continue
for descriptor in descriptors:
try:
link = str(descriptor.readlink())
except OSError:
continue
if link in targets:
return int(pid_dir.name), _command_of(pid_dir)
return None
def describe_listener(port: int, proc_root: Path = PROC_ROOT) -> str:
"""Describe whoever is listening on a port, or "" if nobody identifiable is."""
inodes = _listening_inodes(port, proc_root)
if not inodes:
return ""
owner = _owner_of(inodes, proc_root)
if owner is None:
return "held by a process belonging to another user"
pid, command = owner
return f"held by pid {pid}: {command}"
def assert_port_free(host: str, port: int, proc_root: Path = PROC_ROOT) -> None:
"""Raise PortInUseError unless host:port can actually be bound.
Args:
host: Address the server will bind.
port: Port the server will bind.
proc_root: Root of the proc filesystem. Replaced in tests.
Raises:
PortInUseError: The address is already in use.
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
probe.bind((host, port))
except OSError as exc:
holder = describe_listener(port, proc_root)
detail = f" -- {holder}" if holder else ""
msg = (
f"cannot bind {host}:{port}{detail}. "
f"Set BIND_PORT in .env to a free port, or stop that process. "
f"Refusing to start, because uvicorn would log this and exit 0."
)
raise PortInUseError(msg) from exc