signal-bot/signal_bot/manage/routes_actions.py
Krzysztof kuhy Rudnicki 0b539fc327 feat: the management backend, absorbing the tagger
A second process rather than more routes on the bot's own app. This one can
rewrite .env, and the bot's port already serves /_selftest, which posts for
real into a real group; keeping them apart means the surface that can
reconfigure the bot only listens while somebody has the page open.

The tagger's seven routes move here unchanged, out of tool/ and into the
coverage gate -- which is the reason an unconditional ImportError once sat
in tagger.py through two releases. The media route is still indexed by
integer, and that is not a style choice: a filename from a client is a path,
and the directory holds private photographs. FastAPI refuses a name with 422
before the handler runs, which the tests assert.

guard.py is the part that was not asked for. Binding to loopback stops the
network, not the browser already running on this machine, and two attacks
reach a loopback port from an ordinary web page: a cross-origin form, which
can only send urlencoded, multipart or plain text, so requiring
application/json refuses it with no token to keep; and DNS rebinding, whose
request still carries the attacker's hostname in Host. Forty lines instead
of authentication, which is the right trade for something started by hand
and stopped again a few minutes later.

assets.py branches on whether the page has been built, because StaticFiles
raises at construction on a missing directory -- mounting it unconditionally
would take the app down on a fresh clone, and in CI, where pytest never runs
pnpm build. Both branches are tested; "not built" answers with the command.

The container restart goes through signal_bot.external, wrapped in
asyncio.to_thread: it is synchronous with a 60s default, and blocking the
loop would freeze the page showing the restart.

100% branch coverage across all eleven modules, no omits, no pragmas.

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

87 lines
3.1 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The two things this page does rather than reads.
Both are deliberately thin. ``/api/selftest`` proxies the bot's own endpoint
instead of reimplementing it, so ``SELFTEST_GROUPS`` stays fail-closed in
exactly one place and this page cannot become a second way to post into a
group. The container restart goes through ``signal_bot.external``, the one
module in this repository allowed to start a process -- a direct
``subprocess`` call here would fail both ``ruff S603`` and the test that
asserts by name which files may import it.
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any
import httpx
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from signal_bot import external
if TYPE_CHECKING:
from signal_bot.manage.deps import ManageDeps
# external.run is synchronous and defaults to a 60s timeout. A container
# restart takes seconds, and blocking the event loop for them would freeze
# the page that is showing you the restart.
RESTART_TIMEOUT = 90.0
class SelfTest(BaseModel):
"""One command to type at the bot, and where."""
group: str
text: str
attachments: list[str] = []
def router(deps: ManageDeps, container: str) -> APIRouter:
"""The selftest proxy and the container restart.
Args:
deps: The running app's configuration and shared HTTP client.
container: The docker container signal-cli runs in.
Returns:
A router to include in the management app.
"""
api = APIRouter(prefix="/api")
@api.post("/selftest")
async def selftest(request: SelfTest) -> dict[str, Any]:
"""Ask the bot to type a command at itself, and pass on what it says."""
try:
response = await deps.client.post(
f"{deps.bot_url}/_selftest",
json=request.model_dump(),
timeout=30.0,
)
except httpx.HTTPError as error:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"the bot did not answer: {error}",
) from error
# Verbatim, including 403 and 503: the refusal belongs to the bot,
# and rewording it here would make two different sentences for one
# rule.
body: dict[str, Any] = response.json()
if response.status_code != status.HTTP_200_OK:
raise HTTPException(status_code=response.status_code, detail=body)
return body
@api.post("/restart-container")
async def restart() -> dict[str, Any]:
"""Restart signal-cli's container, and report what actually happened."""
result = await asyncio.to_thread(
external.run, ["docker", "restart", container], timeout=RESTART_TIMEOUT
)
# external.run never raises: a non-zero exit, a missing binary and a
# timeout all arrive as ok=False, which is the one thing this must
# not be able to mistake for success.
return {"ok": result.ok, "stdout": result.stdout, "stderr": result.stderr}
return api