Files
keyrelay/src/control_server.h
T
ducoterra d629a0ff51 phase: 03_session_takeover
**Phase 03 `session_takeover` — final verification pass. All green.**

All four task files were already in `.agents/phases/complete/03_session_takeover/` with per-task reports, so this was the phase-level close, not new implementation.

**Completion criteria**
1. *2nd controller authenticates + takes the bridge; a 3rd is refused* — met host-side: `test_claim` 102 (whitelist matrix, `acceptsUpgrade` one-claim-at-a-time, both deadline clocks across `millis()` rollover, 64-byte cap through the real codec), `test_relayctl` 308 (`--take-over` = `T`,`X`,`H`; default refusal names the flag and sends no `X`), `test_page` 142 (claim→button→one click→`T` then `X` only). Refusal mechanism stated precisely: a second claim gets `503`/`E3`, a third *socket* gets the slot refusal (`slotrefused`).
2. *Handover releases then adopts, lands disarmed* — pinned: `closeLink("taken over")` before adoption (inverting it fails exactly 1 check, reverted); no `arm`/`setArmed`/`armed = true` in the `X` path (inserting one fails exactly 1 check, reverted); un-authenticated `X` admitted in `claim.h` fails `test_claim`+`test_relayctl` (reverted). Also verified by reading: `serveClaim` never writes `_lastInboundMs`/`_controllerSeen`, so a parked claim cannot hold the board armed past 8 s.
3. *Incumbent page says taken over, disarmed, no auto-reclaim* — `onE12()` + `test_page` (disarms, releases mouse, ends typing run, pill `taken over`, reconnect loop off).
4. *Three source pins fail on inversion* — re-demonstrated in this pass, all reverted (`src/claim.h`, `src/control_server.cpp` byte-identical to pre-inversion).
5. *Gate green + README/AGENTS say takeover cannot arm* — yes.

**Validation** — `bash .agents/validate.sh` → **exit 0**: 12 suites **5026 checks, 0 failures** (normal and `SANITIZE=1`), `bridge`/`console`/`rollback` all SUCCESS, page bytes 59 286, README/AGENTS claims OK, hygiene OK. Extra lint: `ruff --select F,E9` clean, `bash -n` clean, `py_compile` clean. Coverage on new/changed logic: `claim.h` 100 % (11 lines), `status.h` 100 % (29), `proto.h` 94.8 % (229), relayctl take-over lines 14/15 (the miss is a `def` continuation trace never records).

**Defects fixed (docs only — no code defect found)**: bug 6 claimed a 2nd `/ws` upgrade "is still refused"; Measured-behaviour samples lacked `taken=`/`busy`/`tkov`; footprint + TLS-cost rows quoted phase-02 numbers while claiming to be what the gate prints (now 99 744/1 257 933, 67 352/1 178 173, 93 568/1 157 609 → TLS = +6 176 B RAM/+100 324 B flash); page-delivery/handshake rows dated; `AGENTS.md` pure-module list was missing `claim.h`.

**Hardware**: no device run. The board still runs pre-phase-03 firmware, reflash/arming mutates a live device (the target is this machine), and both controllers need the operator's passphrase — guessing feeds the shared 5-fail lockout. The 3 blank rows (third-controller refusal, chord labels, nothing-outlives-the-link) are now recorded `skipped (no device run)` with the exact closing commands in `.agents/HW_VERIFICATION.md`.

**Tree left for the harness**: index untouched (0 staged), no history changes; `todo/03_session_takeover/00_phase.md` moved to `complete/` (matches the phase-02 rename pattern); untracked files the commit must include: `.agents/validate.sh`, `src/claim.h`, `tests/test_claim.cpp`, `.agents/phases/complete/03_session_takeover/`, `.agents/reports/03_session_takeover/`. Next phase: `todo/04_controller_page_responsive/`.
2026-11-09 08:57:35 -05:00

335 lines
17 KiB
C++

/**
* keyrelay -- the control server: HTTP for the page, WebSocket for the keystrokes,
* over a plaintext socket or a TLS session -- into one controller slot.
*
* Shape of it:
* - one controller at a time. Two controllers typing into one machine is exactly the
* ambiguity a keyboard must not have, so the second socket never *drives* one. What
* it may now do (phase 03) is stand in the spare slot as a **pending claim**: it
* completes the same handshake, authenticates with the same passphrase against the
* same lockout counters, and may then send nothing but `K` and the deliberate `X`
* -- every arm, key state, chord, text, mouse and release frame is refused by the
* one whitelist in claim.h and the claim is dropped. `X` hands the bridge over
* *through the funnel*: the incumbent is told (`E12`), closeLink() runs (which
* disarms and releases every key and button -- bug 10's fix, un-narrowed), and only
* then is the claimant adopted as the controller, DISARMED. No path reachable from
* `X` can call arm(); the newcomer arms with an ordinary `A1` or not at all
* (handOverClaim). Before phase 03 the answer was `503`/`E3` outright; read-only
* HTTP is *not* and was never refused: what is exclusive is the keyboard, not the
* board. Refusing everything meant pressing reload on your own tab failed, and a
* live controller could not be inspected by anything else (serveViewer).
* - two listeners, ONE state machine, ONE transport view. :80 and (KR_TLS) :443 both
* feed the same accept decision -- tls::admitConnection -- the same serveHttp() /
* serveWebSocket() routing and the same serveReadonly(); the only thing a socket's
* origin changes is which Conn it got bound to, and the only place the difference is
* visible at all is the plaintext policy in serveReadonly() (:80 answers a redirect or
* 426 instead of content, compiled out entirely by -D KR_TLS=0). A socket is HTTP,
* WebSocket or none regardless of port, so a TLS controller and a plaintext controller
* can never both hold the keyboard.
* - non-blocking throughout: tick() runs once per loop() and consumes only what is
* already buffered. The keystroke path must not wait on the network stack -- under
* TLS that includes the handshake (Conn::poll(), one library call per tick) and every
* response write (queued, pumped under kPageBytesPerTick, never waited on); in
* plaintext it includes the write that used to be able to sit in a one-second select
* ten times over (see Conn::writeRaw).
* - nothing is trusted before the passphrase. The socket opens, must present T<token>
* inside kAuthTimeoutMs, and is closed otherwise; repeated failures lock the
* evaluation out for kTokenLockMs. TLS is transport, never an arming input: a
* TLS session that authenticates but does not arm types nothing.
*
* Bytes in are decoded by kr::ws::Reader (host-tested); commands by kr::proto::parse
* (host-tested); the transport and the plaintext-port tables live in conn.h (its pure
* half host-tested). What is left here is routing, framing and lifecycle -- the part that
* genuinely needs a socket to be worth testing.
*/
#pragma once
#include <WiFi.h>
#include "claim.h"
#include "config.h"
#include "conn.h"
#include "proto.h"
#include "status.h"
#include "tls_session.h"
#include "ws_codec.h"
namespace kr {
// StatusSnapshot and statusJson() live in status.h, which includes nothing but stdio and
// config.h. That is not tidiness: it is what lets tests/test_proto.cpp render the real
// frame and measure it against kStatusJsonCap instead of taking that on trust from a
// device. Include status.h rather than re-declaring anything here.
// Error codes, named. The wire form is `E<code>:<text>`; see proto.h for the list.
enum ErrorCode : uint8_t {
kErrNotAuthed = 1,
kErrLockedOut = 2,
kErrBusy = 3,
kErrNotArmed = 4,
kErrNoTarget = 5,
kErrBadMessage = 6,
kErrQueueFull = 7,
kErrTextFull = 8,
kErrUntypeable = 9,
kErrMouseDisabled = 10,
kErrMouseBootProtocol = 11,
// "you were DROPPED", sent on the incumbent's socket just before the handover closes
// it -- distinct from kErrBusy ("you were refused"), because the controller that was
// never let in and the controller that was deliberately displaced deserve different
// sentences on their screens (proto.h carries the list).
kErrTakenOver = 12,
};
class ControlServer {
public:
// ctx-first function pointers rather than std::function: no allocation, and the
// wiring in setup() reads as a list of the callbacks the server may invoke.
using OnMessage = void (*)(void *ctx, const proto::Message &msg);
using OnLinkChange = void (*)(void *ctx, bool linked);
using FillStatus = void (*)(void *ctx, StatusSnapshot &out);
using GetToken = const char *(*)(void *ctx);
void begin(OnMessage onMessage, OnLinkChange onLinkChange, FillStatus fillStatus,
GetToken getToken, void *ctx);
void tick(uint32_t nowMs);
// Push a status frame now. Called on the 1 Hz heartbeat and immediately after an
// arm/disarm, so the page's one source of truth is the board, not its own guess.
void sendStatus();
void sendError(uint8_t code, const char *text);
// The bare `OK` the protocol promises as "accepted": what `X` from the incumbent
// controller gets (it already holds the bridge; there is nothing to do but say so).
void sendOk();
bool linked() const { return _phase == Phase::WebSocket && _authed; }
/**
* True from the first successful authentication until reboot. The silence timer needs
* this as well as lastInboundMs(): an armed board with no controller at all (armed by
* its own button, or by the serial console) has nothing to hear from, and must not be
* disarmed for it -- but a board whose controller came and went must never stay armed
* on the strength of a link that no longer exists.
*/
bool controllerSeen() const { return _controllerSeen; }
uint32_t lastInboundMs() const { return _lastInboundMs; }
uint32_t rejected() const { return _rejected; }
uint32_t authFails() const { return _authFails; }
// Successful bridge handovers (phase 03): a count of events, never addresses, user
// agents or anything else that would identify the other session (invariant 4 -- the
// README's never-stored list is about exactly this temptation).
uint32_t takeovers() const { return _takeovers; }
// Frames the transport could not take (its queue was full) -- a periodic status frame
// loses its slot, the next tick's drain resumes, and nothing is ever half-sent. Either
// transport: the queue is the transport view's, so the count means one thing everywhere.
uint32_t txDropped() const { return _txDropped; }
// Connections refused because the slot table was full -- either listener, either
// transport. A number, about a refusal; the refusal itself is visible to the peer
// (TLS alert then close on :443, immediate close on :80).
uint32_t slotRefused() const { return _slotRefused; }
#if KR_TLS
// The identity the TLS listener serves. Must be set before begin(), and to nullptr
// (or left unset) when there is no usable identity: begin() then does not open :443
// at all, rather than open a listener that fails every handshake. The PemPair lives
// in main.cpp next to the NVS code that loaded it; the server only reads it, at
// session-open time -- copying 2 kB into every server object would be silly.
void setIdentity(const tls::PemPair *identity) { _identity = identity; }
// Live TLS sessions, both slots -- the heap-cost readout main.cpp logs whenever this
// changes. A number, about numbers; nothing about content (invariant 4). Walked over
// the STORAGE, not the slot pointers: a bridge handover swaps which object serves
// which slot (handOverClaim), and the count means the same thing either way.
uint32_t tlsLiveSessions() const {
return (_tlsStore[0].alive() ? 1u : 0u) + (_tlsStore[1].alive() ? 1u : 0u);
}
#endif
private:
// No separate "handshake in progress" phase: the handshake is what Conn::ready() and
// Conn::poll() are for, and a slot in Phase::Http that is not ready yet is a slot still
// handshaking. A phase for it would be a second place to remember the same fact, which
// is how the plaintext and TLS paths used to drift.
enum class Phase : uint8_t { None, Http, WebSocket };
// What the slot does when its outbound queue finally drains: the response is finished,
// so the link goes. Nothing else closes a link on a *write* boundary -- and a response
// that is still draining must never be cut short, or the page arrives truncated and
// parses as garbage.
enum class AfterDrain : uint8_t { Nothing, CloseLink, StopViewer };
// A response built but not yet written: the status line and headers, plus a body that
// lives somewhere else -- a string literal in flash, the rendered status JSON, or the
// 48 KB page in PROGMEM. Every pointer here is to storage that outlives the pumping of
// the response (literals and kIndexHtml live forever; the JSON lives in this struct,
// which the caller keeps until the response is queued), which is what makes "build once,
// queue on either transport" safe.
struct BuiltResponse {
enum class Body : uint8_t { None, Text, Json };
char head[kHttpResponseHeadCap];
size_t headLen = 0;
Body body = Body::None;
const char *text = nullptr; // Body::Text: a literal, or kIndexHtml in PROGMEM
size_t textLen = 0;
bool textInFlash = false; // true for the page: streamed through a bounded chunk
char json[kStatusJsonCap]; // Body::Json: rendered BEFORE buildResponse is called
size_t jsonLen = 0;
};
// One response formatter for every route, every transport and both ports; the header
// block itself is rendered by the pure kr::renderHttpResponseHead() in conn.h, which is
// what makes its sizing host-testable.
static void buildResponse(BuiltResponse &rsp, const char *status,
const char *contentType, BuiltResponse::Body body,
const char *text, size_t textLen,
const char *extra = nullptr, bool textInFlash = false);
void acceptNew(uint32_t nowMs);
void serveHttp(uint32_t nowMs);
// The read-only routes, built rather than written: the routing answer must have exactly
// one source (invariant 8) while the transport answer differs only in which Conn the
// built response is queued on. `plaintext` is what selects the :80 policy (redirect /
// 426) in a TLS build and is always true -- and inert -- in the KR_TLS=0 rollback.
void serveReadonly(const ws::Request &req, BuiltResponse &rsp, bool plaintext);
void serveViewer(uint32_t nowMs);
// The pending claim's half of the spare slot: the WebSocket that lives there once
// an upgrade turned Readonly into Claim (claim.h for the rules).
void serveClaim(uint32_t nowMs);
bool handleClaimFrame(uint32_t nowMs);
// The bridge handover (`X`): tell the incumbent, run the release funnel, adopt the
// claimant -- in that order and no other. Named handOver, never takeover, so a
// reader cannot mistake it for an arming path: it is the opposite, the board lands
// disarmed and nothing here may call arm().
void handOverClaim(uint32_t nowMs);
void dropClaim();
void sendClaimText(const char *text, size_t len);
void sendClaimError(uint8_t code, const char *text);
void stopViewer();
void serveWebSocket(uint32_t nowMs);
void closeLink(const char *why);
bool handleWsFrame(uint32_t nowMs);
void sendPing();
void writeTextFrame(const char *text, size_t len);
size_t readSome(uint8_t *dst, size_t cap);
// Queue a built response on a slot's transport. One response writer for both slots and
// both transports -- the grep for a second HTTP response writer in src/ is part of this
// task's completion criteria -- so `done` says what that slot does when the bytes have
// all left, and the two callers differ only in that answer.
bool queueResponse(Conn &conn, AfterDrain &after, AfterDrain done,
const BuiltResponse &rsp);
void respondAndClose(const BuiltResponse &rsp); // controller slot
void respondViewer(const BuiltResponse &rsp); // read-only slot
// Drain both slots' queues under the per-tick budget, and run the AfterDrain decisions.
void pumpSlots(uint32_t nowMs);
bool spareBusy() const { return _viewOpen; } // the read-only slot, either transport
#if KR_TLS
void startTlsListener();
void admitTls(uint32_t nowMs); // accept at most one TLS socket per tick
#endif
WiFiServer _server;
// The controller slot: a Conn over one of these two, plus the slot's request buffer.
// The WiFiClient and tls::Session objects are the owners; Conn is a view of one of them.
WiFiClient _client;
Conn _ctl;
uint8_t _ctlTx[kConnTxCap];
AfterDrain _ctlAfter = AfterDrain::Nothing;
Phase _phase = Phase::None;
char _http[kHttpHeaderCap];
size_t _httpLen = 0;
// A second socket, read-only, for when the controller slot is taken. Same shape as the
// controller slot -- a Conn over whichever transport answered -- because the spare slot
// is one slot and a second set of route handling would be a second answer to give.
WiFiClient _viewer;
Conn _view;
uint8_t _viewTx[kConnViewTxCap];
AfterDrain _viewAfter = AfterDrain::Nothing;
bool _viewOpen = false;
char _viewerHttp[kHttpHeaderCap];
size_t _viewerLen = 0;
uint32_t _viewerAtMs = 0;
// ---- the pending claim (phase 03) ------------------------------------------
// What the spare slot is for, and how far its request has got. The role type and
// the frame whitelist live in claim.h (pure, host-tested) because the whitelist is
// the whole safety argument of this slot and an argument nothing executes is one a
// later edit can undo silently; `ViewerRole` is spelled here so the slot reads the
// way the task that added it named it.
//
// What each field costs, in the static RAM this board has no PSRAM of:
// _viewerRole, _viewerPhase 1 byte each (uint8_t enums; they pack together)
// _claimAuthed 1 byte -- the claim's OWN auth flag. The
// controller's `_authed` stays a separate
// fact on purpose: linked() is the
// controller link, and a claim must never
// make one look live
// _claimAtMs 4 bytes -- the claim's own deadline anchor; the
// controller-silence clock (_lastInboundMs)
// is not it and is never moved by a claim
// _claimMsg + _claimReader 64 bytes + ws::Reader's ~40 B of decode state (the
// payload IS _claimMsg; the reader owns no
// buffer of its own)
// Total ~110 B. The claim buffer is a QUARTER of the controller's kWsMessageCap --
// 64 against 256 -- because the widest frame a claim may ever send is `T<token>` at
// kTokenMax + 1 = 33 bytes (config.h re-derives this); anything larger is refused by
// the codec, not buffered, so this slot cannot be made to grow.
using ViewerRole = claim::ViewerRole;
ViewerRole _viewerRole = ViewerRole::Readonly;
Phase _viewerPhase = Phase::None;
bool _claimAuthed = false;
uint32_t _claimAtMs = 0;
uint8_t _claimMsg[kClaimMsgCap];
ws::Reader _claimReader{_claimMsg, sizeof(_claimMsg)};
// Task 02 adopts the claim's Conn on the bridge handover; until then the claim
// travels on `_view`, the slot it authenticated on, and nothing else.
uint8_t _wsBuf[kWsMessageCap];
ws::Reader _reader{_wsBuf, sizeof(_wsBuf)};
bool _authed = false;
bool _controllerSeen = false;
uint32_t _connectedAtMs = 0;
uint32_t _lastInboundMs = 0;
uint32_t _lastStatusMs = 0;
uint32_t _lastPingMs = 0;
uint32_t _lockUntilMs = 0;
uint32_t _authFails = 0;
uint32_t _rejected = 0;
uint32_t _txDropped = 0;
uint32_t _slotRefused = 0;
uint32_t _takeovers = 0; // completed handovers: counters only, never identities
#if KR_TLS
// The encrypted listener: a raw LWIP socket rather than WiFiServer, because the
// session must own the accepted fd -- NetworkClient closes its fd through a shared
// handle the moment the last copy dies, which is the double-close tls::Session is
// written to make impossible. bind/listen happen in startTlsListener().
int _tlsListen = -1;
const tls::PemPair *_identity = nullptr;
// Two session objects (kTlsMaxSessions of RAM is what sized them), and two pointers
// that say which one the controller slot uses. Normally _tlsCtl is store[0] and the
// spare's is store[1]; a bridge handover SWAPS the pointers so the claimant's live
// session is named by the controller slot without one crypto context having to move
// inside another (mbedtls contexts own heap buffers and are not moveable state --
// a pointer swap is a fact about slot naming, which is all the handover needs).
tls::Session _tlsStore[kTlsMaxSessions];
static_assert(kTlsMaxSessions == 2, "the slot pointers below name store[0] and store[1]");
tls::Session *_tlsCtl = &_tlsStore[0]; // the controller slot, when _ctl.isTls()
tls::Session *_tlsView = &_tlsStore[1]; // the spare slot, when _view.isTls()
#endif
OnMessage _onMessage = nullptr;
OnLinkChange _onLinkChange = nullptr;
FillStatus _fillStatus = nullptr;
GetToken _getToken = nullptr;
void *_ctx = nullptr;
};
} // namespace kr