mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:37:03 +01:00
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
120 lines
4.3 KiB
Python
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
|