Live symptom: the radio bot shows "Listening to <system>" but is not in the
voice channel.
- Presence was set once on the first successful join and never cleared, so a
bot whose voice dropped (or whose rejoin kept failing) still looked healthy.
Presence is now synced at the end of every reconcile: set only while a voice
connection for that bot is up, cleared otherwise.
- Pre-check the bot's permissions (View Channel, Connect, Speak) and the
channel's user limit before joining. Discord silently ignores a join the bot
may not make; discord.py then times out after 30s and the admin page read
"Could not connect to the voice channel (TimeoutError: )". Now: code
missing_permissions / channel_full with the missing permission named.
- A connect timeout gets its own code (connect_timeout) and readable text.
- requirements: discord.py[voice]>=2.7 — Discord has required DAVE E2EE voice
since 2026-03-01 and 2.7 is the first discord.py with it (the extra pulls
davey). Today's resolution was already 2.7.1 + davey 0.1.6; this stops an
older resolution from ever being accepted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two unauthenticated surfaces closed on the edge node.
Dashboard and API: the local dashboard and every /api/* route were open to
anything on the node's LAN. Adds a login page plus session-cookie auth for
the browser, and cookie-or-Basic for the API so scripted callers stay
possible. Passwords are hashed with stdlib scrypt (no new dependency, this
runs on a Pi) and compared in constant time; the salt and session-signing
secret persist in credentials.json. Startup warns while the default password
is still in place. No non-browser callers of the node API exist today
(C2 talks to nodes over MQTT and nodes call C2 outbound), so nothing breaks.
Adds python-multipart, which FastAPI's Form() needs for the login POST and
which was missing from requirements entirely.
MQTT: nodes authenticated with a shared drb-node password, and the broker
ACL keyed off %c — the client-supplied client id — so any holder of that one
password could claim another node's topic namespace. Nodes now connect as
username=<node_id>, password=<their C2-issued api_key>, which mosquitto's
dynamic-security plugin checks, with the ACL keyed off the authenticated %u.
TLS is gated on MQTT_TLS and uses default CA verification.
The old key_request MQTT path stays in place behind TODO(mqtt-cutover)
markers as the fallback until the cutover is proven; a node with no api_key
on disk logs a clear repeated refusal rather than spinning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>