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/`.
This commit is contained in:
@@ -49,10 +49,10 @@ may have a board attached that nobody is watching, and a second machine for
|
||||
| claim | command | expected observation | outcome |
|
||||
|---|---|---|---|
|
||||
| keystrokes reach the target | `python3 tools/watch_target.py --seconds 60` **on the target**, driven from the page or `tools/relayctl.py` | one press **and** release per key with timestamps; `still held` absent from the summary | skipped (no passphrase) — task 04, 2026-11-09: the board is reachable on `:443` and the TLS/`wss://` transport rows below are measured, but this board's control passphrase is not the default (`status` says `dflt:0`) and a verification run will not change a credential on a device it cannot put back. To close it: `console.py token <known>` on a board you are willing to re-provision, then drive `wss://` |
|
||||
| a chord is the keys its label claims | `python3 tools/relayctl.py --host keyrelay.local chord 04 2b` while `watch_target.py` runs | `LAlt press` then `Tab press` — and **not** `LCtrl`. The watcher is the only thing that says what the target received (README: Alt+Tab was `Ctrl+Alt+A` on the wire once) | |
|
||||
| a chord is the keys its label claims | `python3 tools/relayctl.py --host keyrelay.local chord 04 2b` while `watch_target.py` runs | `LAlt press` then `Tab press` — and **not** `LCtrl`. The watcher is the only thing that says what the target received (README: Alt+Tab was `Ctrl+Alt+A` on the wire once) | skipped (no device run) — phase-03 close, 2026-11-09. This row is still open for the same standing reason as every row in the phase-03 table below: the board is a live device this pass neither reflashed nor armed (arming means typing into whatever this operator's console has focus, and the passphrase is the operator's). Host-side the claim is pinned instead: `test_proto` asserts every labelled chord button's `data-chord` bytes against the combination its label promises, so `04:2b` is Alt+Tab or the suite fails. To close: reflash `bridge`, arm, run the command above with the watcher open |
|
||||
| served page == built page | `curl -s http://keyrelay.local/ \| wc -c` (after phase 02 lands: `curl -sk https://keyrelay.local/ \| wc -c`) | byte-equal to the `page bytes:` line the gate prints. A mismatch means the page in flash is stale — reflash **and** reload any open tab | ok — re-measured 2026-11-09 **over TLS after the phase-02 flash**: `curl -sk https://10.2.0.22/` = 48598 and `curl -s http://keyrelay.local/` = 48598, both = the gate's count. **Task 04** re-ran it over TLS three times in a row: 48 598 bytes each time with `sha256` equal to the raw string in `src/web_page.h` (`f7aae9e5…c6f7`) — the page now leaves through `kr::Conn` at 1460 B per write and 5840 B per tick (34 chunks over ~9 `loop()` iterations) instead of one `WiFiClient::print`, so byte-equality across a multi-tick send is the transport claim. Original — measured 2026-11-09: `curl -s http://keyrelay.local/ \| wc -c` = 48598 = gate's count (board on the LAN, pre-TLS, serving over http as expected). Re-run by the phase 01 final pass, one capped attempt each: 200 / 48 598 bytes, 404 on `/nope`, 401 on `/status` without the token, zero `src=`/`href=` in the served bytes. The first cold probe returned nothing (`http_code=000`, glibc does not cache mDNS) and the retry worked — that retry is the documented behaviour, not a second attempt at the check |
|
||||
| silence disarms | arm, kill the controller process without a clean close (or close the tab), wait `kNoClientTimeoutMs` (8 s), `python3 tools/relayctl.py --host keyrelay.local status` | `armed: 0`, and `watch_target.py` shows releases for anything held. Test the *timer*, not the socket close (README bug 10) | skipped (no passphrase), same reason as the row above. What task 04 *did* pin is the property this row depends on and that the transport rewrite could silently have moved: `_lastInboundMs` is stamped in exactly one place, gated on `_phase == WebSocket && _authed`, and neither the TLS handshake, a `/status` poll nor a viewer refresh reaches it — asserted structurally in `test_relayctl` (`the controller clock is stamped in exactly one place`, `no HTTP path can refresh the controller clock`) |
|
||||
| nothing outlives the link | hold a key and a mouse button, then drop the link | every key up, every button up; mouse summary shows no `still held` | |
|
||||
| nothing outlives the link | hold a key and a mouse button, then drop the link | every key up, every button up; mouse summary shows no `still held` | skipped (no device run) — phase-03 close, 2026-11-09, same standing as the row above. Phase 03 is the phase that makes this row sharper (a *handover* is a link teardown with a new occupant waiting, and the claim path must not keep anything alive), so the host-side pins are the ones named in the phase-03 table: `test_output`'s release-funnel suite and `test_relayctl`'s funnel-runs-before-adoption pin. To close: the run described in the phase-03 table |
|
||||
|
||||
### Phase 02 — HTTPS + `wss://`
|
||||
|
||||
@@ -84,9 +84,11 @@ identity. The instrumentation stays worth having — it is what makes the next c
|
||||
|
||||
| claim | command | expected observation | outcome |
|
||||
|---|---|---|---|
|
||||
| takeover releases the incumbent's held keys | two controllers on one board: incumbent holds a key, newcomer takes over (`relayctl.py --take-over`), all while `watch_target.py` runs **on the target** | `watch_target.py` shows the key **up** *before* the new session's first key-down — the ordering is the whole claim | |
|
||||
| the board comes back disarmed | `python3 tools/relayctl.py --host keyrelay.local status` immediately after the handover | `armed: 0` across the handover; the newcomer must arm again on purpose; nothing in the handover path arms | |
|
||||
| a third controller is still refused | a third `/ws` upgrade while two sessions exist | `E3` — takeover consumes the one spare slot, it does not open the door | |
|
||||
| a second `/ws` upgrade arrives as a pending claim, not a 503; a third socket is still refused (task 01) | after reflashing `bridge` from this tree: controller A connects and authenticates; a second socket does a raw `/ws` upgrade (`openssl s_client -connect keyrelay.local:443` + upgrade headers, or a python one-liner) and sends **one** deliberately-wrong `T`; a third connection opens in parallel; `watch_target.py` runs on the target throughout | second upgrade answers **101** (not 503), then exactly `E1:wrong passphrase` and close (one attempt only — the claim shares the board-wide 5-fail lockout); the third connection is refused (fatal TLS alert / close) and `slotrefused`/`rejected` move while both listeners keep serving; A keeps typing throughout and the silence clock never moves | **skipped (not run on device), 2026-11-09** — the live board runs the phase-02 firmware; the claim path is not flashed, and the authenticating half needs the operator's passphrase, which verification runs must not guess (the same call phase 02 recorded, and more pointed here: a wrong claim token feeds the shared lockout this task wires). Host-side the gate is green: 95 new `test_claim` checks (whitelist matrix over every `proto::Cmd` × auth × role, both deadline clocks at the boundary and across the `millis()` rollover, oversized-frame refusal through the real codec), 4921 checks in both suite runs, all three firmware builds; bridge RAM 99 728 B vs the phase-02 99 616 B — **+112 B**, the claim's whole footprint (64 B buffer + reader state + role/auth bytes + the 4 B clock, matching the comment in `control_server.h`) |
|
||||
| takeover releases the incumbent's held keys | after reflashing `bridge` from this tree, two controllers on one board: incumbent holds a key, newcomer takes over (`relayctl.py --take-over` — landed in task 03; or a raw `/ws` upgrade sending `T<passphrase>` then `X`), all while `watch_target.py` runs **on the target** | `watch_target.py` shows the key **up** *before* the new session's first key-down — the ordering is the whole claim | **skipped (no device run), 2026-11-09** — same standing as the task-01 row directly above: the live board still runs the phase-02 firmware (no claim path), and both controllers need the operator's passphrase, which a verification run must not guess into the shared 5-fail lockout. The ordering is what the run will observe; on the host it is *pinned*: `test_relayctl.py` fails if `closeLink("taken over")` ever moves after `_authed = true` in `handOverClaim` (demonstrated failing on an inverted tree, then reverted), and the funnel it runs is `test_output`'s release-funnel suite. To close: reflash `bridge`, run the two-controller procedure above, capture the transcript |
|
||||
| the board comes back disarmed | `python3 tools/relayctl.py --host keyrelay.local status` immediately after the handover | `armed: 0` across the handover; the newcomer must arm again on purpose; nothing in the handover path arms | **skipped (no device run), 2026-11-09** — same reason as the row above. Host-side the sentence is the (b) pin of `test_relayctl.py`'s **bridge handover** section: no `setArmed` / `armed = true` / `arm(` exists in `handOverClaim`'s code lines nor in the controller-side `X` case (demonstrated failing when a `…_rearm();` line was inserted, then reverted); `forgetPendingWrite()` is the one TLS state the adoption touches, and it writes no arming state. To close: the same reflash and run, `armed: 0` in the status read between handover and the newcomer's first `A1` |
|
||||
| a third controller is still refused | a third `/ws` upgrade while two sessions exist | `E3` — takeover consumes the one spare slot, it does not open the door | **skipped (no device run), 2026-11-09 (phase close)** — the live board still runs the pre-phase-03 firmware, so the second session would be the only claim-shaped thing on it, and opening the two sessions first needs the operator's passphrase. Host-side: `test_claim` pins `claim::acceptsUpgrade()` (a slot that already carries a Claim takes no second upgrade) and `test_tls_session` walks the admission table (`controller`, `spare`, `refused`) with the cap checked before the free slot. Note the mechanism, as the README's Security model states it: a third *socket* never reaches HTTP, so what it gets is the slot refusal — a fatal TLS alert record on `:443`, a close on `:80`, counted in `slotrefused` — and `503`/`E3` is the sentence for an upgrade that reaches the spare slot and cannot be a claim (a malformed upgrade, or that slot already holding one). To close: reflash `bridge`, hold controller + claim, open the third connection, read `slotrefused` moving while both listeners keep serving |
|
||||
| the locked-out operator sees `held by another controller` and takes the bridge with one click; the incumbent's page says it was taken over, disarmed, in its log (task 03) | reflash `bridge` from this tree (check the build's own exit status first); laptop tab holds the bridge; on the phone open `https://keyrelay.local/`, type the passphrase, connect — the pill must read `held by another controller`, the **take over** button appears, and nothing further leaves the phone until it is clicked; press it while `watch_target.py` runs **on the target**; then read the laptop tab | pill `held by another controller`; one click sends `T` then `X` and nothing else; the target shows every key and button up before the phone's next key; the phone is DISARMED after the handover; the laptop tab shows pill `taken over` and a log line naming "another controller took over", and does not reconnect (no second `/ws` from it); `--take-over` from the terminal produces the same frames and states | **skipped (no device run), 2026-11-09** — same standing as the rows above: the live board still runs pre-phase-03 firmware, the gesture needs the operator's passphrase on two devices (guessing feeds the shared 5-fail lockout), and a phone-and-laptop dance is not a headless attempt. Host-side the gate is green: `test_page.js` executes exactly this flow (claim-close → button appears → nothing sent until the click → click sends `T` then `X` and nothing else → first status logged as the DISARMED start; `E12` disarms, releases the mouse, ends a typing run, marks `taken over`, and stays down through the close; `E3` keeps its own "no room" sentence), and `test_relayctl.py` pins the CLI's `T,X,H` ordering, the default refusal naming `--take-over`, and the free-board no-op. To close: the reflash and two-device run above, transcript captured |
|
||||
|
||||
### Phase 04 — responsive page
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
**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/`.
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,213 @@
|
||||
== host suites: bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2379 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 862 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 280 checks, 0 failures
|
||||
test_claim 102 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the bridge handover (phase 03)
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the busy board and --take-over
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 308 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
the busy board and the take-over
|
||||
element ids
|
||||
page 142 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
sanitizers: on
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2379 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 862 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 280 checks, 0 failures
|
||||
test_claim 102 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the bridge handover (phase 03)
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the busy board and --take-over
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 308 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
the busy board and the take-over
|
||||
element ids
|
||||
page 142 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== firmware: platformio run -e bridge -e console -e rollback ==
|
||||
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/bridge/partitions.bin
|
||||
Retrieving maximum program size .pio/build/bridge/firmware.elf
|
||||
Checking size .pio/build/bridge/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
|
||||
Flash: [==== ] 40.0% (used 1257933 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.84 seconds =========================
|
||||
|
||||
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/console/partitions.bin
|
||||
Retrieving maximum program size .pio/build/console/firmware.elf
|
||||
Checking size .pio/build/console/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
|
||||
Flash: [==== ] 37.5% (used 1178173 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.85 seconds =========================
|
||||
|
||||
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/rollback/partitions.bin
|
||||
Retrieving maximum program size .pio/build/rollback/firmware.elf
|
||||
Checking size .pio/build/rollback/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
|
||||
Flash: [==== ] 36.8% (used 1157609 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.84 seconds =========================
|
||||
|
||||
Environment Status Duration
|
||||
------------- -------- ------------
|
||||
bridge SUCCESS 00:00:00.838
|
||||
console SUCCESS 00:00:00.848
|
||||
rollback SUCCESS 00:00:00.837
|
||||
========================= 3 succeeded in 00:00:02.523 =========================
|
||||
|
||||
== page size: src/web_page.h raw strings ==
|
||||
page bytes: 59286 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
|
||||
|
||||
== README/AGENTS claims vs what this run measured ==
|
||||
README/AGENTS claims: OK (5 026 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 286 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
|
||||
|
||||
== repo hygiene ==
|
||||
repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
|
||||
|
||||
== hardware context (informational; cannot change the gate result) ==
|
||||
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
|
||||
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
|
||||
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
|
||||
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
|
||||
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
|
||||
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
|
||||
hw-probe node /usr/bin/node — test_page.js runs
|
||||
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
|
||||
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
|
||||
+ the numbers README/AGENTS quote + hygiene)
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
All green. Report:
|
||||
|
||||
**Implemented (task `03_session_takeover/01_pending_claim_socket.md`)**
|
||||
- `src/claim.h` (new pure module): `ViewerRole`, `acceptsUpgrade()` (one claim at a time), the **one visible whitelist** `honours()` — allowed: `T<token>` (unauth+auth) and `K` (auth); refused by name: `A1`/`A0`, `S=`, `C`, `Y=`, `P=`, `M=`, `R`, `H`, `Bad`/`None`; `X` reserved for task 02 on one marked line; two-clock `expired()` deadline arithmetic (wrap-safe).
|
||||
- `control_server.{h,cpp}`: spare slot gained `_viewerRole`/`_viewerPhase`/`_claimAuthed`/`_claimAtMs`/`_claimMsg[64]`+`_claimReader` (~110 B, commented); `/ws` upgrade on the spare is accepted into Claim (same `ws::acceptFor` + one shared 101 writer — the "exactly one status line" pin holds); claim auth = same constant-time compare, same shared `_authFails`/`_lockUntilMs`, E1/E2 + drop on failure; whitelist violation = E6 + drop; claim never touches `_lastInboundMs`/`_controllerSeen`/`_onMessage`; `_claimAtMs` silence/auth timeouts drop and free the session; stopViewer/both accept paths reset claim state; third socket still refused at the slot table.
|
||||
- `config.h`: `kClaimMsgCap = 64`, `kClaimTimeoutMs = 15000` with reasoning.
|
||||
- `tests/test_claim.cpp` (95 checks) registered in `run_tests.sh`.
|
||||
|
||||
**Verification** — `bash .agents/validate.sh` → **validation OK**: 4921 checks plain and sanitised (12 Tests-table rows match; `test_proto` 2359 and `test_relayctl` 271 **untouched and green** — no wire change, `X` still parses `Bad`); bridge/console/rollback all build; page 51 626 B unchanged; hygiene OK. Bridge RAM 99 728 B vs 99 616 B baseline (**+112 B**, matching the field comment); rollback +112 B likewise.
|
||||
|
||||
**Docs** — README: check counts (intro + quick start), new `test_claim` Tests-table row, `claim.h` in the Architecture map (nine→ten pure), rewrote the stale "second upgrade refused 503/E3" Security-model paragraph and the Wire-protocol refusals paragraph. AGENTS.md: invariant 8 restated (claim cannot drive the keyboard; dispatcher never sees a claim frame) + count. `.agents/HW_VERIFICATION.md`: task-01 device row recorded as **skipped (not run on device)** — live board still runs phase-02 firmware and the authenticating half needs the operator's passphrase (guessing feeds the shared 5-fail lockout this task wires); exact close-out commands written into the row.
|
||||
|
||||
**Deviations** — task text said the claim buffer is "a sixteenth" of the controller's; 64 vs `kWsMessageCap` 256 is a **quarter** — code, tests and README carry the measured ratio and say so. `ViewerRole` lives in `claim.h` with a `using` alias in `ControlServer` so the host suite walks the same type. No live changes made.
|
||||
|
||||
**Next pending task:** `03_session_takeover/02_takeover_funnel.md` (`X` claims the bridge; `E12`; funnel before adoption).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
== host suites: bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 862 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 268 checks, 0 failures
|
||||
test_claim 95 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 271 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
element ids
|
||||
page 113 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
sanitizers: on
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2359 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 862 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 268 checks, 0 failures
|
||||
test_claim 95 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 271 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
element ids
|
||||
page 113 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== firmware: platformio run -e bridge -e console -e rollback ==
|
||||
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/bridge/partitions.bin
|
||||
Retrieving maximum program size .pio/build/bridge/firmware.elf
|
||||
Checking size .pio/build/bridge/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 30.4% (used 99728 bytes from 327680 bytes)
|
||||
Flash: [==== ] 39.7% (used 1249045 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.87 seconds =========================
|
||||
|
||||
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/console/partitions.bin
|
||||
Retrieving maximum program size .pio/build/console/firmware.elf
|
||||
Checking size .pio/build/console/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [== ] 20.5% (used 67336 bytes from 327680 bytes)
|
||||
Flash: [==== ] 37.2% (used 1169305 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/rollback/partitions.bin
|
||||
Retrieving maximum program size .pio/build/rollback/firmware.elf
|
||||
Checking size .pio/build/rollback/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
|
||||
Flash: [==== ] 36.5% (used 1148945 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Environment Status Duration
|
||||
------------- -------- ------------
|
||||
bridge SUCCESS 00:00:00.870
|
||||
console SUCCESS 00:00:00.864
|
||||
rollback SUCCESS 00:00:00.863
|
||||
========================= 3 succeeded in 00:00:02.597 =========================
|
||||
|
||||
== page size: src/web_page.h raw strings ==
|
||||
page bytes: 51626 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
|
||||
|
||||
== README/AGENTS claims vs what this run measured ==
|
||||
README/AGENTS claims: OK (4 921 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 51 626 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
|
||||
|
||||
== repo hygiene ==
|
||||
repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
|
||||
|
||||
== hardware context (informational; cannot change the gate result) ==
|
||||
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
|
||||
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
|
||||
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
|
||||
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
|
||||
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
|
||||
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
|
||||
hw-probe node /usr/bin/node — test_page.js runs
|
||||
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
|
||||
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
|
||||
+ the numbers README/AGENTS quote + hygiene)
|
||||
@@ -0,0 +1,15 @@
|
||||
Everything is 100 % green. Final report:
|
||||
|
||||
**Implemented (`03_session_takeover/02_takeover_funnel.md`)**
|
||||
- `proto.h`: `Cmd::Takeover` for a lone `X` (trailing bytes → `Bad`), header direction table + `E12` error-list entry; `formatError` untouched.
|
||||
- `control_server.{h,cpp}`: `handOverClaim()` — exact order `E12`→incumbent (best-effort) → `closeLink("taken over")` (the bug-10 funnel: disarm + release) → transport transplant → adopt (`_authed/_controllerSeen/_lastInboundMs/_connectedAtMs`, reader reset, `OK`+`sendStatus()`, `_onLinkChange(true)`) → claim slot fully vacated (state + buffers zeroed). `kErrTakenOver=12`, `_takeovers` counter + `takeovers()`, `sendOk()`; `arm()` never called. TLS slot transplant done as a *pointer swap* over two `Session` storage objects (no crypto state moves) + new `Session::forgetPendingWrite()` (documented: mbed TLS resumes half-records from its own buffer; without it the adopted TX path would wedge).
|
||||
- `claim.h`: `X` joins the whitelist on its marked line, authenticated branch only; `main.cpp`: controller-side `X` = no-op answered `OK` (not `E6`); serial `link … taken=N`.
|
||||
- Defect fixed (pre-existing, serves `E12`): the page read error codes as one character — `E10`/`E11`/`E12` misread as `E1`; now parsed to the first `:` (page 51 626 → 52 041 B, all pins moved with it).
|
||||
|
||||
**Verification** — `bash .agents/validate.sh` → **validation OK**: 4 954 checks plain + sanitised, 0 failures (`test_proto` 2 369, `test_claim` 102, `test_relayctl` 287 incl. the three handover pins — each demonstrated FAILING on an inverted tree, then reverted); `bridge/console/rollback` all build (bridge RAM 99 744 B = +16 B over task 01); page 52 041 B; hygiene OK. Device: **skipped (no device run)** recorded in `.agents/HW_VERIFICATION.md` — live board still runs phase-02 firmware and the procedure needs the operator's passphrase (guessing feeds the shared lockout); no live changes made; close-out commands written into the rows.
|
||||
|
||||
**Docs** — README: wire table `X` row, `E12` error code, "Single controller" paragraph now states takeover runs through the funnel and **cannot arm**, refusals/troubleshooting "no takeover" sentences corrected, Tests-table rows + totals, page-size quotes. AGENTS.md: check count + invariant 8's now-false "does nothing else" sentence only.
|
||||
|
||||
**Deviations** — the status-frame `takeovers`/JSON field is left to task 03 (whose file explicitly assigns "StatusSnapshot gains … `uint32_t takeovers`"); here the counter is published via the getter and the serial `status` line. Frames pipelined behind `X` become ordinary controller frames after adoption (documented — the handover itself still never arms).
|
||||
|
||||
**Next pending task:** `03_session_takeover/03_status_page_takeover_ui.md` (`busy`, TAKE OVER affordance, `E12` page handling).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
== host suites: bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2369 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 862 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 268 checks, 0 failures
|
||||
test_claim 102 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the bridge handover (phase 03)
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 287 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
element ids
|
||||
page 113 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
sanitizers: on
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2369 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 862 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 268 checks, 0 failures
|
||||
test_claim 102 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the bridge handover (phase 03)
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 287 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
element ids
|
||||
page 113 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== firmware: platformio run -e bridge -e console -e rollback ==
|
||||
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/bridge/partitions.bin
|
||||
Retrieving maximum program size .pio/build/bridge/firmware.elf
|
||||
Checking size .pio/build/bridge/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
|
||||
Flash: [==== ] 39.8% (used 1250533 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/console/partitions.bin
|
||||
Retrieving maximum program size .pio/build/console/firmware.elf
|
||||
Checking size .pio/build/console/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
|
||||
Flash: [==== ] 37.2% (used 1170789 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.87 seconds =========================
|
||||
|
||||
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/rollback/partitions.bin
|
||||
Retrieving maximum program size .pio/build/rollback/firmware.elf
|
||||
Checking size .pio/build/rollback/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
|
||||
Flash: [==== ] 36.6% (used 1150201 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.87 seconds =========================
|
||||
|
||||
Environment Status Duration
|
||||
------------- -------- ------------
|
||||
bridge SUCCESS 00:00:00.859
|
||||
console SUCCESS 00:00:00.866
|
||||
rollback SUCCESS 00:00:00.870
|
||||
========================= 3 succeeded in 00:00:02.595 =========================
|
||||
|
||||
== page size: src/web_page.h raw strings ==
|
||||
page bytes: 52041 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
|
||||
|
||||
== README/AGENTS claims vs what this run measured ==
|
||||
README/AGENTS claims: OK (4 954 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 52 041 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
|
||||
|
||||
== repo hygiene ==
|
||||
repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
|
||||
|
||||
== hardware context (informational; cannot change the gate result) ==
|
||||
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
|
||||
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
|
||||
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
|
||||
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
|
||||
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
|
||||
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
|
||||
hw-probe node /usr/bin/node — test_page.js runs
|
||||
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
|
||||
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
|
||||
+ the numbers README/AGENTS quote + hygiene)
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
Everything is 100 % green. Final report:
|
||||
|
||||
**Implemented (`03_session_takeover/03_status_page_takeover_ui.md`)**
|
||||
- `status.h` + `main.cpp`: `StatusSnapshot` gains `busy` (= `control.linked()`) and `takeovers`; `statusJson()` emits `"busy":u` / `"tkov":u` — a link state and an event counter, no address/agent/identity anywhere (pinned). Longest legal frame measured at **349 B**; `kStatusJsonCap` stayed 384 because the test-enforced ≥32-byte headroom rule holds (35 left) — `config.h` comment rewritten with the new arithmetic (measured, not grown by guess; the task's "grow" instruction was conditional on the numbers, which the suite re-measures).
|
||||
- `web_page.h`: link pill's third state `held by another controller` (from `s.busy` / claim non-adoption); **take over** button appears only after the board says held; one click sends `T` then `X` and nothing else, page lands DISARMED with a log line; `E12` disarms + releases mouse + aborts typing + pill `taken over` + auto-reconnect off (no re-claim fight); `E3` keeps its "no room for a claim" meaning with its own wording; Connection-card hint states takeover cannot arm.
|
||||
- `relayctl.py`: `--take-over`; adoption probe (`OK`-then-status vs `OK`-then-silence = the board reporting busy; no probing frame sent); default = refusal naming `--take-over` with no `X` on the wire; flag on a free board = stated no-op; `_poll` now drains the whole batch (a status riding behind `OK` is kept, not abandoned — bug found and fixed).
|
||||
- `claim.h`: the `H`-refusal comment corrected — the claimant's answer *is* its own non-adoption; `busy` serves pollers and post-handover frames.
|
||||
|
||||
**Verification** — `bash .agents/validate.sh` → **validation OK, exit 0**: 5 026 checks plain + sanitised, 0 failures (`test_proto` 2 379, `test_page` 142, `test_relayctl` 308 incl. T→X ordering / default refusal / free-board no-op / `T,X,H` through `main()`); `bridge/console/rollback` all build (bridge RAM 99 744 B — unchanged; Flash 1 257 933 B, page +7 245 B); page 59 286 B, `test_conn` chunk math updated (41 chunks / 11 ticks); hygiene OK. Device: **skipped (no device run)** recorded in `.agents/HW_VERIFICATION.md` — a board is reachable but runs pre-phase-03 firmware, and the two-device gesture needs the operator's passphrase (guessing feeds the shared lockout); **no live changes made**; close-out commands written into the row.
|
||||
|
||||
**Docs** — README: check count ×2, page 59 286 B, S-frame row (`busy`/`tkov`), trust-table row "someone holding the passphrase … can take the bridge over, cannot arm it by taking it", Controller-page pill/take-over rows, Security-model busy-is-not-identity sentence, CLI `--take-over` docs + example, Troubleshooting page/CLI rows, Tests-table rows/descriptions. AGENTS.md: check count only (its stale-fact test).
|
||||
|
||||
**Next pending task:** `03_session_takeover/04_takeover_tests_docs.md` (`relayctl`/frame test completion, pin-inversion demonstration, README final pass).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
== host suites: bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2379 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 862 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 280 checks, 0 failures
|
||||
test_claim 102 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the bridge handover (phase 03)
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the busy board and --take-over
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 308 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
the busy board and the take-over
|
||||
element ids
|
||||
page 142 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
sanitizers: on
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2379 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 862 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 280 checks, 0 failures
|
||||
test_claim 102 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the bridge handover (phase 03)
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the busy board and --take-over
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 308 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
the busy board and the take-over
|
||||
element ids
|
||||
page 142 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== firmware: platformio run -e bridge -e console -e rollback ==
|
||||
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/bridge/partitions.bin
|
||||
Retrieving maximum program size .pio/build/bridge/firmware.elf
|
||||
Checking size .pio/build/bridge/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
|
||||
Flash: [==== ] 40.0% (used 1257933 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.87 seconds =========================
|
||||
|
||||
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/console/partitions.bin
|
||||
Retrieving maximum program size .pio/build/console/firmware.elf
|
||||
Checking size .pio/build/console/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
|
||||
Flash: [==== ] 37.5% (used 1178173 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.85 seconds =========================
|
||||
|
||||
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/rollback/partitions.bin
|
||||
Retrieving maximum program size .pio/build/rollback/firmware.elf
|
||||
Checking size .pio/build/rollback/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
|
||||
Flash: [==== ] 36.8% (used 1157609 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.86 seconds =========================
|
||||
|
||||
Environment Status Duration
|
||||
------------- -------- ------------
|
||||
bridge SUCCESS 00:00:00.866
|
||||
console SUCCESS 00:00:00.855
|
||||
rollback SUCCESS 00:00:00.858
|
||||
========================= 3 succeeded in 00:00:02.579 =========================
|
||||
|
||||
== page size: src/web_page.h raw strings ==
|
||||
page bytes: 59286 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
|
||||
|
||||
== README/AGENTS claims vs what this run measured ==
|
||||
README/AGENTS claims: OK (5 026 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 286 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
|
||||
|
||||
== repo hygiene ==
|
||||
repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
|
||||
|
||||
== hardware context (informational; cannot change the gate result) ==
|
||||
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
|
||||
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
|
||||
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
|
||||
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
|
||||
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
|
||||
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
|
||||
hw-probe node /usr/bin/node — test_page.js runs
|
||||
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
|
||||
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
|
||||
+ the numbers README/AGENTS quote + hygiene)
|
||||
@@ -0,0 +1,12 @@
|
||||
**Report — task 03/04 (`04_takeover_tests_docs.md`)**
|
||||
|
||||
- Implemented:
|
||||
- Verified the three takeover source pins in `tests/test_relayctl.py` (funnel-before-adoption; no `arm(`/`setArmed`/`armed = true` in `handOverClaim` or the controller-side `X` case; whitelist consulted before the Takeover branch and `X` only in `claim.h`'s authenticated branch; `E12` named code + incumbent send) — all present and re-read against `control_server.cpp`/`claim.h`/`main.cpp`.
|
||||
- Re-read bug 10's three existing pins + the 4-writer `_lastInboundMs` census against the phase-03-touched code: sentences still true (not just green).
|
||||
- README: takeover bullet in "Disarmed is the only default"; end-to-end takeover procedure (`watch_target` + `key KeyA --hold 30000` + `--take-over status`, key-up-before-first-newcomer-key ordering) + evidence-table row; bugs 12 (page read `E<code>` as one char — `E10/E11/E12` misread as `E1`) and 13 (relayctl `_poll` abandoned the batch behind `OK`, breaking the adoption probe) added; section → "Thirteen bugs", all 6 cross-refs (README ×3, AGENTS.md ×2, `tls_pem.h` comment) updated.
|
||||
- Page safety sentences confirmed in `test_page.js` (disarm/mouse-release/run-end/`taken over`/no-auto-reclaim on `E12`; nothing sent before the click; `E3` distinct). AGENTS.md: stale bug-count titles fixed only; invariant 8 already described the claim.
|
||||
- Tests/lint: `bash .agents/validate.sh` → **validation OK**: 5 026 checks ×2 (plain + `SANITIZE=1`), 12 Tests-table rows match suites, page 59 286 B, Backlog contiguous, ws:// sentences clean; `bridge`/`console`/`rollback` builds SUCCESS; hygiene OK.
|
||||
- Pin inversions proven (then reverted, no leftovers): (a) `closeLink` moved after adoption → exactly 1 FAIL ("funnel runs BEFORE…"); (b) `rearm();` inserted → 1 FAIL ("no `arm(`…"); (c) `claim.h` admits `X` unauthed → pin FAIL + `test_claim` matrix cell FAIL.
|
||||
- README/AGENTS changes: as above; AGENTS.md gained no prose, only stale-fact fixes.
|
||||
- Live infra: **no live changes.** A board is reachable but runs pre-phase-03 firmware; the two-controller takeover needs the operator's passphrase (guessing feeds the shared 5-fail lockout) and a reflash is a device side effect — the takeover rows in `.agents/HW_VERIFICATION.md` stand as `skipped (no device run)` with the exact close-out commands.
|
||||
- Next pending: phase-03 final pass; then `04_controller_page_responsive/01_responsive_layout.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
== host suites: bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2379 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 862 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 280 checks, 0 failures
|
||||
test_claim 102 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the bridge handover (phase 03)
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the busy board and --take-over
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 308 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
the busy board and the take-over
|
||||
element ids
|
||||
page 142 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== host suites: SANITIZE=1 bash tests/run_tests.sh ==
|
||||
compiler: g++ inside localhost/ai-sandbox:ubuntu-9a08b1d500b0 (repo mounted at /work)
|
||||
sanitizers: on
|
||||
keystate 40 checks, 0 failures
|
||||
proto 2379 checks, 0 failures
|
||||
text 459 checks, 0 failures
|
||||
mouse 48 checks, 0 failures
|
||||
output 153 checks, 0 failures
|
||||
ws_codec 135 checks, 0 failures
|
||||
test_tls_pem 862 checks, 0 failures
|
||||
test_tls_session 118 checks, 0 failures
|
||||
test_conn 280 checks, 0 failures
|
||||
test_claim 102 checks, 0 failures
|
||||
handshake
|
||||
frame encoding
|
||||
frame decoding
|
||||
key table
|
||||
pasted text
|
||||
mouse
|
||||
protocol strings
|
||||
the fail-safe wiring
|
||||
the bridge handover (phase 03)
|
||||
one routing path, two transports
|
||||
the client side of pinning
|
||||
the busy board and --take-over
|
||||
the target-side verifier
|
||||
t= 0.00s >>> Ctrl+Alt+Del <<<
|
||||
relayctl 308 checks, 0 failures
|
||||
the log stays readable
|
||||
characters
|
||||
key names in the log
|
||||
sending text
|
||||
refusing what it cannot type
|
||||
stopping
|
||||
the mouse
|
||||
the connect path
|
||||
the channel pill and the certificate line
|
||||
the busy board and the take-over
|
||||
element ids
|
||||
page 142 checks, 0 failures
|
||||
RESULT: all suites passed
|
||||
|
||||
== firmware: platformio run -e bridge -e console -e rollback ==
|
||||
Processing bridge (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/bridge/partitions.bin
|
||||
Retrieving maximum program size .pio/build/bridge/firmware.elf
|
||||
Checking size .pio/build/bridge/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 30.4% (used 99744 bytes from 327680 bytes)
|
||||
Flash: [==== ] 40.0% (used 1257933 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.88 seconds =========================
|
||||
|
||||
Processing console (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/console/partitions.bin
|
||||
Retrieving maximum program size .pio/build/console/firmware.elf
|
||||
Checking size .pio/build/console/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [== ] 20.6% (used 67352 bytes from 327680 bytes)
|
||||
Flash: [==== ] 37.5% (used 1178173 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.87 seconds =========================
|
||||
|
||||
Processing rollback (platform: espressif32@8.0.0; board: esp32-s3-devkitc-1; framework: arduino)
|
||||
--------------------------------------------------------------------------------
|
||||
Verbose mode can be enabled via `-v, --verbose` option
|
||||
CONFIGURATION: https://docs.platformio.org/page/boards/espressif32/esp32-s3-devkitc-1.html
|
||||
PLATFORM: Espressif 32 (8.0.0) > Espressif ESP32-S3-DevKitC-1-N8 (8 MB QD, No PSRAM)
|
||||
HARDWARE: ESP32S3 240MHz, 320KB RAM, 8MB Flash
|
||||
DEBUG: Current (esp-builtin) On-board (esp-builtin) External (cmsis-dap, esp-bridge, esp-prog, iot-bus-jtag, jlink, minimodule, olimex-arm-usb-ocd, olimex-arm-usb-ocd-h, olimex-arm-usb-tiny-h, olimex-jtag-tiny, tumpa)
|
||||
PACKAGES:
|
||||
- framework-arduinoespressif32 @ 4.30312.261024 (3.3.12)
|
||||
- framework-arduinoespressif32-esp32s3-libs @ 3.3.12
|
||||
- tool-esptool @ 2.50400.0 (5.4.0)
|
||||
- tool-riscv32-esp-elf-gdb @ 12.1.0+20221002
|
||||
- tool-xtensa-esp-elf-gdb @ 12.1.0+20221002
|
||||
- toolchain-riscv32-esp @ 14.2.0+20260121
|
||||
- toolchain-xtensa-esp-elf @ 14.2.0+20260121
|
||||
LDF: Library Dependency Finder -> https://bit.ly/configure-pio-ldf
|
||||
LDF Modes: Finder ~ chain, Compatibility ~ soft
|
||||
Found 45 compatible libraries
|
||||
Scanning dependencies...
|
||||
Dependency Graph
|
||||
|-- Adafruit NeoPixel @ 1.15.5
|
||||
|-- ESPmDNS @ 3.3.12
|
||||
|-- USB @ 3.3.12
|
||||
|-- Preferences @ 3.3.12
|
||||
|-- WiFi @ 3.3.12
|
||||
Building in release mode
|
||||
Generating partitions .pio/build/rollback/partitions.bin
|
||||
Retrieving maximum program size .pio/build/rollback/firmware.elf
|
||||
Checking size .pio/build/rollback/firmware.elf
|
||||
Advanced Memory Usage is available via "PlatformIO Home > Project Inspect"
|
||||
RAM: [=== ] 28.6% (used 93568 bytes from 327680 bytes)
|
||||
Flash: [==== ] 36.8% (used 1157609 bytes from 3145728 bytes)
|
||||
========================= [SUCCESS] Took 0.88 seconds =========================
|
||||
|
||||
Environment Status Duration
|
||||
------------- -------- ------------
|
||||
bridge SUCCESS 00:00:00.879
|
||||
console SUCCESS 00:00:00.867
|
||||
rollback SUCCESS 00:00:00.882
|
||||
========================= 3 succeeded in 00:00:02.628 =========================
|
||||
|
||||
== page size: src/web_page.h raw strings ==
|
||||
page bytes: 59286 (a reflashed device must serve exactly this many; kIndexHtmlLen is sizeof(kIndexHtml)-1 of these same bytes, both builds)
|
||||
|
||||
== README/AGENTS claims vs what this run measured ==
|
||||
README/AGENTS claims: OK (5 026 checks quoted and measured in both runs; 12 Tests-table rows match their suites; 59 286 page bytes quoted; Backlog contiguous; every ws:// sentence names the rollback)
|
||||
|
||||
== repo hygiene ==
|
||||
repo hygiene: OK (109 tracked files, no ignored-tracked files, no key-material shapes, no PII hits)
|
||||
|
||||
== hardware context (informational; cannot change the gate result) ==
|
||||
hw-probe serial /dev/ttyACM0 present, readable (NOT opened: opening toggles DTR/RTS and resets the board)
|
||||
hw-probe mdns keyrelay.local resolves -> 10.2.0.22
|
||||
hw-probe https https://keyrelay.local/ -> HTTP 200 (TLS listener is up)
|
||||
hw-probe http http://keyrelay.local/ -> HTTP 301, redirects to https://keyrelay.local/ (redirect-only: the post-phase-02 shape)
|
||||
hw-probe tls 10.2.0.22:443 negotiated TLSv1.2 / ECDHE-ECDSA-AES256-GCM-SHA384 (a self-signed verify failure is the expected shape; the fingerprint to compare it against is on the serial console)
|
||||
hw-probe uv /home/ducoterra/.local/bin/uv — firmware builds available
|
||||
hw-probe node /usr/bin/node — test_page.js runs
|
||||
hw-probe evdev board 046d:c33f visible: keyboard=/dev/input/event19 (readable) mouse=/dev/input/event20 (readable) — python3 tools/watch_target.py runs ON THIS machine (the target)
|
||||
validation OK (suites + sanitised suites + bridge/console/rollback builds + page size
|
||||
+ the numbers README/AGENTS quote + hygiene)
|
||||
@@ -10,7 +10,7 @@ The README is the single source of truth for behaviour, protocol, safety and mea
|
||||
This file is the operating manual: what must not break, how to build and verify, and where
|
||||
the traps are. **Do not fork the protocol or the safety model into `docs/`** — a second copy
|
||||
drifts, and two paths that can disagree about arming is the exact failure this project keeps
|
||||
having to fix (see README, "Eleven bugs").
|
||||
having to fix (see README, "Thirteen bugs").
|
||||
|
||||
## Phased execution
|
||||
|
||||
@@ -57,9 +57,17 @@ This repository runs work through `.agents/phases/`:
|
||||
VT it reboots the target. Same reasoning for the VT-switch chords.
|
||||
7. **One frame is one command.** ASCII, no JSON inbound, ≤ `kWsMessageCap`. Long payloads are
|
||||
the *controller's* problem to split, with the board reporting how much it still holds.
|
||||
8. **The keyboard is exclusive; the board is not.** A second `/ws` upgrade is refused (`E3`);
|
||||
read-only HTTP is served even while a controller holds the bridge. Keep both routes going
|
||||
through `serveReadonly()` so they cannot drift apart.
|
||||
8. **The keyboard is exclusive; the board is not.** A second `/ws` upgrade cannot drive
|
||||
the keyboard: it may only stand in the spare slot as a pending claim that
|
||||
authenticates (same passphrase, same shared lockout) and may then send nothing but
|
||||
`K` and the deliberate `X` — `X` hands the bridge over *through the release funnel*
|
||||
(incumbent told with `E12`, every key and button released) and the board lands
|
||||
**disarmed**; nothing reachable from `X` may arm. Every other frame is refused by
|
||||
the one whitelist in `src/claim.h`, which drops the claim, and no claim frame ever
|
||||
reaches the command dispatcher. A second claim, or a third socket, is
|
||||
refused (`503`/`E3`, or the slot refusal); read-only HTTP is served even while a
|
||||
controller holds the bridge. Keep both routes going through `serveReadonly()` so they
|
||||
cannot drift apart.
|
||||
|
||||
## Tables that must agree
|
||||
|
||||
@@ -90,7 +98,7 @@ the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copi
|
||||
## Build, test, flash
|
||||
|
||||
```bash
|
||||
tests/run_tests.sh # 4826 checks, no hardware
|
||||
tests/run_tests.sh # 5026 checks, no hardware
|
||||
SANITIZE=1 tests/run_tests.sh # + ASan/UBSan — use it for anything touching buffers
|
||||
# PlatformIO. The venv below runs Python 3.15, which esptool REFUSES ("Python version must
|
||||
# be 3.10..3.14") -- builds fine, dies at upload. Use uv's 3.14 for anything with -t upload:
|
||||
@@ -159,7 +167,7 @@ wired now — but ask, of every green check, *what would still pass if this were
|
||||
## Editing rules
|
||||
|
||||
- **Pure modules** (`keystate.h`, `mouse.h`, `output.h`, `proto.h`, `status.h`, `text.h`,
|
||||
`ws_codec.h`, `sha1.h`, `tls_pem.h`) include no Arduino or ESP-IDF headers. That is what lets the
|
||||
`ws_codec.h`, `sha1.h`, `tls_pem.h`, `claim.h`) include no Arduino or ESP-IDF headers. That is what lets the
|
||||
identical code run on a host under sanitisers. Keep it — and keep the *crypto library*
|
||||
out too: `tls_pem.h` handles the identity's strings, `tls.h`/`tls.cpp` handle the
|
||||
library, and the host suite includes the first and never the second. `tls_session.h`
|
||||
@@ -194,7 +202,7 @@ wired now — but ask, of every green check, *what would still pass if this were
|
||||
nothing may print it, hash-and-print it in a way that reads as the key, or be added to the
|
||||
tree as a `.pem`. Fingerprint yes, key never.
|
||||
- `TODO.md` holds **open items only**; clear it when they are done. The durable record is the
|
||||
README: a fixed bug belongs in "Eleven bugs this verification caught" with its root cause and
|
||||
README: a fixed bug belongs in "Thirteen bugs this verification caught" with its root cause and
|
||||
how it was proven, a new capability in the section that describes it, and a rule worth
|
||||
repeating here. A TODO that has accumulated resolved items tells the next reader nothing about
|
||||
what is left to do.
|
||||
|
||||
@@ -56,15 +56,20 @@ that one is the browser's own guarantee, not something this firmware has to hono
|
||||
script, nothing it can type later, no gesture it can replay. Pasted text is typed from
|
||||
RAM and forgotten; the serial console has no command that types or clicks anything at
|
||||
all. It is a keyboard and a mouse, not a payload dropper.
|
||||
* **4826 checks** cover the key-state machine, the mouse state, the report queues and the
|
||||
* **5026 checks** cover the key-state machine, the mouse state, the report queues and the
|
||||
release funnel, the wire protocol, the text→keystroke map, SHA-1/base64, the WebSocket
|
||||
codec, the TLS identity's PEM armour, name table and fingerprint renderings, the TLS
|
||||
session's policy layer (handshake deadline, slot admission, library-return table,
|
||||
refusal record and frame ring), the transport view's response head, `:80` answer table,
|
||||
redirect builder and page send cursor, the status frame's fields and the buffer they have
|
||||
to fit in, the controller page's own script (including its refusal to type a passphrase
|
||||
from a page that arrived in the clear), the CLI's frames and its TLS trust decisions
|
||||
(no trust argument → refuse; wrong fingerprint → nothing sent), and the target-side
|
||||
redirect builder and page send cursor, the pending takeover claim's whitelist and
|
||||
clocks (which frames a claim may send at all — arming is not one of them), the status
|
||||
frame's fields (including `busy`/`tkov`, which say a bridge is held without ever saying
|
||||
by whom) and the buffer they have
|
||||
to fit in, the controller page's own script (its refusal to type a passphrase from a
|
||||
page that arrived in the clear, and the take-over gesture that stays down until one
|
||||
deliberate click), the CLI's frames and its TLS trust decisions
|
||||
(no trust argument → refuse; wrong fingerprint → nothing sent) and its `--take-over`
|
||||
ordering, and the target-side
|
||||
verifier itself — all before anything is flashed.
|
||||
|
||||
---
|
||||
@@ -168,7 +173,7 @@ connector it already uses.
|
||||
|
||||
```bash
|
||||
cd keyrelay
|
||||
tests/run_tests.sh # 4826 checks, no hardware needed
|
||||
tests/run_tests.sh # 5026 checks, no hardware needed
|
||||
pio run -e bridge # ~1.2 MB image, ~40% of the 3 MB app slot (TLS on)
|
||||
python3 tools/to_bootloader.py # 1200-baud touch -> ROM download mode
|
||||
pio run -e bridge -t upload # flash
|
||||
@@ -247,7 +252,11 @@ component that makes all of this pointless. A pinned run hashes the peer certifi
|
||||
**before sending its first frame** and aborts on mismatch, so a wrong pin never even leaks
|
||||
the passphrase attempt. `--plain` selects the plaintext `ws://` on :80 — the `-D KR_TLS=0`
|
||||
rollback board is the only thing that answers it, explicit opt-in, scriptable like everything
|
||||
else.
|
||||
else. A board held by another controller is a refusal, never a fallback: the tool says so
|
||||
and names `--take-over`, which is the same one deliberate `X` the page's TAKE OVER button
|
||||
sends — the incumbent is told (`E12`), every key and every mouse button is released through
|
||||
the release funnel, and the session continues **disarmed**. Against a free board
|
||||
`--take-over` is a stated no-op; neither path can arm.
|
||||
|
||||
To send text instead of pressing keys, paste it into **Send text** and press its button:
|
||||
it says how many characters it will type, the board types them as keystrokes at a pace the
|
||||
@@ -321,6 +330,14 @@ Enforced in this order, each independent of the others:
|
||||
1. **Disarmed is the only default.** Boot, page reload, WebSocket reconnect and page load
|
||||
all begin disarmed. ` armed` is a state the board holds, not something the page
|
||||
assumes — the page's own arming only becomes visible when the board confirms it.
|
||||
A **takeover** lands disarmed too: when a second authenticated socket takes the
|
||||
bridge — `X`, or `--take-over` in the terminal — the handover runs through
|
||||
`releaseEverything()` *before* the newcomer is adopted, so every key **and every
|
||||
mouse button** the incumbent held comes up, and the handover itself cannot arm —
|
||||
nothing reachable from `X` calls `arm()`; only an ordinary authenticated `A1`
|
||||
(rule 2), the board's button, or the console arms the session that now holds it.
|
||||
The displaced incumbent sees `E12` and mirrors the same gesture locally: disarm,
|
||||
release the mouse, end the typing run, and stay down until a human clicks again.
|
||||
2. **Arming requires authority**: the passphrase over the network, the button on the
|
||||
board, or the serial console. Nothing else can arm it, and nothing at all reaches the
|
||||
target while disarmed — not one key, not one chord, not even a "hello".
|
||||
@@ -344,7 +361,7 @@ Enforced in this order, each independent of the others:
|
||||
indefinitely with the last key still pressed (bug 10). A board armed by its own button
|
||||
with no controller at all is deliberately left running: it has no controller to go
|
||||
quiet. This backstop is not gated on the link still existing, and must never be — see
|
||||
[Eleven bugs](#eleven-bugs-this-verification-caught-all-fixed).
|
||||
[Thirteen bugs](#thirteen-bugs-this-verification-caught-all-fixed).
|
||||
5. **Focus loss releases every key.** The tab blurring or being hidden sends "all keys
|
||||
up" and disarms (unless you tick *stay armed if this tab loses focus*, which still
|
||||
releases the keys — you cannot keep a held Ctrl alive by tabbing away).
|
||||
@@ -391,7 +408,7 @@ specifically, and it is the browser's, not ours.
|
||||
the target rather than only clearing the board's own idea of what is down. That is the
|
||||
fixed ninth bug: a held key stayed held on the target after silence-disarm, target loss or
|
||||
a reload, because the report that would have released it was never queued (see
|
||||
[Measured behaviour](#eleven-bugs-this-verification-caught-all-fixed)).
|
||||
[Measured behaviour](#thirteen-bugs-this-verification-caught-all-fixed)).
|
||||
|
||||
---
|
||||
|
||||
@@ -405,6 +422,7 @@ a reload, because the report that would have released it was never queued (see
|
||||
| Anyone with a copy of this firmware (or of this repository) | build their own keyrelay; read every byte of the protocol and of this README | impersonate *your* board or decrypt a session to it. The keypair is generated on your device, lives only in its NVS, and is not in the image, the tree, a log or a URL; every board's fingerprint is different, which is the only reason pinning means anything |
|
||||
| Whoever holds the laptop's mouse | the browser tab's pointer, only after a click on **Capture pointer**, and only while armed | move the target's pointer without the LED being red, or keep moving it after `Esc` — Pointer Lock is revoked by the browser and cannot be retaken without another click |
|
||||
| Someone guessing the passphrase | 5 attempts before a 30 s lockout during which the token is not evaluated at all | learn anything from the failure: same error, same close, no timing oracle (constant-time compare) |
|
||||
| Someone holding the passphrase, arriving on a second socket — a second tab, another laptop, a colleague | stand in the one pending-claim slot, keep itself warm, and take the bridge with one deliberate `X` (or `--take-over` from the terminal) | arm it by doing so — **someone with the passphrase can take the bridge over, and cannot arm it by taking it**: the handover runs through the release funnel and lands disarmed, and only an ordinary authenticated `A1` arms. And they cannot learn *who* holds or held it: the status frame says `busy` and counts takeovers — a boolean and a counter, never addresses, user agents or identities |
|
||||
| The owner of the WiFi | join the network, learn that a keyrelay is there, watch the size and timing of a session | read or forge what a controller sends, or arm anything. The perimeter used to be the network alone; now it is the network *and* whether the certificate was checked — see below |
|
||||
| You, holding the board | arm/disarm (button), reconfigure or reflash (console), stop everything (unplug) | — |
|
||||
| The target's user | notice a red LED next to their monitor, and pull it out | — |
|
||||
@@ -455,14 +473,33 @@ secret (`kr::kApPass`, change it in `src/config.h`) — the AP means you never h
|
||||
board on someone else's LAN, which is a smaller blast radius even when the traffic is
|
||||
encrypted.
|
||||
|
||||
**Single controller, by design.** A second WebSocket upgrade is refused with `503` / `E3`
|
||||
rather than queued, swapped or shared. Two controllers typing into one machine is precisely
|
||||
the ambiguity a keyboard must not have, and "who owns this bridge" should never be a tie.
|
||||
The refusal is one slot wide: the board holds at most `kTlsMaxSessions` (2) live
|
||||
connections — the controller's, and one read-only spare — and a third connection is
|
||||
refused outright (a TLS alert record on `:443`, a close on `:80`, both counted), never
|
||||
queued and never half-opened, because a TLS session's I/O buffers are ~32 KB each and
|
||||
"accept first, find no memory later" is not an option on a board with no PSRAM.
|
||||
**Single controller, by design.** A second WebSocket upgrade cannot drive the keyboard.
|
||||
It is accepted into the board's one spare slot as a **pending claim**: it completes the
|
||||
same handshake, authenticates against the same passphrase and the same lockout counters,
|
||||
and may then send nothing but `K` to keep itself warm and one deliberate frame — `X`,
|
||||
**take this bridge over**. Arm, key state, chords including `Ctrl+Alt+Del`, pasted text,
|
||||
mouse frames and even `R` are each refused by one whitelist (`src/claim.h`, host-tested)
|
||||
with `E6`, and the claim is dropped: no frame from a claim reaches the target, disturbs
|
||||
the incumbent, or moves the arming line, and no claim frame is ever handed to the command
|
||||
dispatcher at all. `X` is the one act the door exists for — the operator locked out by
|
||||
somebody's forgotten tab (usually their own) — and it moves the bridge **through the
|
||||
release funnel, never around it**: the incumbent is told (`E12`), every key and every
|
||||
mouse button is released, and the handover lands **disarmed**. A takeover can never
|
||||
produce an armed board — nothing reachable from `X` calls `arm()`; the newcomer arms with
|
||||
an ordinary authenticated `A1`, exactly like a controller that connected cold, or it never
|
||||
arms (the ordering is pinned by source checks in `tests/test_relayctl.py`). A claim that
|
||||
goes quiet is dropped (`kClaimTimeoutMs`, 15 s) and its session freed, so a parked claim
|
||||
cannot hold one of the board's two TLS sessions hostage. Two controllers typing into one
|
||||
machine is precisely the ambiguity a keyboard must not have, and "who owns this bridge"
|
||||
should never be a tie. What the locked-out operator can *see* is `busy` and `tkov` in the
|
||||
status frame — a link state and a count of events — which is why the page can say "held by
|
||||
another controller" and the CLI can refuse by name without either ever saying *who* holds
|
||||
it, or from where. The refusal is one slot wide: the board holds at most
|
||||
`kTlsMaxSessions` (2) live connections — the controller's, and one spare (read-only
|
||||
viewer *or* the one pending claim, never both) — and a third connection is refused
|
||||
outright (a TLS alert record on `:443`, a close on `:80`, both counted), never queued
|
||||
and never half-opened, because a TLS session's I/O buffers are ~32 KB each and "accept
|
||||
first, find no memory later" is not an option on a board with no PSRAM.
|
||||
|
||||
What is exclusive is the **keyboard**, not the board: `GET /` and `/status?token=` are
|
||||
served even while a controller holds the bridge, so a page can be reloaded, a second window
|
||||
@@ -590,7 +627,7 @@ tool's validator and the board's map accept exactly the same 128 codes.
|
||||
|
||||
## Controller page
|
||||
|
||||
Served by the board from flash (`src/web_page.h`, 51 626 bytes — the exact count
|
||||
Served by the board from flash (`src/web_page.h`, 59 286 bytes — the exact count
|
||||
`.agents/validate.sh` prints on every run, which is what a reflashed device must serve; no
|
||||
CDN, no fonts, no framework, nothing external — a keyboard bridge must work on an airplane
|
||||
and on a network that is down). Its script is executed by `tests/test_page.js`, because a
|
||||
@@ -605,9 +642,10 @@ clear, while the rollback build keeps its `ws://`.
|
||||
|---|---|
|
||||
| big ARM/DISARM bar | the state must be unmissable; it only changes when the board confirms |
|
||||
| red page border + red LED while armed | two independent indicators of the same fact |
|
||||
| status pills: network / target / link / armed / caps-num / **mouse** / **tls** | "will my keystrokes land, and where", whether the pointer is yours right now, and whether the channel is encrypted (`tls` vs `plaintext`, from the status frame — not from what the page hopes) |
|
||||
| status pills: network / target / link / armed / caps-num / **mouse** / **tls** | "will my keystrokes land, and where", whether the pointer is yours right now, and whether the channel is encrypted (`tls` vs `plaintext`, from the status frame — not from what the page hopes). The link pill has three honest states: `linked`, `link down`, and `held by another controller` — from the status frame's `busy`, never from a guess, and never saying *who* |
|
||||
| certificate line in the Connection card | the board's own `fp` from the status frame, verbatim, plus where to confirm it (`cert` on the serial console). Rendered only when the frame carries TLS and a fingerprint; the page invents nothing |
|
||||
| *"This page arrived in the clear"* banner | a TLS build's page on an `http://` origin — bytes no TLS board ever served on :80 — disables the passphrase box and the connect button outright and says why, rather than collecting a secret it cannot protect |
|
||||
| **take over** (beside the passphrase field, visible only when the board says it is held) | the accident this is for: a session left connected somewhere else. The button appears only after the board said so — un-adopted claim, `busy` status frame, or the `E12` that says *this* session was displaced — and one click sends `T` then `X` and nothing else. The incumbent is told, the release funnel runs, the board lands **disarmed**, and the page that lost the bridge stops reconnecting until a human clicks again: auto-reclaim is how two tabs become a fight |
|
||||
| **Ctrl+Alt+Del** with two-press confirm | the OS eats the real one; on Linux it reboots |
|
||||
| VT1–VT7, Alt+Tab, Win, PrtSc, Menu | the chords the laptop's compositor steals |
|
||||
| **⇦ snap left / snap right ⇨ / ⛶ fullscreen** | Super+Left, Super+Right, Super+PageUp — KDE window management, sent by the board so your laptop does not answer it with its own windows |
|
||||
@@ -654,9 +692,10 @@ and impossible for one malformed field to desynchronise what follows it.
|
||||
| c → d | `M=<btn>:<dx>:<dy>:<wheel>:<pan>` | **the complete mouse button state** plus this report's relative movement |
|
||||
| c → d | `R` | release every key *and every mouse button*, stay armed — **and drop a text run in progress** |
|
||||
| c → d | `K` | keepalive |
|
||||
| c → d | `X` | claim this bridge for myself — honoured **only** on an authenticated pending claim: the incumbent is told (`E12`), the release funnel runs, the board lands **disarmed**, then the claimant is adopted. From the incumbent controller it is a no-op answered `OK` (you already hold it) |
|
||||
| d → c | `OK` | accepted |
|
||||
| d → c | `E<code>:<text>` | error (codes below) |
|
||||
| d → c | `S{json}` | status, 1 Hz and on every change, incl. `typing` = characters of the run still to type, `tls` = the board holds a working TLS identity, `fp` = the first 8 bytes of its SHA-256 in hex |
|
||||
| d → c | `S{json}` | status, 1 Hz and on every change, incl. `typing` = characters of the run still to type, `busy` = a controller holds the keyboard (to a `/status` poller, someone else; to the adopted controller, itself — a link state, never an identity), `tkov` = completed bridge handovers (a count of events, never who or from-where), `tls` = the board holds a working TLS identity, `fp` = the first 8 bytes of its SHA-256 in hex |
|
||||
|
||||
**The transport is `wss://` on `:443`, and that is the only thing TLS changed about this
|
||||
table.** Every frame above is the same bytes in the same order — the phase that encrypted the
|
||||
@@ -710,19 +749,30 @@ controller sends the next frame when the status says `typing: 0` — the board's
|
||||
not a guess at timing.
|
||||
|
||||
Error codes: `1` not authenticated · `2` locked out · `3` another controller holds the
|
||||
bridge · `4` not armed · `5` no USB host attached · `6` bad message · `7` queue overflow
|
||||
(released and disarmed) · `8` text buffer full · `9` a character with no US-ANSI keystroke ·
|
||||
`10` the mouse interface is off on this board · `11` the target chose HID boot protocol, so
|
||||
no mouse report can be sent safely.
|
||||
bridge (your claim found no room) · `4` not armed · `5` no USB host attached · `6` bad
|
||||
message · `7` queue overflow (released and disarmed) · `8` text buffer full · `9` a
|
||||
character with no US-ANSI keystroke · `10` the mouse interface is off on this board ·
|
||||
`11` the target chose HID boot protocol, so no mouse report can be sent safely ·
|
||||
`12` your session was taken over by another controller — you were **dropped**, which is
|
||||
a different sentence from `3`'s "refused": one of them should be alarming on your screen
|
||||
and the other should not.
|
||||
|
||||
`10` and `11` are two digits, which changed the encoder: `formatError` used to emit
|
||||
`10`, `11` and `12` are two digits, which changed the encoder: `formatError` used to emit
|
||||
`E` + `code % 10` and would have rendered both of them as `E0:` — a refusal that names
|
||||
nothing. Codes now render in full, every single-digit code byte-identical to before.
|
||||
nothing. Codes now render in full, every single-digit code byte-identical to before, and
|
||||
clients read the code up to the first `:` rather than assuming one character — the
|
||||
controller page's single-character read turned `E10`/`E11` into a bogus "E1: no
|
||||
passphrase" and would have hidden `E12`'s reason inside the same misread; the page now
|
||||
parses to the colon, like the protocol says.
|
||||
|
||||
**The refusals that are not `E<code>`** live at the HTTP layer, because they happen before a
|
||||
WebSocket exists: `401` for `/status` without the passphrase; `503` (and `E3`, if the socket
|
||||
got as far as an upgrade) when another controller already holds the bridge; `404` on an
|
||||
unknown path on `:443`. And on the plaintext port, `301` for a page request — a `Location`
|
||||
WebSocket exists: `401` for `/status` without the passphrase; `503` (and `E3`, for a client
|
||||
that maps status codes to them) on a `/ws` upgrade that cannot be accepted as the board's
|
||||
one **pending claim** — a malformed upgrade, or a second claimant when the one claim slot
|
||||
is already filled. The first claimant's upgrade now succeeds (`101`) and authenticates, and
|
||||
what it may then do is wait, keep itself warm with `K`, or send the one deliberate `X` --
|
||||
never anything that arms, types, or disturbs the incumbent
|
||||
([Security model](#security-model-and-trust-boundaries)); `404` on an unknown path on `:443`. And on the plaintext port, `301` for a page request — a `Location`
|
||||
pointing at `https://` the same address and nothing else — plus **`426 Upgrade Required`**
|
||||
for a `/ws` upgrade or for **any** request carrying `?token=`, which is refused rather than
|
||||
redirected precisely so the passphrase is not echoed a second time on the wire the redirect
|
||||
@@ -789,6 +839,11 @@ src/
|
||||
measured against kStatusJsonCap, which is why it is not in the server
|
||||
sha1.h pure SHA-1 + base64 for the WebSocket handshake
|
||||
ws_codec.h pure handshake request parsing + RFC 6455 frame codec
|
||||
claim.h pure the pending takeover claim (phase 03): the spare slot's role, the
|
||||
ONE whitelist of frames a pending claim may have honoured (T, K,
|
||||
and the deliberate X; arm/state/text/mouse/release refused by
|
||||
name -- X only after authentication), and
|
||||
its two-clock deadline arithmetic, as data a host suite walks
|
||||
tls_pem.h pure the TLS identity as strings: PEM armour in both directions, the
|
||||
fingerprint's hex and the two shapes the console and the status frame
|
||||
need (colon pairs, short label), the ONE table of names the certificate
|
||||
@@ -831,9 +886,10 @@ src/
|
||||
is the only HTTP answer for either listener and either port, so the
|
||||
encrypted and plaintext paths cannot disagree about what is served --
|
||||
they differ only in that :80's answer is a redirect or a 426
|
||||
web_page.h the controller page, in PROGMEM (~51 KB now, with Pointer Lock
|
||||
and the pinning banner; one #if KR_TLS build marker, equal-length
|
||||
branches, assembled and size-pinned by three separate test readers)
|
||||
web_page.h the controller page, in PROGMEM (~58 KB now, with Pointer Lock,
|
||||
the pinning banner and the take-over affordance; one #if KR_TLS
|
||||
build marker, equal-length branches, assembled and size-pinned by
|
||||
three separate test readers)
|
||||
main.cpp glue: USB identity/HID composite, WiFi, chords, text run, the TLS
|
||||
identity's NVS records, LED, button, watchdogs, serial console
|
||||
tools/
|
||||
@@ -850,7 +906,7 @@ tools/
|
||||
tests/
|
||||
run_tests.sh builds and runs everything (host g++ or a local container)
|
||||
test_keystate.cpp test_mouse.cpp test_output.cpp test_proto.cpp test_text.cpp
|
||||
test_ws_codec.cpp test_tls_pem.cpp test_tls_session.cpp test_conn.cpp
|
||||
test_ws_codec.cpp test_tls_pem.cpp test_tls_session.cpp test_conn.cpp test_claim.cpp
|
||||
test_relayctl.py test_page.js (node, skipped when absent)
|
||||
.agents/
|
||||
PLAN.md the roadmap and the decisions a phase may not move
|
||||
@@ -867,7 +923,7 @@ TODO.md open items, kept with their root cause rather than deleted
|
||||
If you are an agent or a new contributor, read `AGENTS.md` before changing anything: several
|
||||
rules here are invisible to a compiler and to the test suite at the same time.
|
||||
|
||||
The nine modules marked *pure* — plus `conn.h`, marked `pure*` because only its tables are —
|
||||
The ten modules marked *pure* — plus `conn.h`, marked `pure*` because only its tables are —
|
||||
have no Arduino and no ESP-IDF include, so the identical code compiles and runs on a host
|
||||
under `-fsanitize=address,undefined`. That is not
|
||||
convenience for its own sake: the sanitiser caught five real defects in the frame decoder
|
||||
@@ -950,14 +1006,15 @@ bash .agents/validate.sh # the gate: both runs above + all three firmware
|
||||
| `test_keystate` | 40 | idempotence, transition preservation (a fast tap is two reports), 6-key limit, ErrorRollOver and recovery from it, dedupe, `clear()`, the exact Ctrl+Alt+Del bytes `05 00 4c 00 00 00 00 00` |
|
||||
| `test_mouse` | 48 | the 5-byte report layout, every button bit where the HID spec says it is, idle frames producing **no** report, a button change with no movement still producing one, the button byte being restated rather than evented, `releaseAll` being idempotent, and that no frame can put a value outside one signed byte on the wire |
|
||||
| `test_output` | 153 | **the release funnel.** Queues hold their order and collapse duplicates; releasing with keys held queues an all-keys-up report *when there is a target to receive it*; releasing drops a press that was queued but never sent; releasing with a mouse button held queues all-buttons-up; boot protocol or `mouse off` means no mouse report is queued **but the key release still is**; a pasted run and an in-flight chord are both cancelled; and overflow is detected on both queues. Regression-proven: reinstating the old `releaseEverything()` fails 23 of these |
|
||||
| `test_proto` | 2359 | every command form, strict rejection of malformed state specs, bounds (cap on keys, token length and text frames), render/parse round-trip, error framing (including the two two-digit codes), constant-time compare, **every chord button's bytes matched against the label printed on it**, the page's text constants pinned to `config.h`, every `M=` frame rendered from every button bit against the full signed-byte movement range parsed back to the report it claims, and **the status frame**: `tls`/`fp` rendering, `fp` empty whenever `tls` is 0 however the snapshot was filled, a non-hex `fp` cut at the first non-hex byte so it cannot forge a field, SSID sanitising, and the longest frame the board can legally send (322 bytes) asserted to fit `kStatusJsonCap` with a field's worth of headroom -- and to render as *nothing* one byte short of what it needs |
|
||||
| `test_proto` | 2379 | every command form including `X` (the takeover: parsed alone, `Bad` with any trailing bytes, and the exact claimant-side frame run through the firmware's own parser), strict rejection of malformed state specs, bounds (cap on keys, token length and text frames), render/parse round-trip, error framing (including all three two-digit codes -- `E12` renders at full width so the dropped incumbent reads "taken over", never a truncated `E1`), constant-time compare, **every chord button's bytes matched against the label printed on it**, the page's text constants pinned to `config.h`, every `M=` frame rendered from every button bit against the full signed-byte movement range parsed back to the report it claims, and **the status frame**: `tls`/`fp` rendering, the `busy`/`tkov` pair that says a bridge is held without ever saying by whom (a link boolean and a counter; the fields carry no address, agent or peer, and walking them is what makes the next "just add the client IP" a deliberate edit rather than a quiet one), `fp` empty whenever `tls` is 0 however the snapshot was filled, a non-hex `fp` cut at the first non-hex byte so it cannot forge a field, SSID sanitising, and the longest frame the board can legally send (349 bytes, `busy` set and `tkov` at `UINT32_MAX`) asserted to fit `kStatusJsonCap` with a field's worth of headroom -- and to render as *nothing* one byte short of what it needs |
|
||||
| `test_text` | 459 | the whole printable-ASCII map walked (nothing may be unmapped, nothing may claim a key it has no glyph for), shifted glyphs and the digit row, what is refused rather than guessed at, all-or-nothing frame acceptance, pace arithmetic and clamping, the exact press/release report sequence for two characters, overlay on the operator's held keys, ErrorRollOver propagation, cancel, buffer-full, `P=` render/parse round-trip including the largest legal frame, and one end-to-end case that takes the exact frame the page sends and asserts the HID report sequence the target ends up seeing |
|
||||
| `test_ws_codec` | 135 | SHA-1 against published vectors including the million-`a` case, base64 against RFC 4648, the RFC 6455 handshake example, frame encode/decode at the 125/126 and 64-bit boundaries, byte-at-a-time delivery, and rejection of unmasked, fragmented, reserved-bit, binary, over-long and oversized frames |
|
||||
| `test_tls_pem` | 862 | the identity's strings, walked exhaustively (100 % of `src/tls_pem.h`): PEM armour against an RFC 4648 vector and line-by-line at 0/1/45/46/47/48/49 bytes and every 64-character boundary up to 704, the padded final group terminating its own line, a refusal (writing nothing) one byte short of `cap`, the armour read back through CRLF / missing-newline / stray-space shapes and torn or re-labelled blocks rejected rather than decoded, the fingerprint against the published SHA-256 of `abc` and refused at one byte short, **the two renderings of it the board actually prints** (the status frame's 16-character label, and the console's colon pairs -- rejoining the two printed lines reproduces the digest exactly, each line is short enough for the 128-byte CDC transmit ring, and a refusal writes nothing rather than half a fingerprint), **`derContains`** deciding whether a stored certificate really carries the SAN bytes this build writes (one changed byte anywhere, in the needle or in the certificate, stops matching), the name table pinned to `kHostname`/`kApGw`/`kCertCommonName` with the CN asserted to be `kHostname + ".local"`, the SAN extension's 34 DER bytes (cross-checked byte-for-byte against what OpenSSL writes for the same three names), **the OID that turns those bytes into a `subjectAltName`** (`06 03 55 1D 11`, pinned as DER content bytes with an explicit check that no byte of the dotted text survives into what the writer is handed, and the whole 43-byte `SEQUENCE { OID, OCTET STRING }` pinned against the certificate OpenSSL issues for the same three names), and **`derCarriesSan`** answering no to the exact extension a live board produced before that was fixed -- the right value bytes under a mislabelled OID, which `derContains` still calls a match and no client does -- plus one mutated OID byte, value-without-OID, OID-without-value, and a haystack cut one byte short of carrying it, strict dotted-quad parsing, `__DATE__` with its padded day, the leap-day + 10 years clamp, `utcTime` refusing every component UTCTime cannot carry, and `dateTimeText` rendering a validity bound for `cert` -- same component checks, deliberately without UTCTime's 1950..2049 window because reading a date is not the permission to write one |
|
||||
| `test_tls_session` | 118 | the TLS session's policy layer, walked without mbed TLS (100 % of the pure half of `src/tls_session.h`): the handshake/stall deadline in its wrap-safe `int32_t(now - start) > limit` form including the 49.7-day `millis()` rollover and the exact-boundary case, the slot-admission table (controller, spare, refused) with the cap checked before the free slot and *no transport parameter to consult* -- the third connection is refused the same way whichever listener it knocked on -- `kTlsMaxSessions` pinned to the 2 the RAM arithmetic in `config.h` is written against, the library-return table that decides Bytes / Want / Closed / Error (a WANT misread as fatal drops working controllers on slow WiFi; a fatal misread as WANT spins the loop; the three error-code literals it branches on are pinned against the real mbed TLS by `static_assert` inside `tls_session.cpp`, so neither half can drift silently), the 7-byte fatal-alert record byte-for-byte against RFC 5246 -- including the description byte drafted once as `0x40 because forty`, which is the entire reason a wire format gets its own test, never confidence -- and the frame ring's whole-frame-or-nothing push, wrap-span arithmetic and stable head (which is what makes re-handing identical bytes after a refused write possible at all), its default-constructed / unseated state -- cap coerced to 1, nothing accepted, no modulo by zero on the first tick before any socket exists -- and `reseat()`, because `kr::Conn` binds to a different transport between sockets and a byte left behind would be delivered to the new occupant. Task 04 deleted its `WriteQueue` (and with it the queue checks): the transport view queues head and body through the ring plus `kr::FlashSend` instead, so a second streaming cursor here would be exactly the second path this phase exists to remove |
|
||||
| `test_conn` | 268 | the transport view's deciding half, walked without Arduino or sockets (100 % of the pure half of `src/conn.h`): the **one** HTTP response head -- exact bytes, `Content-Length` agreeing with the body, an extra header landing before `Connection`, a buffer too small refused with **nothing written** rather than a truncated block, and the *widest legal* response (a full dotted-quad authority plus `/index.html`) fitting `kHttpResponseHeadCap` with margin asserted rather than hoped for; **the `:80` answer table** for both builds, including that `-D KR_TLS=0` serves everything (the rollback criterion, expressed as a row) and that an upgrade carrying a token is answered as an upgrade; **`token=` in a query** detected at a key boundary and nowhere else (`mytoken=` is not the passphrase, `&token=` is) and never with the value copied; **the redirect authority** -- `keyrelay.local`, `keyrelay` and a private dotted quad survive and are canonicalised, while `evil.com`, `keyrelay.local@evil.com` (the shape of a real open-redirect URL), a public IP, `evil.local`, `::1`, `300.1.1.1` and a 300-byte Host all fall back to the certificate's name; **the redirect target** carrying only `/` and `/index.html` and refusing a path containing a CR or LF that would inject a header into this board's own response; and **the page send cursor** -- 51 626 bytes drained at `kPageChunkBytes` per write and `kPageBytesPerTick` per tick, every byte of every piece checked against the source, the piece and tick caps asserted, and the resulting 36 chunks / 9 ticks pinned to `config.h`. Two defects in this code were caught by the **sanitised** run rather than by its assertions (a 6-byte key `memcmp`ed past the end of `"a=1"`, and an octet counter that let `"192.168.1.1.1"` write `octets[4]`); both are pinned by the case that triggered them |
|
||||
| `test_relayctl` | 271 | the Python client's codec, that its key table has not drifted from the page's, that its text validator agrees with the firmware's character map on all 128 ASCII codes, CRLF normalisation, `TEXT_FRAME_MAX`/`MAX_PASTE_CHARS` pinned to `config.h`, the mouse frame builder refusing a sixth button and any movement that does not fit one report, movement splitting that reproduces the original displacement exactly, click/drag/wheel frame *ordering*, every generated frame parseable by the firmware's own parser, and the button bits and frame pace agreeing between page, tool and `config.h`. Its structural sections add `handshake_refusal()` -- so "the board wants TLS" (426) and "wrong passphrase" (401) are never the same sentence to an operator -- the **one routing path, two transports** pins described below, and **the client side of pinning**: `select_transport()` refusing no-trust-argument and mixed arguments and naming the way forward, the port table (TLS 443, `--plain` 80, explicit `--port` wins), fingerprints normalised from bare/colon/upper-case forms and *refused* at any other length or alphabet; and a fake board socket that records every byte this side sends, proving a wrong fingerprint aborts with **nothing at all on the wire** (not the upgrade, certainly not `T<token>`), that the right one puts the authentication frame first on the encrypted session, that `--ca` builds `create_default_context` with hostname checking ON and the chain REQUIRED while pinning shares the deliberate post-handshake DER hash, that `--insecure` warns on stderr *every run*, that `--plain` builds no TLS context and is the only path to :80, that `$KEYRELAY_TLS_FP` satisfies `main()`'s gate without a reachable board and the refusal never prints a credential, and that the token pre-flight and the `paste --yes` gate still run before any connection at all, and that every failure on the way to an authenticated session is named -- TLS handshake failure (with the wrong-port hint, never an invitation to use it), `--ca` verification failure (identity-rotation or wrong-device, the two real causes), a peer that shows no certificate, a wrong `Sec-WebSocket-Accept`, a header block that never ends, a board that hangs up mid-handshake. The page-bytes pin also assembles *both* builds' pages from the `#if KR_TLS` marker chunks and asserts the two marker branches are equal length -- three readers of that layout (here, `test_page.js`, `validate.sh`), so no build can serve a page nobody tested |
|
||||
| `test_page` | 113 | the page's script, executed: blank and whitespace-only log lines never render and a repeating line is counted not stacked, character validation, CRLF, hex encoding, one frame for a short paste and exactly the right bytes, a busy board holding the next frame back, a 300-character paste reassembling to the text that went in, refusal of untypeable text and the opt-in drop, cancellation by stop/disarm, **the mouse: capture requires the bridge armed, movement becomes exactly one `M=` per report with nothing sent when idle, a 400×300 flick split so no frame exceeds one signed byte while the diagonal shape survives, the click ordering that makes a drag a drag, wheel pixels quantised to notches with the remainder carried, `Esc`/blur/disarm each releasing the buttons, `stay armed on blur` exempting the keyboard only, a board that reports `mouse:0` refusing capture and dropping one in progress — and every mouse frame the page emits parsed by the firmware's own parser, plus every element id the script reaches for checked against the markup, and **the connect path**: an `https:` origin builds `wss://host/ws` (an `https:` page opening `ws://` is blocked as mixed content and looks exactly like a dead board), a TLS build's page on an `http:` origin opens **no socket and sends not one frame** and says "this page arrived in the clear; reload over https", `boot()` on that origin disables the passphrase box and the connect button and raises the banner, the rollback marker (`KR_TLS_BUILD=0`) still builds `ws://` because that is the documented way back, no credential rides in the connect URL, exactly one `new WebSocket(` exists in the whole page, and the served bytes contain exactly one plaintext `ws://` -- the rollback's path, reachable only from an origin a TLS board never hands out**; and **the channel pill and certificate line**: `tls`/`plaintext` rendered from the status frame with the alarming colour on the alarming state, the line showing exactly the `fp` the frame carried and naming the serial `cert` command, rendering nothing when `tls:0` (even if the frame carried an `fp` anyway) or when `tls:1` brings no `fp` -- the page displays the board's bytes or nothing** |
|
||||
| `test_conn` | 280 | the transport view's deciding half, walked without Arduino or sockets (100 % of the pure half of `src/conn.h`): the **one** HTTP response head -- exact bytes, `Content-Length` agreeing with the body, an extra header landing before `Connection`, a buffer too small refused with **nothing written** rather than a truncated block, and the *widest legal* response (a full dotted-quad authority plus `/index.html`) fitting `kHttpResponseHeadCap` with margin asserted rather than hoped for; **the `:80` answer table** for both builds, including that `-D KR_TLS=0` serves everything (the rollback criterion, expressed as a row) and that an upgrade carrying a token is answered as an upgrade; **`token=` in a query** detected at a key boundary and nowhere else (`mytoken=` is not the passphrase, `&token=` is) and never with the value copied; **the redirect authority** -- `keyrelay.local`, `keyrelay` and a private dotted quad survive and are canonicalised, while `evil.com`, `keyrelay.local@evil.com` (the shape of a real open-redirect URL), a public IP, `evil.local`, `::1`, `300.1.1.1` and a 300-byte Host all fall back to the certificate's name; **the redirect target** carrying only `/` and `/index.html` and refusing a path containing a CR or LF that would inject a header into this board's own response; and **the page send cursor** -- 59 286 bytes drained at `kPageChunkBytes` per write and `kPageBytesPerTick` per tick, every byte of every piece checked against the source, the piece and tick caps asserted, and the resulting 41 chunks / 11 ticks pinned to `config.h`. Two defects in this code were caught by the **sanitised** run rather than by its assertions (a 6-byte key `memcmp`ed past the end of `"a=1"`, and an octet counter that let `"192.168.1.1.1"` write `octets[4]`); both are pinned by the case that triggered them |
|
||||
| `test_claim` | 102 | the pending takeover claim's predicates (`src/claim.h`), walked as a matrix: every `proto::Cmd` against both authentication states and both spare-slot roles -- unauthenticated a claim may send only `T` (the explicit cell that an `X` before the passphrase is refused: no promotion path without `_claimAuthed`), authenticated only `T`, `K` and the deliberate `X` (allowed exactly there, and named as the one act that handover runs through the funnel and cannot arm), with the refusals named one by one (arm, disarm, key state, `Ctrl+Alt+Del`, chords, pasted text, mouse, `R`, `H`) because that list *is* the reason "a pending claim can arm nothing" is checkable rather than believed -- one claim at a time; both clocks (the controller's `kAuthTimeoutMs` before authentication, `kClaimTimeoutMs` after) at the exact boundary and across the 49.7-day `millis()` rollover; and the claim's 64-byte frame budget measured through the real codec: the widest legal `T<token>` parses with room to spare, a frame *at* the cap completes, and one byte over it is an `Err::TooLong` refusal -- never a truncation, never an allocation |
|
||||
| `test_relayctl` | 308 | the Python client's codec, that its key table has not drifted from the page's, that its text validator agrees with the firmware's character map on all 128 ASCII codes, CRLF normalisation, `TEXT_FRAME_MAX`/`MAX_PASTE_CHARS` pinned to `config.h`, the mouse frame builder refusing a sixth button and any movement that does not fit one report, movement splitting that reproduces the original displacement exactly, click/drag/wheel frame *ordering*, every generated frame parseable by the firmware's own parser, and the button bits and frame pace agreeing between page, tool and `config.h`. Its structural sections add `handshake_refusal()` -- so "the board wants TLS" (426) and "wrong passphrase" (401) are never the same sentence to an operator -- the **bridge handover** pins (the release funnel runs *before* the claimant is adopted; no `arm`/`setArmed`/`armed = true` is reachable from the `X` handler or from the controller-side no-op; `claim::honours(_claimAuthed)` is consulted before the Takeover branch and `X` lives in `claim.h`'s authenticated branch only, so no promotion without the passphrase; `E12` is the named code the incumbent is told with, distinct from `E3`), the **one routing path, two transports** pins described below, and **the client side of pinning**: `select_transport()` refusing no-trust-argument and mixed arguments and naming the way forward, the port table (TLS 443, `--plain` 80, explicit `--port` wins), fingerprints normalised from bare/colon/upper-case forms and *refused* at any other length or alphabet; and a fake board socket that records every byte this side sends, proving a wrong fingerprint aborts with **nothing at all on the wire** (not the upgrade, certainly not `T<token>`), that the right one puts the authentication frame first on the encrypted session, that `--ca` builds `create_default_context` with hostname checking ON and the chain REQUIRED while pinning shares the deliberate post-handshake DER hash, that `--insecure` warns on stderr *every run*, that `--plain` builds no TLS context and is the only path to :80, that `$KEYRELAY_TLS_FP` satisfies `main()`'s gate without a reachable board and the refusal never prints a credential, the **busy board and `--take-over`** (the fake board answers `T` as a controller -- `OK` *plus* the adoption status -- or parks the client as a pending claim: `OK` and the silence that means `busy`; so the default refusal names the flag and puts no `X` on the wire and frees the slot, the flag sends `T` then `X` in that order and nothing else, a free board gets a stated no-op with no `X` wasted on it, and through `main()` the whole gesture is `T`, `X`, then `H` -- claim, handover, status -- with the terminal told the board came back DISARMED and no credential printed), and that the token pre-flight and the `paste --yes` gate still run before any connection at all, and that every failure on the way to an authenticated session is named -- TLS handshake failure (with the wrong-port hint, never an invitation to use it), `--ca` verification failure (identity-rotation or wrong-device, the two real causes), a peer that shows no certificate, a wrong `Sec-WebSocket-Accept`, a header block that never ends, a board that hangs up mid-handshake. The page-bytes pin also assembles *both* builds' pages from the `#if KR_TLS` marker chunks and asserts the two marker branches are equal length -- three readers of that layout (here, `test_page.js`, `validate.sh`), so no build can serve a page nobody tested |
|
||||
| `test_page` | 142 | the page's script, executed: blank and whitespace-only log lines never render and a repeating line is counted not stacked, character validation, CRLF, hex encoding, one frame for a short paste and exactly the right bytes, a busy board holding the next frame back, a 300-character paste reassembling to the text that went in, refusal of untypeable text and the opt-in drop, cancellation by stop/disarm, **the mouse: capture requires the bridge armed, movement becomes exactly one `M=` per report with nothing sent when idle, a 400×300 flick split so no frame exceeds one signed byte while the diagonal shape survives, the click ordering that makes a drag a drag, wheel pixels quantised to notches with the remainder carried, `Esc`/blur/disarm each releasing the buttons, `stay armed on blur` exempting the keyboard only, a board that reports `mouse:0` refusing capture and dropping one in progress — and every mouse frame the page emits parsed by the firmware's own parser, plus every element id the script reaches for checked against the markup, and **the connect path**: an `https:` origin builds `wss://host/ws` (an `https:` page opening `ws://` is blocked as mixed content and looks exactly like a dead board), a TLS build's page on an `http:` origin opens **no socket and sends not one frame** and says "this page arrived in the clear; reload over https", `boot()` on that origin disables the passphrase box and the connect button and raises the banner, the rollback marker (`KR_TLS_BUILD=0`) still builds `ws://` because that is the documented way back, no credential rides in the connect URL, exactly one `new WebSocket(` exists in the whole page, and the served bytes contain exactly one plaintext `ws://` -- the rollback's path, reachable only from an origin a TLS board never hands out**; and **the channel pill and certificate line**: `tls`/`plaintext` rendered from the status frame with the alarming colour on the alarming state, the line showing exactly the `fp` the frame carried and naming the serial `cert` command, rendering nothing when `tls:0` (even if the frame carried an `fp` anyway) or when `tls:1` brings no `fp` -- the page displays the board's bytes or nothing**; and **the busy board and the take-over**: `busy` drives the link pill's third state (`held by another controller`) while the adopted controller still reads its own busy as `linked`; an un-adopted claim close shows the take-over button and sends nothing on its own, with the reconnect loop off -- no retry fight; one click sends `T` then `X` in that order and nothing else, and the first status after it is logged as the DISARMED start it is; `E12` disarms, releases the mouse, ends a typing run mid-frame, marks the pill `taken over` and stays down through the close (coming back is one deliberate click, never zero); and `E3` keeps its separate meaning -- it disarms the ask, logs "no room for a claim", and does not claim anyone was displaced** |
|
||||
|
||||
The verifier is verified too, because twice now it was the verifier that was wrong: node
|
||||
classification (EV_REL means a pointing device — not the device's name, not the order the
|
||||
@@ -1043,6 +1100,7 @@ printf 'Hello from keyrelay\n' | \
|
||||
python3 tools/relayctl.py --host keyrelay.local paste --yes # 19 characters
|
||||
python3 tools/relayctl.py --host keyrelay.local listen 20 # heartbeats, live
|
||||
python3 tools/relayctl.py --host keyrelay.local disarm
|
||||
python3 tools/relayctl.py --host keyrelay.local --take-over status # take a bridge a forgotten tab still holds (lands disarmed)
|
||||
|
||||
# 3. the mouse, same two terminals
|
||||
python3 tools/relayctl.py --host keyrelay.local mouse move 120 -40
|
||||
@@ -1065,6 +1123,35 @@ press **capture the pointer**, move the mouse (the target's cursor moves, the la
|
||||
hidden), hold left and move for a drag, press `Esc`. Then arm it, capture, hold a button,
|
||||
and **close the tab** — the target must stop clicking within 8 s of the silence timeout.
|
||||
|
||||
And the bridge handover — the one claim that needs two controllers, and the only ordering
|
||||
claim in this file: the incumbent's key must come **up** before the newcomer's first key
|
||||
reaches the target. Terminal A watches the target; terminal B holds the bridge with a key
|
||||
pressed and held; a third controller — a phone page, or a second terminal holding the same
|
||||
passphrase — takes it:
|
||||
|
||||
```bash
|
||||
# A. on the target, left running: what actually arrived
|
||||
python3 tools/watch_target.py --seconds 120
|
||||
|
||||
# B. on the laptop: hold the bridge and press-and-hold a key long enough to act against
|
||||
python3 tools/relayctl.py --host keyrelay.local arm
|
||||
python3 tools/relayctl.py --host keyrelay.local key KeyA --hold 30000
|
||||
|
||||
# C. while B still holds it: the deliberate act. On the phone, open the page and press
|
||||
# TAKE OVER once the pill says "held by another controller"; from a terminal:
|
||||
python3 tools/relayctl.py --host keyrelay.local --take-over status
|
||||
```
|
||||
|
||||
What must appear, in this order: A shows `A press` (B's held key), then `A release`
|
||||
**caused by the handover** — and *nothing from C before it*, because C's session starts
|
||||
disarmed and types nothing until it arms on purpose. C's own output says the bridge was
|
||||
taken and the board came back **DISARMED**; the `status` it prints reads `armed: 0`,
|
||||
`busy: 1`, `tkov: 1`. B is dropped with `E12` mid-hold; a browser tab in B's seat marks
|
||||
its pill `taken over`, logs "another controller took over", releases its mouse, and stops
|
||||
reconnecting. If `A release` ever appears *after* C's first key, or C is armed without
|
||||
having sent its own `A1`, the funnel moved after the adoption — on the host that same
|
||||
ordering is pinned by the **bridge handover** section of `tests/test_relayctl.py`.
|
||||
|
||||
What a correct run proves, and how to tell:
|
||||
|
||||
| claim | evidence |
|
||||
@@ -1080,6 +1167,7 @@ What a correct run proves, and how to tell:
|
||||
| target LEDs are read back | `LED CapsLock on` on the target, caps indicator on the page |
|
||||
| silence disarms | arm it, close the tab, wait 8 s, `status` says `armed: 0` |
|
||||
| auth is real | `relayctl` with a wrong token fails; the board logs `authfails=1`; 5 tries → `E2` |
|
||||
| **a takeover releases before it adopts** | in `watch_target.py`, the incumbent's `A release` appears **before** anything from the new session — the newcomer starts disarmed and types nothing until its own `A1`; the taker's `status` says `armed: 0`, `busy: 1`, `tkov: 1`; the displaced page's pill says `taken over`, its log says "another controller took over", and it does not reconnect |
|
||||
| **movement reaches the target as movement** | `watch_target.py` prints a `movement dx … dy …` line per report and the summary's `net position` matches the direction and rough distance you moved — the keyboard half of the watcher cannot show this, which is why the mouse node is watched separately |
|
||||
| **the button bits are the buttons they claim to be** | `mouse click --yes` gives `BTN Left press/release`; `--button right` gives `BTN Right`. A button that arrives as the wrong one is a bit-order bug and looks identical from the page |
|
||||
| **a drag is down, move, up in that order** | `BTN Left press`, then `movement` lines, then `BTN Left release` — any other order is a drag that clicks at the start and drags nothing |
|
||||
@@ -1104,10 +1192,12 @@ the stream, and give `--seconds` enough room for the pace (`2048` characters at
|
||||
_On real hardware: AtomS3 Lite → this laptop (Linux, US layout), board on the same WiFi,
|
||||
firmware 1.0.0, `bridge` environment._ Fill in / re-run after any change to the keystroke
|
||||
path; the numbers below are the shape a correct run has. (Those frames predate the TLS
|
||||
identity: a status frame from a phase-02 board carries `tls` and `fp` as well, and `cert`
|
||||
exists and has been confirmed against the live handshake — the pinning rows below — but
|
||||
the status fields themselves and the keystroke path *over a pinned session* need an
|
||||
authenticated controller, and the board's passphrase is the operator's, not this repo's.)
|
||||
identity: a status frame from a phase-02 board carries `tls` and `fp` as well, and a
|
||||
phase-03 board adds `busy` and `tkov` — the link state and the handover count, and never
|
||||
an identity — while its serial `link` line ends with `taken=`. `cert` exists and has been
|
||||
confirmed against the live handshake — the pinning rows below — but the status fields
|
||||
themselves and the keystroke path *over a pinned session* need an authenticated
|
||||
controller, and the board's passphrase is the operator's, not this repo's.)
|
||||
|
||||
```
|
||||
$ python3 tools/console.py status
|
||||
@@ -1135,10 +1225,10 @@ AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0.
|
||||
|
||||
| what | result |
|
||||
|---|---|
|
||||
| flash | 1 134 768 bytes written (706 661 compressed) in 8.1 s at 1 116 kbit/s, hash verified — measured on the pre-TLS image; the phase-02 image is 1 246 145 B (see the footprint row below), so the same math applies to a body ~10 % larger |
|
||||
| footprint | `bridge` with TLS on: **RAM 30.4 % (99 616 / 327 680); Flash 39.6 % (1 246 145 / 3 145 728)** of the 3 MB app slot, `console` 20.5 % (67 224 B) / 37.1 % (1 166 397 B) — the pairs `.agents/validate.sh` prints on every run, re-measured at the phase-02 final pass on 2026-11-09. Against the phase-01 baseline (RAM 26.7 % / 87 592 B, Flash 36.1 % / 1 135 293 B) the whole of phase 02 — identity, listener, transport view, the pinning page, the SAN fix — cost **+12 024 B of RAM and +110 852 B of flash**, 3.5 % more of the app slot. The RAM figure has not moved since task 06 even though the final pass added code: `kSanOidTlv` is 5 bytes of flash, the rest of bug 11 is instructions |
|
||||
| **what TLS itself costs** | the same tree at `-D KR_TLS=0` (`pio run -e rollback`): RAM 28.5 % (93 456 B) / Flash 36.4 % (1 146 137 B) — unchanged by bug 11, which lives entirely inside `#if KR_TLS`. So the encrypted channel is **+6 160 B of RAM and +100 008 B of flash** on top of everything else the phase added, and the gate builds that image every run so the way back cannot rot. Proof the flag took, rather than a build that merely ran: `xtensa-esp-elf-nm .pio/build/rollback/firmware.elf \| grep -c mbedtls_ssl_` → **0** against 115 in the `bridge` image; the same for `mbedtls_x509write` → 0 vs 15; `strings … \| grep -c "pin this certificate"` → 0 vs 1. Measured 2026-11-09 |
|
||||
| **TLS handshake time** | `curl -sk -o /dev/null -w '%{time_appconnect} %{time_total}\n' https://keyrelay.local/` and `…/status`, one capped run each, 2026-11-09: seven warm connections **handshake 0.349–0.423 s**, the whole 51 626-byte page (36 chunks over 9 `loop()` ticks) in **0.53–0.62 s**, `/status` in 0.38–0.43 s. The first connection after ~10 minutes of quiet took **3.94 s** of handshake — worth knowing because from a browser that looks like a timeout and is a cold start |
|
||||
| flash | 1 134 768 bytes written (706 661 compressed) in 8.1 s at 1 116 kbit/s, hash verified — measured on the pre-TLS image; the image this tree builds is 1 257 933 B (see the footprint row below), so the same math applies to a body ~11 % larger |
|
||||
| footprint | `bridge` with TLS on: **RAM 30.4 % (99 744 / 327 680); Flash 40.0 % (1 257 933 / 3 145 728)** of the 3 MB app slot, `console` 20.6 % (67 352 B) / 37.5 % (1 178 173 B) — the pairs `.agents/validate.sh` prints on every run, re-measured at the phase-03 final pass on 2026-11-09. Phase 03 — the claim slot, its whitelist, `busy`/`tkov`, the handover and the take-over affordance — is **+128 B of RAM and +11 788 B of flash** over the phase-02 final pass (RAM 30.4 % / 99 616 B, Flash 39.6 % / 1 246 145 B), most of the flash the page. Against the phase-01 baseline (RAM 26.7 % / 87 592 B, Flash 36.1 % / 1 135 293 B) the whole of phases 02 and 03 cost **+12 152 B of RAM and +122 640 B of flash**, 3.9 % more of the app slot. The RAM figure has barely moved since phase 02 task 06 even though both phases added code: `kSanOidTlv` is 5 bytes of flash, the rest of bug 11 is instructions, and what phase 03 adds per slot is one 64-byte buffer, one frame decoder and four small counters |
|
||||
| **what TLS itself costs** | the same tree at `-D KR_TLS=0` (`pio run -e rollback`): RAM 28.6 % (93 568 B) / Flash 36.8 % (1 157 609 B) — unchanged in kind by bug 11, which lives entirely inside `#if KR_TLS`. So the encrypted channel is **+6 176 B of RAM and +100 324 B of flash** on top of everything else the phase added, and the gate builds that image every run so the way back cannot rot. Proof the flag took, rather than a build that merely ran: `xtensa-esp-elf-nm .pio/build/rollback/firmware.elf \| grep -c mbedtls_ssl_` → **0** against 115 in the `bridge` image; the same for `mbedtls_x509write` → 0 vs 15; `strings … \| grep -c "pin this certificate"` → 0 vs 1. Measured 2026-11-09 |
|
||||
| **TLS handshake time** | `curl -sk -o /dev/null -w '%{time_appconnect} %{time_total}\n' https://keyrelay.local/` and `…/status`, one capped run each, 2026-11-09: seven warm connections **handshake 0.349–0.423 s**, the whole 51 626-byte page of that build (36 chunks over 9 `loop()` ticks; the page is 59 286 B since phase 03, and the served-bytes half of this check is a row in `.agents/HW_VERIFICATION.md`) in **0.53–0.62 s**, `/status` in 0.38–0.43 s. The first connection after ~10 minutes of quiet took **3.94 s** of handshake — worth knowing because from a browser that looks like a timeout and is a cold start |
|
||||
| **TLS session heap** | the board's own `[tls] sessions=N heap=…` serial lines: boot `[tls] https :443 listening heap=187936` → **1 session ≈ 145.5 k** → **2 sessions ≈ 107.9 k**; the task-04 run with the transport view on both ports read 140 092 → 109 228 → close one 144 836 → close all 182 256. A second session is ~31–38 kB (two 16 kB mbed TLS record buffers plus the context), the memory comes back, and 20 connect/disconnect cycles showed no downward drift. This is why `kTlsMaxSessions` is 2 and why a third connection is refused outright rather than queued |
|
||||
| enumeration | `class=03 sub=01 proto=01` — boot-protocol keyboard, the Windows secure-attention precondition |
|
||||
| reflashing | 1200-baud touch into ROM download mode works with this firmware running, so no button and no GPIO0 |
|
||||
@@ -1151,7 +1241,7 @@ AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0.
|
||||
| chord path | `chords=1` counted and delivered (Shift+F15) |
|
||||
| LED read-back | `LED CapsLock on` then `off`, coming from the *target's* HID output reports |
|
||||
| **silence disarms** | controller process killed → `armed:0` within 11 s, keys released. **Worth knowing:** that run passed *before* the silence timer existed — killing a process closes the socket, and the socket-close path disarmed. The FIN-less death (laptop slept, network vanished) is what the timer covers, and it is wired now. A check that passes for the wrong reason is still a check that passes, which is exactly the problem |
|
||||
| single controller | a second client got `E3` / `503` and the board logged `rejected=1` — no takeover |
|
||||
| single controller | a second client got `E3` / `503` and the board logged `rejected=1` — no takeover *(observed pre-phase-03; a second client is a pending claim now — the takeover claims are rows in `.agents/HW_VERIFICATION.md`)* |
|
||||
| **mouse enumeration** | the target created **two** input devices from one HID interface — `keyboard=/dev/input/event19`, `mouse=/dev/input/event20` — with `caps rel: 1943`, i.e. REL_X, REL_Y, REL_WHEEL, REL_HWHEEL and both hi-res axes |
|
||||
| **mouse movement** | `mouse move 120 -40` → `movement dx +120 dy -40` on the target, exact, one report |
|
||||
| **mouse buttons** | `click` → `BTN Left press` + `BTN Left release`; `--button right` → `BTN Right`. The bit order the page claims is the bit order the target sees |
|
||||
@@ -1162,7 +1252,7 @@ AtomS3 Lite → Linux laptop (US layout), board joined the LAN, firmware 1.0.0.
|
||||
| lockout | 5 wrong passphrases → `E2`, for 30 s, from the page's own retry loop |
|
||||
| queues | `drop=0 roll=0` throughout; `queue 0 keys pending, 0 held, ok` at rest |
|
||||
|
||||
### Eleven bugs this verification caught (all fixed)
|
||||
### Thirteen bugs this verification caught (all fixed)
|
||||
|
||||
Worth listing, because each one is a class of bug that HID-over-network tools ship with:
|
||||
|
||||
@@ -1187,11 +1277,16 @@ Worth listing, because each one is a class of bug that HID-over-network tools sh
|
||||
a 503: `curl` of the page, a second browser, and worst of all **pressing reload on the
|
||||
page that already held the bridge**, since the board could still be holding the slot the
|
||||
instant the new request arrives. The fix moves the gate to what it actually protects: a
|
||||
second `/ws` upgrade is still refused (that is a controller claim), while read-only HTTP
|
||||
is served on a separate short-lived socket through the same routing code (`serveReadonly`)
|
||||
so the two paths cannot drift. Verified live: with your browser holding the bridge, the
|
||||
page returns 200 and `/status` returns 401 without the token, 200 with it, and a second
|
||||
upgrade still gets 503 while the incumbent stays `controller=yes` and healthy.
|
||||
second `/ws` upgrade was still refused then (that is a controller claim — phase 03 turned
|
||||
that refusal into the one **pending claim**, so a 503 now answers a *second* claim or a
|
||||
malformed upgrade), while read-only HTTP is served on a separate short-lived socket
|
||||
through the same routing code (`serveReadonly`) so the two paths cannot drift. Verified
|
||||
live: with your browser holding the bridge, the page returns 200 and `/status` returns
|
||||
401 without the token, 200 with it, and a second upgrade still gets 503 while the
|
||||
incumbent stays `controller=yes` and healthy. *(That last sentence is the pre-phase-03
|
||||
observation, kept because it is what the fix was measured against at the time; the
|
||||
read-only half of it is unchanged, and what a second upgrade gets now is in*
|
||||
[Security model](#security-model-and-trust-boundaries).*)
|
||||
7. **The Alt+Tab button sent Ctrl+Alt+A.** Its `data-chord` was `05:04` — LCtrl|LAlt with
|
||||
usage `0x04`, which is the A key. It parsed cleanly, it was correctly gated by arming,
|
||||
it arrived intact and it was the wrong combination: Alt+Tab is `04:2b`. Reported by a
|
||||
@@ -1287,6 +1382,37 @@ Worth listing, because each one is a class of bug that HID-over-network tools sh
|
||||
wrong-but-parseable argument is undocumented until it has bitten you, and a wire format has
|
||||
to be checked against **somebody else's** implementation, not against your own encoder.
|
||||
|
||||
12. **The page read the error code as one character, so every two-digit code arrived as
|
||||
`E1`.** The controller page parsed `E<code>:<text>` with `raw[1]` — fine while every
|
||||
code was one digit, wrong ever since `E10` (mouse interface off) and `E11` (target
|
||||
chose boot protocol) existed: the board's "the pointer is gone" or "boot protocol, no
|
||||
mouse" reached a fully authenticated page as the wrong-passphrase message, clearing
|
||||
`authed` and painting "no passphrase" on a session that was working. Phase 03 found
|
||||
it by writing the test for the code it was adding: `E12` — the one sentence a dropped
|
||||
operator most needs read back verbatim ("your session was taken over") — would have
|
||||
arrived as the same bogus `E1`, hiding the takeover inside the misread. Fixed by
|
||||
parsing the code up to the first `:`, which is what the wire format always was;
|
||||
`test_page.js` now walks `E12` end to end (disarm, mouse released, typing run ended,
|
||||
pill `taken over`, no auto-reclaim) with `E3`'s separate "no room for a claim"
|
||||
sentence pinned beside it, and `test_proto` pins the two-digit codes' full-width
|
||||
rendering so the board cannot send a truncated one. The lesson is the mouse codes'
|
||||
lesson again:
|
||||
a client that assumes a field's width has stopped parsing the protocol and started
|
||||
guessing it.
|
||||
|
||||
13. **`relayctl`'s `_poll` returned from inside the read loop, abandoning the rest of the
|
||||
batch.** A status frame riding in behind the reply the call was waiting for was lost
|
||||
with the abandoned generator — invisible while every caller asked for one thing at a
|
||||
time, and fatal for the phase-03 adoption probe, which reads what *follows* the `OK`:
|
||||
a controller gets `OK` then status back to back (auth answers them in one write), a
|
||||
pending claim gets `OK` and silence, and a drain that dropped the status would report
|
||||
a **free** board as busy — inviting `--take-over` against a bridge nobody held. Found
|
||||
by writing the suite's fake board the way the real one writes; fixed by draining the
|
||||
whole batch, parking an un-wanted status in `_pending_status`, and returning only once
|
||||
the socket is quiet. `test_relayctl.py` pins both probe answers — the fake puts `T`'s
|
||||
`OK` and the adoption status on one write — so an early-return regression makes the
|
||||
free board look held and fails the stated no-op row instead of passing quietly.
|
||||
|
||||
### The hazard nobody warns you about
|
||||
|
||||
**When the target is the machine you are working on, your keystrokes land on your own
|
||||
@@ -1318,10 +1444,11 @@ way. So:
|
||||
| `arm` does nothing, LED blinks red | no USB host attached (`usb:0`); the board is not plugged into the target, or the target suspended the bus |
|
||||
| typing does nothing, `armed` says yes | the tab lost focus: click into the page. Or the target's focus moved — that is the target's answer, not a bug |
|
||||
| `E4 not armed` | it is not armed. `arm` first; chords and states are never free passes |
|
||||
| `E3 another controller` | another tab/machine holds it. Close it; there is no takeover, deliberately |
|
||||
| `E3 another controller` | another tab/machine holds it **and** the one pending-claim slot was already filled — your claim found no room. Close the other tab, or wait for its claim to expire (`kClaimTimeoutMs`, 15 s) and claim again; a takeover lands the board disarmed |
|
||||
| the page's link pill says `held by another controller` | the board said so — an authenticated socket it declined to adopt, a `busy` status frame, or the `E12` that says *this* session was displaced. It never says who: a link state, not an identity. The take-over button beside the passphrase field is one deliberate click (`T` then `X`, nothing else); the page that lost the bridge stays down until someone presses it — auto-reclaim is two tabs fighting over a keyboard |
|
||||
| `E2 locked out` | 5 wrong passphrases; wait 30 s |
|
||||
| keys arrive out of order or missing | check `dropped` (queue overflow) and that the laptop's signal is good; `WiFi.setSleep(false)` is already set. If a *verifier* says keys are missing, check it names the codes — see [the hazard nobody warns you about](#the-hazard-nobody-warns-you-about) |
|
||||
| the CLI says `another controller holds this bridge` | your browser page has it, and that is the rule working. Close the tab; there is no takeover |
|
||||
| the CLI says `another controller holds this bridge` | your browser page has it, and that is the rule working. Close the tab — or re-run with `--take-over`, which is the same one deliberate `X` the page's TAKE OVER button sends: the incumbent is told (`E12`), every key and every mouse button is released through the release funnel, and the board comes back **disarmed** so the new session arms on purpose. Against a free board `--take-over` is a stated no-op |
|
||||
| pasted text arrives as different characters | the **target** is not on a US layout — characters travel as positions, exactly like the live keys. Or the run was sent with *drop characters I cannot type* / `--strip`, which reported how many it removed |
|
||||
| a paste stops before the end | something cancelled it, on purpose: the tab lost focus, the bridge disarmed, the target suspended the bus, `E7`/`E8`, or 8 s with nothing inbound from the controller. The page's log says which, and how far it got |
|
||||
| a chord button does something other than its label | the bytes on the button are wrong — the page carries the wire form itself, so nothing browser-side can catch it. `curl` the page and read `data-chord`; this was the case for Alt+Tab (`05:04`, Ctrl+Alt+A) until the label-vs-bytes test was written |
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* keyrelay -- the pending claim: the state machine of the second socket that may
|
||||
* authenticate but can do nothing else. Pure logic, host-tested
|
||||
* (tests/test_claim.cpp): no Arduino, no IDF, no sockets.
|
||||
*
|
||||
* Why this door exists
|
||||
* --------------------
|
||||
* "No room" used to be the only answer a second `/ws` upgrade ever got, which turned
|
||||
* the most ordinary accident -- a laptop tab that kept the socket open -- into a dead
|
||||
* board until you found the other tab or rebooted. A claim is therefore not a new
|
||||
* privilege: it is the SAME privilege (the passphrase) exercised from a different
|
||||
* socket, plus one deliberate act. This module holds the predicates of that act's
|
||||
* first half -- stand in the doorway -- because the predicates are the whole safety
|
||||
* argument, and an argument nothing executes is an argument a later edit can undo
|
||||
* without noticing. control_server.cpp keeps the routing and the sockets; the answer
|
||||
* to "what may this frame be, in this role, doing that?" lives here and is walked by
|
||||
* tests under sanitisers, in the style of output.h.
|
||||
*
|
||||
* The load-bearing rule of everything here: a pending claim can never produce an
|
||||
* armed board. Nothing in this file knows that arming exists; the whitelist below is
|
||||
* what makes that true by construction rather than by care.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#include "config.h"
|
||||
#include "proto.h"
|
||||
#include "tls_session.h" // tls::deadlinePassed -- one wrap-safe clock arithmetic
|
||||
|
||||
namespace kr {
|
||||
namespace claim {
|
||||
|
||||
/**
|
||||
* What the spare slot is for.
|
||||
*
|
||||
* `Readonly` is the long-standing behaviour: the read-only routes through
|
||||
* `serveReadonly()`, one response, close. `Claim` is a pending takeover claim --
|
||||
* a second controller that has completed the WebSocket upgrade and (if its
|
||||
* passphrase was right) authenticated, and holds nothing. The spare slot exists
|
||||
* exactly once -- one incumbent plus one more, never one incumbent plus two
|
||||
* claimants -- which is the same shape the server already had and the TLS-session
|
||||
* ceiling from phase 02 (`kTlsMaxSessions` = 2), so a claim costs no new socket,
|
||||
* no new buffer class and no new auth path.
|
||||
*/
|
||||
enum class ViewerRole : uint8_t { Readonly, Claim };
|
||||
|
||||
/**
|
||||
* One claim at a time. A second `/ws` upgrade on a slot that already carries a
|
||||
* claim gets the `503` the slot always answered with; a third *socket* never gets
|
||||
* this far -- it is refused at the slot table (`tls::admitConnection`), which is
|
||||
* RAM policy, not takeover policy, and does not move.
|
||||
*/
|
||||
inline bool acceptsUpgrade(ViewerRole role) { return role != ViewerRole::Claim; }
|
||||
|
||||
/**
|
||||
* THE WHITELIST -- the whole safety argument of the pending claim, as one visible
|
||||
* list. A frame from the spare slot is honoured only when this says so:
|
||||
*
|
||||
* allowed, unauthenticated: T<token> -- the credential the claim is made of
|
||||
* allowed, authenticated: T<token> -- re-present it (same compare, same lockout)
|
||||
* K -- keepalive, freshness only
|
||||
* X -- claim the bridge: the one
|
||||
* deliberate act this door exists for. It
|
||||
* joins the whitelist HERE, nowhere else,
|
||||
* and only in the authenticated branch --
|
||||
* the handover it triggers
|
||||
* (control_server.cpp::handOverClaim)
|
||||
* runs the release funnel, lands the
|
||||
* board DISARMED, and cannot arm: an
|
||||
* unauthenticated `X` would be the whole
|
||||
* privilege with none of the proof.
|
||||
*
|
||||
* refused in both states, spelled out because this list is why "a pending claim can
|
||||
* arm nothing" is checkable rather than believed:
|
||||
*
|
||||
* A1 / A0 arm / disarm -- arming is never reachable from a claim
|
||||
* S= key state -- nothing a claim sends reaches the target
|
||||
* C, Y= chords, Ctrl+Alt+Del included (it is fixed on the device, and the
|
||||
* device never sees this frame at all)
|
||||
* P= pasted text
|
||||
* M= mouse reports
|
||||
* R release -- a claim must not be able to disturb the
|
||||
* incumbent's keyboard either; only the funnel
|
||||
* on a real link change does that
|
||||
* H status -- the status frame is the controller's, and
|
||||
* phase 03 does not weaken that even for the
|
||||
* claimant: the claimant's answer IS its own
|
||||
* non-adoption (`OK` with no status frame
|
||||
* behind it means the bridge is held -- the
|
||||
* `busy` field exists so a /status poller and
|
||||
* the post-handover frames say the same thing
|
||||
* in counter-and-link-state terms). Asking
|
||||
* `H` here earns `E6` and the drop, which is
|
||||
* exactly what the page's own detection reads
|
||||
* None/Bad -- an unparseable frame from a non-privileged socket is a drop, not
|
||||
* a conversation
|
||||
*
|
||||
* A `Readonly` slot has no WebSocket at all, so no frame is honoured in that role;
|
||||
* the parameter is here so the predicate is the single authority over both roles and
|
||||
* the test can walk the whole matrix.
|
||||
*/
|
||||
inline bool honours(ViewerRole role, bool authed, proto::Cmd cmd) {
|
||||
if (role != ViewerRole::Claim) return false;
|
||||
if (!authed) return cmd == proto::Cmd::Auth;
|
||||
switch (cmd) {
|
||||
case proto::Cmd::Auth:
|
||||
case proto::Cmd::KeepAlive:
|
||||
case proto::Cmd::Takeover: // `X` -- the deliberate act, authenticated branch only
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the claim past its clock? One anchor (`_claimAtMs`), two limits:
|
||||
*
|
||||
* unauthenticated kAuthTimeoutMs -- exactly the controller's rule: open the
|
||||
* socket, present the passphrase, or go away
|
||||
* authenticated kClaimTimeoutMs -- a claim that has not asked for the bridge
|
||||
* by now is dropped and its session freed
|
||||
* (config.h: an unanswered claim sitting on
|
||||
* one of two TLS sessions is a nuisance
|
||||
* attack, and 15 s is plenty for a click)
|
||||
*
|
||||
* The anchor is refreshed only by frames of this claim, and it is NEVER
|
||||
* `_lastInboundMs`: a claim attempt is not the operator being alive, and the
|
||||
* controller-silence fail-safe must not be held open by a socket that is trying to
|
||||
* take over. That distinction is `readSome()`'s comment and bug 10; this function is
|
||||
* where the claim's own clock lives instead. Wrap-safe via deadlinePassed, so the
|
||||
* 49.7-day `millis()` rollover can neither make a claim immortal nor evict a live one.
|
||||
*/
|
||||
inline bool expired(bool authed, uint32_t nowMs, uint32_t anchorMs) {
|
||||
return tls::deadlinePassed(nowMs, anchorMs, authed ? kClaimTimeoutMs : kAuthTimeoutMs);
|
||||
}
|
||||
|
||||
} // namespace claim
|
||||
} // namespace kr
|
||||
+34
-11
@@ -139,12 +139,12 @@ constexpr size_t kConnWriteChunk = 4096;
|
||||
// write. 1460 = one TCP MSS (1500 MTU - 20 IP - 20 TCP), so a chunk leaves as exactly one
|
||||
// segment and exactly one TLS record, and it is below kConnWriteChunk by construction. The
|
||||
// cost is static RAM: one buffer per live conn, 2 x 1460 = 2920 B, quoted in the phase
|
||||
// report against the phase 01 baseline. The served page is 51 626 B (the count the gate
|
||||
// prints), so a page is 36 chunks.
|
||||
// report against the phase 01 baseline. The served page is 59 286 B (the count the gate
|
||||
// prints), so a page is 41 chunks.
|
||||
constexpr size_t kPageChunkBytes = 1460;
|
||||
|
||||
// The per-tick budget for draining a slot's queue: 4 chunks, so the 51 626-byte page leaves
|
||||
// in 9 ticks of pump() and no single loop() iteration can spend more than four transport
|
||||
// The per-tick budget for draining a slot's queue: 4 chunks, so the 59 286-byte page leaves
|
||||
// in 11 ticks of pump() and no single loop() iteration can spend more than four transport
|
||||
// writes on one socket -- whatever that socket's peer is doing. loop() runs continuously
|
||||
// (nothing in it delays), so the page is out in milliseconds; the budget is not about
|
||||
// throughput, it is the ceiling on how long one iteration can be busy sending.
|
||||
@@ -230,13 +230,16 @@ constexpr uint32_t kStatusIntervalMs = 1000;
|
||||
//
|
||||
// The arithmetic, with every field at its widest legal value -- a 32-character SSID
|
||||
// (staSsid is 33 bytes), a 15-character dotted quad, UINT32_MAX in each of the five
|
||||
// counters, UINT16_MAX in `typing`, and the whole 16-hex `fp`: 322 bytes, measured by the
|
||||
// longest-legal-snapshot check in tests/test_proto.cpp and asserted below this cap, not
|
||||
// believed. `tls` and `fp` together cost 32 of those 322 -- `,"tls":1` is 8 bytes and
|
||||
// `,"fp":"0123456789abcdef"` is 24 -- which is why the cap did not have to move when they
|
||||
// were added, and why the measurement exists: the next field added without re-measuring is
|
||||
// how this constant stops meaning anything. The 62 bytes left over are one more field's
|
||||
// worth of headroom, asserted in the test, not slack to be spent by accident.
|
||||
// counters, UINT16_MAX in `typing`, `busy` set and `tkov` at UINT32_MAX, and the whole
|
||||
// 16-hex `fp`: 349 bytes, measured by the longest-legal-snapshot check in
|
||||
// tests/test_proto.cpp and asserted below this cap, not believed. The two phase-03 fields
|
||||
// cost 27 of those 349 -- `,"busy":1` is 9 bytes and `,"tkov":4294967295` is 18 -- which
|
||||
// is why this cap did not have to grow when they were added and the measurement still
|
||||
// says so; it is also why the measurement is re-run every time: the 35 bytes left over
|
||||
// are one more field's worth of headroom (the test asserts at least 32), no longer the
|
||||
// comfortable two it were, so the next field starts by re-measuring. `tls`/`fp` cost 32
|
||||
// the same way in, and the constant stops meaning anything the first time it is edited
|
||||
// without the number.
|
||||
constexpr size_t kStatusJsonCap = 384;
|
||||
|
||||
// The widest HTTP response head the board can build, in bytes: one formatter
|
||||
@@ -258,4 +261,24 @@ constexpr size_t kHttpHeaderCap = 1536; // reject a request line bigger tha
|
||||
constexpr size_t kWsMessageCap = 256; // one controller frame is a command, not a payload
|
||||
constexpr uint32_t kAuthTimeoutMs = 5000; // a new socket must authenticate this fast
|
||||
|
||||
// A pending takeover claim (phase 03, src/claim.h) is a controller-shaped socket that
|
||||
// holds nothing, so it gets a controller-shaped slice of the buffers: one quarter of
|
||||
// kWsMessageCap. (One quarter, not the sixteenth an early draft said -- kWsMessageCap
|
||||
// is 256, and the arithmetic here is the measurement, not the prose.) The widest frame
|
||||
// a claim may ever send is `T<token>` at kTokenMax + 1 = 33 bytes; `K` is one byte and
|
||||
// `X` (task 02) is one byte, so 64 is the whole legal range with headroom, and a frame
|
||||
// larger than it is refused by the codec (ws::Err::TooLong), never buffered -- which is
|
||||
// why a hostile claimant cannot make this board allocate: the buffer is static, sized
|
||||
// here, exactly one exists, and nothing grows it.
|
||||
constexpr size_t kClaimMsgCap = 64;
|
||||
|
||||
// A pending claim that has not asked for the bridge this long is dropped and its
|
||||
// session freed. 15 s is plenty for a human click, and an unanswered claim holding one
|
||||
// of this board's two TLS sessions open indefinitely is a nuisance attack: with the
|
||||
// ceiling at 2, one parked claim plus one lost controller is enough to lock the actual
|
||||
// operator out of the board. The anchor moves only on frames from the claim itself --
|
||||
// never on `_lastInboundMs`, the controller-silence clock (see control_server.cpp:
|
||||
// readSome(), and README bug 10 for why that distinction exists at all).
|
||||
constexpr uint32_t kClaimTimeoutMs = 15000;
|
||||
|
||||
} // namespace kr
|
||||
|
||||
+393
-26
@@ -66,6 +66,25 @@ constexpr char kBodyPlaintextToken[] =
|
||||
constexpr char kBodyRedirect[] =
|
||||
"keyrelay: this port redirects. Follow Location, or use https:// on this host\n";
|
||||
|
||||
// The 101 that switches either slot to WebSocket: the controller's and a pending
|
||||
// claim's are byte-identical, and they are built in ONE place on purpose --
|
||||
// tests/test_relayctl.py pins that this file contains exactly one literal HTTP status
|
||||
// line, because a second response writer is the drift invariant 8 forbids. The accept
|
||||
// hash is computed by the caller (ws::acceptFor, host-tested) because the two slots
|
||||
// answer a bad key differently: the controller slot says 400, the spare slot's claim
|
||||
// attempt is simply not claimable. False means the head would not fit or the socket
|
||||
// refused it; the caller owns the goodbye.
|
||||
bool pushUpgradeHead(Conn &conn, const char *accept) {
|
||||
char head[224];
|
||||
const int n = snprintf(head, sizeof(head),
|
||||
"HTTP/1.1 101 Switching Protocols\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Accept: %s\r\n\r\n",
|
||||
accept);
|
||||
return n > 0 && conn.push(reinterpret_cast<const uint8_t *>(head), (size_t)n);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ControlServer::begin(OnMessage onMessage, OnLinkChange onLinkChange,
|
||||
@@ -160,8 +179,12 @@ void ControlServer::acceptNew(uint32_t nowMs) {
|
||||
|
||||
if (admit == tls::Admit::Spare) {
|
||||
// The bridge is driven by somebody. The incumbent keeps the *keyboard*; this socket
|
||||
// is still allowed to look at the board. Only a /ws upgrade -- a controller claim --
|
||||
// is refused, and serveViewer() is what decides that.
|
||||
// may look at the board, and it may stand in this slot as a pending claim (phase 03
|
||||
// -- serveViewer/serveClaim decide, claim.h gates). Either way the occupant starts
|
||||
// from nothing: stopViewer() above resets the role/phase/auth/reader when the slot
|
||||
// was open, and saying it again here costs nothing and keeps the invariant visible
|
||||
// at the accept itself -- a claim that survived its own slot would be exactly the
|
||||
// bug this file keeps catching.
|
||||
if (_viewOpen) stopViewer(); // the spare is dead-but-flagged; settle it first
|
||||
_viewer = fresh;
|
||||
_viewer.setNoDelay(true);
|
||||
@@ -171,6 +194,11 @@ void ControlServer::acceptNew(uint32_t nowMs) {
|
||||
_viewerLen = 0;
|
||||
_viewerHttp[0] = '\0';
|
||||
_viewerAtMs = nowMs;
|
||||
_viewerRole = ViewerRole::Readonly;
|
||||
_viewerPhase = Phase::Http;
|
||||
_claimAuthed = false;
|
||||
_claimAtMs = 0;
|
||||
_claimReader.reset();
|
||||
return;
|
||||
}
|
||||
fresh.setNoDelay(true);
|
||||
@@ -407,14 +435,7 @@ void ControlServer::serveHttp(uint32_t nowMs) {
|
||||
respondAndClose(rsp);
|
||||
return;
|
||||
}
|
||||
char head[224];
|
||||
const int n = snprintf(head, sizeof(head),
|
||||
"HTTP/1.1 101 Switching Protocols\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Accept: %s\r\n\r\n",
|
||||
accept);
|
||||
if (n <= 0 || !_ctl.push(reinterpret_cast<const uint8_t *>(head), (size_t)n)) {
|
||||
if (!pushUpgradeHead(_ctl, accept)) {
|
||||
closeLink("handshake failed");
|
||||
return;
|
||||
}
|
||||
@@ -528,9 +549,14 @@ void ControlServer::serveReadonly(const ws::Request &req, BuiltResponse &rsp,
|
||||
// and close. This exists because the first version refused everything, which turned the
|
||||
// most ordinary gesture -- pressing reload on the page that already holds the bridge --
|
||||
// into a wall of text, and left a running controller with no way to be inspected.
|
||||
// Still refused here: a /ws upgrade, because that is a controller claim -- and on the
|
||||
// plaintext port it is refused as a transport question (426) before it is refused as a
|
||||
// slot question (503), so a client never learns "busy" when the answer is "TLS only".
|
||||
// A /ws upgrade on this slot is no longer a flat refusal: one newcomer at a time may be
|
||||
// accepted into it as a PENDING CLAIM (phase 03) -- same handshake, same passphrase,
|
||||
// and the whitelist in claim.h until it can do anything else. What is still refused:
|
||||
// a second upgrade on a slot that already carries a claim (503 -- the claim is not a
|
||||
// second controller either), and on the plaintext port the transport question (426)
|
||||
// still answers before any slot question, so a client never learns "busy" when the
|
||||
// answer is "TLS only". A third SOCKET never reaches this function at all: the slot
|
||||
// table refused it, counted in _slotRefused.
|
||||
void ControlServer::serveViewer(uint32_t nowMs) {
|
||||
if (!_view.attached()) { stopViewer(); return; }
|
||||
|
||||
@@ -543,6 +569,11 @@ void ControlServer::serveViewer(uint32_t nowMs) {
|
||||
|
||||
if (_viewAfter != AfterDrain::Nothing) return; // draining; pumpSlots owns it
|
||||
|
||||
// A slot whose upgrade completed speaks WebSocket, not HTTP, and what lives there is
|
||||
// a pending claim -- its frames, clocks and refusals are serveClaim's, never this
|
||||
// loop's.
|
||||
if (_viewerPhase == Phase::WebSocket) { serveClaim(nowMs); return; }
|
||||
|
||||
for (;;) {
|
||||
const size_t space = sizeof(_viewerHttp) - _viewerLen - 1;
|
||||
if (space == 0) {
|
||||
@@ -578,9 +609,9 @@ void ControlServer::serveViewer(uint32_t nowMs) {
|
||||
}
|
||||
|
||||
if (req.upgradeWebsocket) {
|
||||
_rejected++;
|
||||
#if KR_TLS
|
||||
if (!_view.isTls()) {
|
||||
_rejected++; // a controller claim refused for want of TLS: no room on this port
|
||||
BuiltResponse rsp;
|
||||
buildResponse(rsp, "426 Upgrade Required", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, kBodyPlaintextWs,
|
||||
@@ -589,14 +620,36 @@ void ControlServer::serveViewer(uint32_t nowMs) {
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// One controller. The incumbent keeps the bridge; the newcomer is told why -- over
|
||||
// either transport with the same 503, because the refusal to hand over the keyboard
|
||||
// is not a transport question either.
|
||||
BuiltResponse rsp;
|
||||
const char *busy = "keyrelay: another controller already holds this bridge.\n";
|
||||
buildResponse(rsp, "503 Service Unavailable", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, busy, strlen(busy));
|
||||
respondViewer(rsp);
|
||||
// One newcomer may stand here as a pending claim: the same accept handshake as
|
||||
// the controller path (same ws::acceptFor, same pushUpgradeHead -- one literal
|
||||
// 101 in this file, pinned), then the whitelist, then nothing it can do.
|
||||
// Refused -- with the 503 this slot has always answered, because the refusal to
|
||||
// hand over the keyboard is not a transport question either -- is anything that
|
||||
// cannot BE a claim: a malformed /ws upgrade, or a second upgrade on the one
|
||||
// slot that already carries one. `_rejected` means "no room": a controller claim
|
||||
// this board would not take. An ACCEPTED claim is not a rejection and never
|
||||
// touches the counter; the third SOCKET is a different counter entirely
|
||||
// (_slotRefused, at the slot table) and never gets this far.
|
||||
char accept[64];
|
||||
const bool claimable = strcmp(req.path, kPathWs) == 0 && req.wsKey[0] &&
|
||||
claim::acceptsUpgrade(_viewerRole) &&
|
||||
ws::acceptFor(req.wsKey, strlen(req.wsKey), accept,
|
||||
sizeof(accept));
|
||||
if (!claimable) {
|
||||
_rejected++;
|
||||
BuiltResponse rsp;
|
||||
const char *busy = "keyrelay: another controller already holds this bridge.\n";
|
||||
buildResponse(rsp, "503 Service Unavailable", "text/plain; charset=utf-8",
|
||||
BuiltResponse::Body::Text, busy, strlen(busy));
|
||||
respondViewer(rsp);
|
||||
return;
|
||||
}
|
||||
if (!pushUpgradeHead(_view, accept)) { stopViewer(); return; }
|
||||
_viewerRole = ViewerRole::Claim;
|
||||
_viewerPhase = Phase::WebSocket;
|
||||
_claimAuthed = false;
|
||||
_claimAtMs = nowMs; // the claim's clock starts here; never _lastInboundMs
|
||||
_claimReader.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -607,6 +660,260 @@ void ControlServer::serveViewer(uint32_t nowMs) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The pending claim: the WebSocket that lives on the spare slot once an upgrade
|
||||
// turned Readonly into Claim. Rules and whitelist: claim.h. Nothing below may
|
||||
// reach the target, the arming state, or the controller-silence clock -- the
|
||||
// strongest form of that sentence is structural: no claim frame is ever handed
|
||||
// to _onMessage, so main.cpp's command dispatcher cannot see one.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ControlServer::serveClaim(uint32_t nowMs) {
|
||||
// Two clocks, one anchor. Unauthenticated, the claim gets exactly the controller's
|
||||
// kAuthTimeoutMs; authenticated and not claiming, kClaimTimeoutMs (config.h: an
|
||||
// unanswered claim parked on one of the board's two TLS sessions is a nuisance
|
||||
// attack, and 15 s is plenty for a human click). Neither of them is _lastInboundMs
|
||||
// -- a claim attempt is not the operator being alive, which is readSome()'s comment
|
||||
// and bug 10; these lines exist precisely so that rule keeps holding while the door
|
||||
// is open.
|
||||
if (claim::expired(_claimAuthed, nowMs, _claimAtMs)) {
|
||||
if (!_claimAuthed) sendClaimError(kErrNotAuthed, "passphrase required");
|
||||
dropClaim();
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t chunk[64];
|
||||
for (;;) {
|
||||
// Directly on the Conn, the way the read-only viewer reads -- whoever is waiting
|
||||
// for the bridge is not whoever is typing.
|
||||
const size_t got = _view.read(chunk, sizeof(chunk));
|
||||
if (got == 0) return;
|
||||
for (size_t i = 0; i < got; i++) {
|
||||
const ws::In r = _claimReader.feed(chunk[i]);
|
||||
if (r == ws::In::NeedMore) continue;
|
||||
if (r == ws::In::Error) {
|
||||
// A frame the codec refuses is refused whole (TooLong means "larger than the
|
||||
// claim's buffer", which is a policy statement, not an accident) -- say so once
|
||||
// and drop, exactly like the controller treats a frame error, except that the
|
||||
// claimant has nothing to lose and the incumbent must not lose a tick to it.
|
||||
sendClaimError(kErrBadMessage, ws::describe(_claimReader.error()));
|
||||
dropClaim();
|
||||
return;
|
||||
}
|
||||
if (!handleClaimFrame(nowMs)) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ControlServer::handleClaimFrame(uint32_t nowMs) {
|
||||
// Freshness of the claim, refreshed by every frame it completes -- and it is the
|
||||
// only clock a claim may move. _lastInboundMs never appears in this function.
|
||||
_claimAtMs = nowMs;
|
||||
|
||||
const uint8_t opcode = _claimReader.opcode();
|
||||
if (opcode == ws::kOpPing) {
|
||||
// The same echo the controller gets: a browser keeps its sockets alive, and a pong
|
||||
// is the only answer a ping should have. Copy first -- the payload lives inside
|
||||
// _claimMsg, which is this reader's own buffer, and control payloads are capped at
|
||||
// 125 bytes by the codec, so both buffers here are small and bounded.
|
||||
uint8_t payload[128];
|
||||
uint8_t frame[136];
|
||||
const size_t plen =
|
||||
_claimReader.payloadLen() < sizeof(payload) ? _claimReader.payloadLen()
|
||||
: sizeof(payload);
|
||||
memcpy(payload, _claimReader.payload(), plen);
|
||||
_claimReader.reset();
|
||||
const size_t n = ws::encodeFrame(ws::kOpPong, payload, plen, frame, sizeof(frame));
|
||||
if (n && _view.alive() && !_view.push(frame, n)) _txDropped++;
|
||||
return true;
|
||||
}
|
||||
if (opcode == ws::kOpPong) { _claimReader.reset(); return true; }
|
||||
if (opcode == ws::kOpClose) { dropClaim(); return false; }
|
||||
|
||||
proto::Message msg;
|
||||
const proto::Cmd cmd = proto::parse((const char *)_claimReader.payload(),
|
||||
_claimReader.payloadLen(), msg);
|
||||
_claimReader.reset();
|
||||
|
||||
if (cmd == proto::Cmd::Bad) {
|
||||
sendClaimError(kErrBadMessage, "unrecognised command");
|
||||
dropClaim();
|
||||
return false;
|
||||
}
|
||||
|
||||
// One lockout for the board, not one per socket: the counters are the controller
|
||||
// path's own, so a claimant cannot burn attempts the incumbent would not pay for,
|
||||
// and while the lock is on the token is not even looked at.
|
||||
if (!_claimAuthed && _lockUntilMs != 0 && (int32_t)(nowMs - _lockUntilMs) < 0) {
|
||||
sendClaimError(kErrLockedOut, "too many failures, try later");
|
||||
dropClaim();
|
||||
return false;
|
||||
}
|
||||
|
||||
// THE WHITELIST, as one call to one list: claim::honours() (claim.h) refuses by name
|
||||
// A1/A0 (arm/disarm), S= (key state), C and Y= (chords, Ctrl+Alt+Del included), P=
|
||||
// (pasted text), M= (mouse), R (release -- a claim may not disturb the incumbent
|
||||
// either) and H (status), plus Bad and None. What it allows is T<token> always and,
|
||||
// once authenticated, K and X. The ordering below is load-bearing: the whitelist is
|
||||
// consulted BEFORE the Takeover branch, so `X` cannot reach handOverClaim (the one
|
||||
// path that sets `_authed = true` on this slot) without `_claimAuthed` having been
|
||||
// checked -- that is what "a claim cannot be promoted unauthenticated" means in
|
||||
// code, and tests/test_relayctl.py pins it. A refusal here is not a conversation:
|
||||
// the claim is dropped, the incumbent never notices, and no frame of this slot's
|
||||
// ever reaches _onMessage or arm().
|
||||
if (!claim::honours(_viewerRole, _claimAuthed, cmd)) {
|
||||
if (!_claimAuthed) sendClaimError(kErrNotAuthed, "authenticate first");
|
||||
else sendClaimError(kErrBadMessage, "a pending claim may not send that");
|
||||
dropClaim();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cmd == proto::Cmd::Auth) {
|
||||
// The same constant-time compare, the same shared failure counters, and the same
|
||||
// close-with-nothing on failure as the controller path -- and on success, the same
|
||||
// `OK` and nothing else: no status frame, no _controllerSeen, no link change, no
|
||||
// _lastInboundMs. The claim exists; the incumbent is untouched; the board is no
|
||||
// closer to armed than it was.
|
||||
const char *want = _getToken ? _getToken(_ctx) : "";
|
||||
if (!proto::constTimeEq(msg.text, strlen(msg.text), want, strlen(want))) {
|
||||
_authFails++;
|
||||
if (_authFails >= kTokenMaxFails) _lockUntilMs = nowMs + kTokenLockMs;
|
||||
sendClaimError(kErrNotAuthed, "wrong passphrase");
|
||||
dropClaim();
|
||||
return false;
|
||||
}
|
||||
_claimAuthed = true;
|
||||
_authFails = 0;
|
||||
_lockUntilMs = 0;
|
||||
sendClaimText("OK", 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cmd == proto::Cmd::Takeover) {
|
||||
// The one deliberate act. Everything the handover owes the incumbent -- the E12,
|
||||
// the funnel, the disarm -- happens inside handOverClaim(), in that order; the
|
||||
// claim slot ceases to exist there, so this returns false and serveClaim stops
|
||||
// reading a socket that has just become the controller's.
|
||||
handOverClaim(nowMs);
|
||||
return false;
|
||||
}
|
||||
|
||||
// KeepAlive is all the whitelist has left: freshness only, already recorded at the
|
||||
// top of this function.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bridge handover (`X` from an authenticated pending claim).
|
||||
*
|
||||
* NOTHING on this path arms the board, and that sentence is the reason the function is
|
||||
* named handOver and not takeover: it calls no arm(), sets no `armed`, and reaches
|
||||
* main.cpp only through the two _onLinkChange callbacks that already exist -- (false)
|
||||
* from closeLink, which is the release funnel itself (disarm + releaseEverything, bug
|
||||
* 10's fix, deliberately not re-narrowed here), and (true) after the adoption, which
|
||||
* only announces that a controller exists. The newcomer arms with an ordinary
|
||||
* authenticated `A1`, exactly like any controller that connected cold, or it never
|
||||
* arms. The order below is the safety property: the incumbent is told, the funnel
|
||||
* runs, the board is disarmed with every key and every mouse button released -- and
|
||||
* only THEN is the claimant adopted, so it can never inherit a held key or an armed
|
||||
* bridge. If the incumbent is already gone by the time `X` arrives, closeLink() is the
|
||||
* no-op it has always been on a dead slot and the same steps still run: one path.
|
||||
*
|
||||
* What the incumbent gets first is E12 ("you were dropped"), best-effort: an
|
||||
* unreadable incumbent must not block the handover, so the frame is queued and only
|
||||
* ever gets the same bounded shove the WS-close goodbye always got inside closeLink.
|
||||
* It is not counted as a rejection -- `_rejected` keeps meaning "no room", and this
|
||||
* socket found room; the separate `_takeovers` counter says what actually happened.
|
||||
*/
|
||||
void ControlServer::handOverClaim(uint32_t nowMs) {
|
||||
// 1. Tell the incumbent, best-effort, while its link is still up to carry it.
|
||||
sendError(kErrTakenOver, "session taken over");
|
||||
|
||||
// 2. The funnel, before anything is adopted. closeLink() sends the close frame and
|
||||
// the bounded shove, tears the transport down, clears _authed/_phase/_reader,
|
||||
// stamps the silence clock, and runs _onLinkChange(false) -- which in main.cpp
|
||||
// is disarm("controller disconnected") -> releaseEverything(). Anything the
|
||||
// incumbent left pressed is pressed no more, and the board lands DISARMED.
|
||||
closeLink("taken over");
|
||||
|
||||
// 3. Transplant the claimant's transport into the controller slot. One bounded pump
|
||||
// first (the resume pump of whatever the claim had queued -- an `OK` or a pong);
|
||||
// anything still queued after it is retired with the slot. Losing it cannot
|
||||
// strand state: the bytes a claim may have queued are its own `OK` and pongs,
|
||||
// and the claimant that just sent `X` has everything it needs to be a controller
|
||||
// -- it gets a fresh `OK` and status below.
|
||||
if (_view.attached()) _view.pump(kPageChunkBytes);
|
||||
#if KR_TLS
|
||||
if (_view.isTls()) {
|
||||
// The claim's half-written record (if any) belonged to the retiring Conn's queue;
|
||||
// the library resumes it from its own buffer on the next write (see
|
||||
// Session::forgetPendingWrite). Then swap the slot pointers, so the controller
|
||||
// slot NAMES the session object the claim was using -- no crypto state moves, and
|
||||
// the spare's pointer lands on the freshly-closed incumbent session, ready for
|
||||
// the next accept exactly as admitTls() expects.
|
||||
_tlsView->forgetPendingWrite();
|
||||
tls::Session *const wasSpare = _tlsView;
|
||||
_tlsView = _tlsCtl;
|
||||
_tlsCtl = wasSpare;
|
||||
_ctl.bindTls(_tlsCtl, _ctlTx, sizeof(_ctlTx));
|
||||
} else
|
||||
#endif
|
||||
{
|
||||
// Plaintext: NetworkClient is a shared handle, and `_client = _viewer` is the same
|
||||
// copy acceptNew() does with a freshly accepted socket. Clearing _viewer drops one
|
||||
// reference; the controller slot's copy keeps the fd open. This is also the only
|
||||
// shape a KR_TLS=0 rollback ever takes, which is why the claim needs no new
|
||||
// transport anywhere.
|
||||
_client = _viewer;
|
||||
_viewer = WiFiClient();
|
||||
_ctl.bindPlain(&_client, _ctlTx, sizeof(_ctlTx));
|
||||
}
|
||||
_ctlAfter = AfterDrain::Nothing;
|
||||
|
||||
// Adopt: exactly the state a freshly authenticated controller holds, and nothing
|
||||
// more. _lastInboundMs is stamped here for the same reason auth stamps it -- the
|
||||
// controller exists from here, and this is the fourth and last writer of that clock
|
||||
// (readSome, auth, closeLink, here; tests/test_relayctl.py counts them). Whatever
|
||||
// bytes the claimant pipelined behind `X` are now ordinary controller frames: it
|
||||
// holds the passphrase, so an immediate `A1` from it is an ordinary authenticated
|
||||
// arm, and nothing about the handover itself did anything the operator could not
|
||||
// have typed a second later.
|
||||
_phase = Phase::WebSocket;
|
||||
_authed = true;
|
||||
_controllerSeen = true;
|
||||
_lastInboundMs = millis();
|
||||
_connectedAtMs = nowMs;
|
||||
_lastStatusMs = nowMs;
|
||||
_lastPingMs = nowMs;
|
||||
_httpLen = 0;
|
||||
_reader.reset();
|
||||
_takeovers++;
|
||||
|
||||
// 4. Vacate the claim slot completely -- a stale claim must not be promotable twice,
|
||||
// and the spare must read as genuinely free to the next accept (this is exactly
|
||||
// what stopViewer() does for a dying slot; it cannot be called here because the
|
||||
// slot's transport is not being stopped, it was just handed to the controller).
|
||||
_view.unbind();
|
||||
_viewAfter = AfterDrain::Nothing;
|
||||
_viewOpen = false;
|
||||
_viewerLen = 0;
|
||||
_viewerHttp[0] = '\0';
|
||||
_viewerAtMs = 0;
|
||||
_viewerRole = ViewerRole::Readonly;
|
||||
_viewerPhase = Phase::None;
|
||||
_claimAuthed = false;
|
||||
_claimAtMs = 0;
|
||||
_claimReader.reset();
|
||||
memset(_claimMsg, 0, sizeof(_claimMsg)); // the payload buffer goes too: nothing a
|
||||
// future claim reads back is yesterday's
|
||||
|
||||
// The greeting a new controller gets, and the link change the rest of the firmware
|
||||
// recognises. Status is the board's, not a guess; arming is not in any of it.
|
||||
writeTextFrame("OK", 2);
|
||||
sendStatus();
|
||||
if (_onLinkChange) _onLinkChange(_ctx, true);
|
||||
}
|
||||
|
||||
void ControlServer::stopViewer() {
|
||||
if (_view.bound()) {
|
||||
_view.flushBeforeClose();
|
||||
@@ -617,6 +924,50 @@ void ControlServer::stopViewer() {
|
||||
_viewer = WiFiClient(); // the shared handle closes the fd when this last copy dies
|
||||
_viewOpen = false;
|
||||
_viewerLen = 0;
|
||||
// The claim dies with its slot: role, phase, auth and deadline all go back to
|
||||
// "read-only viewer", so the next occupant starts from nothing and a dropped claim
|
||||
// cannot be resurrected by a socket that inherits this slot. Releasing the Conn
|
||||
// above released the TLS session with it -- kTlsMaxSessions has one more slot, and
|
||||
// that is what "no leak over N attempts" means here: every path out of the spare
|
||||
// slot, timeout and silence included, arrives here.
|
||||
_viewerRole = ViewerRole::Readonly;
|
||||
_viewerPhase = Phase::None;
|
||||
_claimAuthed = false;
|
||||
_claimAtMs = 0;
|
||||
_claimReader.reset();
|
||||
}
|
||||
|
||||
// End a pending claim. Whatever was queued to be told to it (an E-frame, the 101) gets
|
||||
// ONE bounded shove out first -- the rule closeLink lives by: say what you mean, never
|
||||
// wait for a peer that is not reading -- then stopViewer() frees the slot, the session
|
||||
// and the claim's whole state.
|
||||
void ControlServer::dropClaim() {
|
||||
if (_view.attached()) {
|
||||
_view.pump(kPageChunkBytes);
|
||||
_view.flushBeforeClose();
|
||||
}
|
||||
stopViewer();
|
||||
}
|
||||
|
||||
void ControlServer::sendClaimText(const char *text, size_t len) {
|
||||
// Sized for the widest thing this sends (an `E<code>:<text>` line, 96 bytes, plus a
|
||||
// 4-byte frame header) rather than for the claim's own inbound cap -- a refusal that
|
||||
// silently does not fit because the claimant may not SEND big frames would be the
|
||||
// claimant's problem to misread as silence.
|
||||
uint8_t frame[112];
|
||||
const size_t n = ws::encodeFrame(ws::kOpText, text, len, frame, sizeof(frame));
|
||||
if (n == 0) return;
|
||||
if (!_view.alive()) return;
|
||||
// Whole frame or nothing, like every other write on either transport: a dropped
|
||||
// refusal is a socket that got told nothing and gets dropped anyway, a half-sent
|
||||
// one is a corrupted stream.
|
||||
if (!_view.push(frame, n)) _txDropped++;
|
||||
}
|
||||
|
||||
void ControlServer::sendClaimError(uint8_t code, const char *text) {
|
||||
char line[96];
|
||||
const size_t len = proto::formatError(line, sizeof(line), code, text);
|
||||
if (len) sendClaimText(line, len);
|
||||
}
|
||||
|
||||
void ControlServer::serveWebSocket(uint32_t nowMs) {
|
||||
@@ -745,6 +1096,13 @@ void ControlServer::sendError(uint8_t code, const char *text) {
|
||||
if (len) writeTextFrame(line, len);
|
||||
}
|
||||
|
||||
void ControlServer::sendOk() {
|
||||
// The protocol's "accepted". Only ever meaningful on a live controller link -- a
|
||||
// claim gets its OK from the claim path (sendClaimText), never from here.
|
||||
if (_phase != Phase::WebSocket || !_authed) return;
|
||||
writeTextFrame("OK", 2);
|
||||
}
|
||||
|
||||
void ControlServer::closeLink(const char *why) {
|
||||
(void)why; // named for the readers of this file, not printed: the CDC ring truncates
|
||||
// "A controller could have left keys down on the target" is the question here, and the
|
||||
@@ -863,23 +1221,32 @@ void ControlServer::admitTls(uint32_t nowMs) {
|
||||
// _viewerHttp/_viewerLen state, or the previous viewer's half-written response
|
||||
// would be pumped to the new one.
|
||||
stopViewer();
|
||||
if (!_tlsView.open(fd, *_identity)) { // open consumed the fd either way
|
||||
if (!_tlsView->open(fd, *_identity)) { // open consumed the fd either way
|
||||
_slotRefused++;
|
||||
return;
|
||||
}
|
||||
_view.bindTls(&_tlsView, _viewTx, sizeof(_viewTx));
|
||||
_view.bindTls(_tlsView, _viewTx, sizeof(_viewTx));
|
||||
_viewOpen = true;
|
||||
_viewerLen = 0;
|
||||
_viewerHttp[0] = '\0';
|
||||
_viewerAtMs = nowMs;
|
||||
// Fresh occupant, fresh state -- the same belt-and-braces as the plaintext accept:
|
||||
// stopViewer() above already reset role/phase/auth/reader, and naming it again at
|
||||
// the accept is what keeps "a claim never outlives its slot" visible on both
|
||||
// transports rather than true by inheritance.
|
||||
_viewerRole = ViewerRole::Readonly;
|
||||
_viewerPhase = Phase::Http;
|
||||
_claimAuthed = false;
|
||||
_claimAtMs = 0;
|
||||
_claimReader.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_tlsCtl.open(fd, *_identity)) { // open consumed the fd either way
|
||||
if (!_tlsCtl->open(fd, *_identity)) { // open consumed the fd either way
|
||||
_slotRefused++;
|
||||
return;
|
||||
}
|
||||
_ctl.bindTls(&_tlsCtl, _ctlTx, sizeof(_ctlTx));
|
||||
_ctl.bindTls(_tlsCtl, _ctlTx, sizeof(_ctlTx));
|
||||
_ctlAfter = AfterDrain::Nothing;
|
||||
_authed = false;
|
||||
_httpLen = 0;
|
||||
|
||||
+90
-10
@@ -3,12 +3,21 @@
|
||||
* over a plaintext socket or a TLS session -- into one controller slot.
|
||||
*
|
||||
* Shape of it:
|
||||
* - one controller at a time. A second WebSocket upgrade is refused outright rather
|
||||
* than queued or swapped silently, because two controllers typing into one machine
|
||||
* is exactly the ambiguity a keyboard must not have. The page says so (E3).
|
||||
* Read-only HTTP is *not* 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).
|
||||
* - 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
|
||||
@@ -37,6 +46,7 @@
|
||||
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "claim.h"
|
||||
#include "config.h"
|
||||
#include "conn.h"
|
||||
#include "proto.h"
|
||||
@@ -63,6 +73,11 @@ enum ErrorCode : uint8_t {
|
||||
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 {
|
||||
@@ -82,6 +97,9 @@ class ControlServer {
|
||||
// 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; }
|
||||
/**
|
||||
@@ -96,6 +114,11 @@ class ControlServer {
|
||||
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.
|
||||
@@ -115,9 +138,11 @@ class ControlServer {
|
||||
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).
|
||||
// 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 (_tlsCtl.alive() ? 1u : 0u) + (_tlsView.alive() ? 1u : 0u);
|
||||
return (_tlsStore[0].alive() ? 1u : 0u) + (_tlsStore[1].alive() ? 1u : 0u);
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -168,6 +193,18 @@ class ControlServer {
|
||||
// 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);
|
||||
@@ -217,6 +254,40 @@ class ControlServer {
|
||||
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)};
|
||||
|
||||
@@ -231,6 +302,7 @@ class ControlServer {
|
||||
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
|
||||
@@ -240,8 +312,16 @@ class ControlServer {
|
||||
int _tlsListen = -1;
|
||||
const tls::PemPair *_identity = nullptr;
|
||||
|
||||
tls::Session _tlsCtl; // the controller slot, when _ctl.isTls()
|
||||
tls::Session _tlsView; // the spare slot, when _view.isTls()
|
||||
// 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;
|
||||
|
||||
+22
-2
@@ -712,6 +712,13 @@ void fillStatus(void *, kr::StatusSnapshot &s) {
|
||||
s.mouseFrames = mouseFrames;
|
||||
s.defaultToken = strcmp(token, kr::kDefaultToken) == 0;
|
||||
s.apForced = apMode == 1;
|
||||
// The bridge half: "a controller holds the keyboard" and a count of handovers. A
|
||||
// link state and a number -- no address, device or timing anywhere near them
|
||||
// (invariant 4; the README's never-stored list names this temptation exactly).
|
||||
// To a /status poller busy:1 means someone else is driving; to the adopted
|
||||
// controller it means itself, and the page is the only thing that knows which.
|
||||
s.busy = control.linked();
|
||||
s.takeovers = control.takeovers();
|
||||
// The TLS half. `tls` describes the identity this board holds, not the link this frame is
|
||||
// travelling on -- the listener is later in phase 02, and a controller running over
|
||||
// plaintext must not be told otherwise by a field in the frame it received in clear.
|
||||
@@ -809,6 +816,17 @@ void onMessage(void *, const proto::Message &msg) {
|
||||
case proto::Cmd::KeepAlive:
|
||||
return; // freshness is already recorded
|
||||
|
||||
case proto::Cmd::Takeover:
|
||||
// `X` from the controller that already holds the bridge: there is nothing to
|
||||
// take, and this is a no-op -- answered `OK`, not `E6`, because the frame means
|
||||
// "I hold this" and it does. The handover itself lives in
|
||||
// control_server.cpp::handOverClaim, reachable only from a pending claim socket;
|
||||
// a controller cannot use `X` to reset or re-arm its own session, and nothing
|
||||
// here calls setArmed: the only frames that arm are A1 (this dispatcher) and the
|
||||
// board's own button and console.
|
||||
control.sendOk();
|
||||
return;
|
||||
|
||||
case proto::Cmd::Arm:
|
||||
setArmed(true, "controller");
|
||||
return;
|
||||
@@ -1114,9 +1132,11 @@ void printStatus() {
|
||||
char json[kr::kStatusJsonCap];
|
||||
kr::statusJson(s, json, sizeof(json));
|
||||
Serial.printf("status %s\n", json);
|
||||
Serial.printf("link controller=%s rejected=%u authfails=%u\n",
|
||||
// `taken=` is a count of completed handovers, not an identity: who took the bridge
|
||||
// is not something this board knows, stores, or may be made to print (invariant 4).
|
||||
Serial.printf("link controller=%s rejected=%u authfails=%u taken=%u\n",
|
||||
control.linked() ? "yes" : "no", (unsigned)control.rejected(),
|
||||
(unsigned)control.authFails());
|
||||
(unsigned)control.authFails(), (unsigned)control.takeovers());
|
||||
Serial.printf("queue %u keys pending, %u held, %s\n",
|
||||
(unsigned)stage.keyQ.count(), (unsigned)stage.keys.held(),
|
||||
stage.keys.rollover() ? "ROLLOVER" : "ok");
|
||||
|
||||
+19
@@ -22,6 +22,11 @@
|
||||
* c -> d R release every key AND every mouse button
|
||||
* (kept armed)
|
||||
* c -> d K keepalive
|
||||
* c -> d X claim this bridge for myself; requires
|
||||
* authentication, and nothing else is honoured
|
||||
* on a claim socket (phase 03 -- the handover
|
||||
* runs the release funnel and lands DISARMED;
|
||||
* from the incumbent controller it is a no-op)
|
||||
* d -> c OK accepted, or the reply to H
|
||||
* d -> c E<code>:<text> error, e.g. E4:arm required
|
||||
* d -> c S{json} status heartbeat, 1 Hz and on change
|
||||
@@ -70,6 +75,7 @@ enum class Cmd : uint8_t {
|
||||
Mouse, // one mouse report: button byte plus movement
|
||||
ReleaseAll,
|
||||
KeepAlive,
|
||||
Takeover, // `X`: the pending claim asks for the bridge (phase 03)
|
||||
Bad,
|
||||
};
|
||||
|
||||
@@ -112,6 +118,7 @@ inline const char *name(Cmd c) {
|
||||
case Cmd::Mouse: return "mouse";
|
||||
case Cmd::ReleaseAll: return "release-all";
|
||||
case Cmd::KeepAlive: return "keepalive";
|
||||
case Cmd::Takeover: return "takeover";
|
||||
default: return "bad";
|
||||
}
|
||||
}
|
||||
@@ -291,6 +298,15 @@ inline Cmd parse(const char *data, size_t len, Message &out) {
|
||||
if (len != 1) return out.cmd = Cmd::Bad;
|
||||
return out.cmd = Cmd::SecureAttention;
|
||||
|
||||
case 'X':
|
||||
// One deliberate act on a claim socket (claim.h whitelists it there; the handover
|
||||
// itself lives in control_server.cpp::handOverClaim and cannot arm). Trailing
|
||||
// bytes are Bad rather than "X plus noise": one frame is one command, and a
|
||||
// frame that would silently carry a second intent is the failure this protocol
|
||||
// keeps refusing to have.
|
||||
if (len != 1) return out.cmd = Cmd::Bad;
|
||||
return out.cmd = Cmd::Takeover;
|
||||
|
||||
case 'A':
|
||||
if (len != 2) return out.cmd = Cmd::Bad;
|
||||
if (data[1] == '1') return out.cmd = Cmd::Arm;
|
||||
@@ -475,6 +491,9 @@ inline size_t formatText(char *out, size_t cap, uint16_t periodMs,
|
||||
// 8 text buffer full 9 a character this keyboard map cannot type
|
||||
// 10 the mouse interface is off on this board
|
||||
// 11 the target asked for boot protocol, so a mouse report cannot be sent
|
||||
// 12 your session was taken over by another controller (you were DROPPED, which is
|
||||
// a different sentence from 3's "you were refused" -- one of them should be
|
||||
// alarming on the incumbent's screen and the other should not)
|
||||
//
|
||||
// The code is written in decimal at full width -- `code % 10` truncated 10 to `E0`,
|
||||
// which is a code no client would recognise. Codes below 10 render byte-identically
|
||||
|
||||
+12
-1
@@ -66,6 +66,15 @@ struct StatusSnapshot {
|
||||
uint8_t ledState = 0; // the target's caps/num/scroll LEDs, per its HID reports
|
||||
bool defaultToken = false;
|
||||
bool apForced = false;
|
||||
// The bridge, in the only terms the board is allowed to know about it: `busy` is
|
||||
// "a controller holds the keyboard" (the link state), `takeovers` counts completed
|
||||
// handovers. No address, no user agent, no timing that would identify the other
|
||||
// session -- the README's never-stored list is about exactly this temptation, and
|
||||
// it is what lets the page say "held by another controller" without saying who.
|
||||
// A /status poller reads busy:1 as "someone is driving"; the adopted controller
|
||||
// reads the same 1 about itself, and only the page knows which one it is.
|
||||
bool busy = false;
|
||||
uint32_t takeovers = 0;
|
||||
// The TLS identity: `tls` is true when this build compiled TLS in *and* the board holds
|
||||
// a keypair-plus-certificate that passed its boot-time check -- the identity the :443
|
||||
// listener serves. It is still a statement about the identity, not about the transport
|
||||
@@ -129,13 +138,15 @@ inline size_t statusJson(const StatusSnapshot &s, char *out, size_t cap) {
|
||||
"{\"v\":\"%s\",\"up\":%lu,\"usb\":%u,\"armed\":%u,\"net\":\"%s\","
|
||||
"\"ssid\":\"%s\",\"ip\":\"%s\",\"keys\":%lu,\"chords\":%lu,\"drop\":%lu,"
|
||||
"\"roll\":%u,\"leds\":%u,\"dflt\":%u,\"ap\":%u,\"typed\":%lu,\"typing\":%u,"
|
||||
"\"mouse\":%u,\"mbtn\":%u,\"mframes\":%lu,\"tls\":%u,\"fp\":\"%s\"}",
|
||||
"\"mouse\":%u,\"mbtn\":%u,\"mframes\":%lu,\"busy\":%u,\"tkov\":%lu,"
|
||||
"\"tls\":%u,\"fp\":\"%s\"}",
|
||||
s.version, (unsigned long)s.uptimeS, s.usbHost ? 1u : 0u, s.armed ? 1u : 0u,
|
||||
s.net, ssid, s.ip, (unsigned long)s.keysSent, (unsigned long)s.chordsSent,
|
||||
(unsigned long)s.dropped, s.rollover ? 1u : 0u, (unsigned)s.ledState,
|
||||
s.defaultToken ? 1u : 0u, s.apForced ? 1u : 0u,
|
||||
(unsigned long)s.typedChars, (unsigned)s.typing,
|
||||
s.mouse ? 1u : 0u, (unsigned)s.mouseButtons, (unsigned long)s.mouseFrames,
|
||||
s.busy ? 1u : 0u, (unsigned long)s.takeovers,
|
||||
s.tls ? 1u : 0u, fp);
|
||||
return (n < 0 || (size_t)n >= cap) ? 0 : (size_t)n;
|
||||
}
|
||||
|
||||
+1
-1
@@ -641,7 +641,7 @@ inline size_t sanExtensionDer(uint8_t *out, size_t cap) {
|
||||
// extension is the problem. That is how this shipped once: `openssl x509 -text` on the
|
||||
// live board's certificate showed `1.10.46.53.46.50.57.46.49.55` where `X509v3 Subject
|
||||
// Alternative Name` should have been, and every host test stayed green because they walked
|
||||
// the value and the name table and never the label. See README "Eleven bugs", item 11.
|
||||
// the value and the name table and never the label. See README "Thirteen bugs", item 11.
|
||||
constexpr uint8_t kSanOidTlv[] = {0x06, 0x03, 0x55, 0x1D, 0x11};
|
||||
constexpr size_t kSanOidTlvLen = sizeof(kSanOidTlv);
|
||||
|
||||
|
||||
@@ -319,6 +319,27 @@ class Session {
|
||||
// ::close(fd). Idempotent -- safe from the destructor and from any error path.
|
||||
void close();
|
||||
|
||||
/**
|
||||
* Forget the resume bookkeeping of a half-finished write, leaving the connection
|
||||
* otherwise untouched. Used by exactly one caller: the bridge handover
|
||||
* (control_server.cpp::handOverClaim), where the bytes that were in flight belonged
|
||||
* to a Conn whose queue is being retired with the claim slot, so no caller will ever
|
||||
* re-hand them -- and without this, the resume guard below would refuse every write
|
||||
* from the adopted controller forever, wedging its outbound path.
|
||||
*
|
||||
* Why this cannot corrupt the TLS stream: the half-sent RECORD lives in mbed TLS's
|
||||
* own output buffer (ssl->out_buf/out_left), not in the caller's, and
|
||||
* mbedtls_ssl_write() flushes that buffer before it touches new arguments -- the
|
||||
* record already half on the wire completes byte-identically from the library's copy.
|
||||
* The guard here is stricter than the library requires (it exists to keep kr::Conn's
|
||||
* resume bookkeeping simple), so relaxing it at a slot transplant is relaxing our own
|
||||
* rule, not breaking the library's.
|
||||
*/
|
||||
void forgetPendingWrite() {
|
||||
_pendPtr = nullptr;
|
||||
_pendLen = 0;
|
||||
}
|
||||
|
||||
// The library code of the last failure this session hit; diagnostic, never identity.
|
||||
int lastError() const { return _lastError; }
|
||||
|
||||
|
||||
+169
-17
@@ -22,6 +22,15 @@
|
||||
* and cancels a run of pasted text that is mid-flight. A keyboard that keeps
|
||||
* typing after you tabbed away is the failure mode this design cannot be allowed
|
||||
* to have.
|
||||
* - A session left holding the bridge can be taken over from the screen that is
|
||||
* locked out, not only from whichever tab forgot to disconnect. The TAKE OVER
|
||||
* button appears only when the board says it is held -- by a claim the board
|
||||
* authenticated and refused to adopt, by a busy status frame, or by E12, the
|
||||
* frame that says THIS session was displaced. One click sends T then X and
|
||||
* nothing else; the handover runs through the release funnel and lands
|
||||
* DISARMED, and the page that lost the bridge stops reconnecting until someone
|
||||
* clicks again -- an auto-reclaiming page turns two tabs into a fight over a
|
||||
* keyboard.
|
||||
* - The mouse is captured, never grabbed. "Capture the pointer" asks the browser for
|
||||
* Pointer Lock -- the same thing a web game asks for -- so the cursor cannot escape
|
||||
* the tab while the board is forwarding it, and Escape always gives it back. Capture
|
||||
@@ -222,6 +231,7 @@ body.off{opacity:.92}
|
||||
<div class="row">
|
||||
<input type="password" id="token" placeholder="passphrase" autocomplete="off">
|
||||
<button class="k" id="connect">connect</button>
|
||||
<button class="k danger" id="takeover" hidden title="Appears only when the board says another controller holds the bridge. One deliberate click: authenticate, then ask for the bridge (X). The other session is told (E12), every key and every mouse button is released, and the board comes back DISARMED — you arm again on purpose.">take over</button>
|
||||
</div>
|
||||
<label class="opt" style="margin-top:9px">
|
||||
<input type="checkbox" id="keepArmed">
|
||||
@@ -230,6 +240,10 @@ body.off{opacity:.92}
|
||||
<p style="color:var(--dim);font-size:11.5px;margin:8px 0 0">
|
||||
The passphrase is checked by the board on every connection. Without it nothing
|
||||
can be armed, so anyone else on the network gets a page that does nothing.
|
||||
Someone who <b>has</b> it can take the bridge from a session left connected
|
||||
— that is what <b>take over</b> is for — and cannot arm it by doing
|
||||
so: every handover lands disarmed, with the other session’s keys and
|
||||
buttons released and its screen told.
|
||||
</p>
|
||||
<p class="hint" id="certFp" hidden></p>
|
||||
</div>
|
||||
@@ -465,6 +479,35 @@ function describePress(code) {
|
||||
* ------------------------------------------------------------------ */
|
||||
let ws = null, armed = false, authed = false, reconnectAt = 0, status = null;
|
||||
|
||||
/* Facts about the link that are not the socket object itself.
|
||||
* gotOk / gotStatus what THIS socket has been told: a controller gets `OK`
|
||||
* and a status frame immediately; a pending claim (phase 03,
|
||||
* the board held by someone else) gets `OK` and no status --
|
||||
* the status frame belongs to the controller alone. "OK then
|
||||
* never adopted" is therefore the board reporting `busy` in
|
||||
* the only terms it gives a socket that does not hold the
|
||||
* bridge.
|
||||
* busyKnown the board has said a controller holds the bridge: by not
|
||||
* adopting us, by E12, or by a status frame saying busy while
|
||||
* we are not the controller.
|
||||
* takenOver our own session was displaced by another controller (E12).
|
||||
* autoReconnect whether the 2 s loop may reconnect at all. Turned off by
|
||||
* exactly those two facts: a page that immediately re-claims
|
||||
* turns two tabs into a fight over a keyboard. It comes back
|
||||
* on the next deliberate click (connect, or take over).
|
||||
* pendingTakeover this socket was opened by the take-over button; its first
|
||||
* status frame is the moment to say what happened. */
|
||||
let gotOk = false, gotStatus = false, busyKnown = false, takenOver = false;
|
||||
let autoReconnect = true, pendingTakeover = false;
|
||||
|
||||
function setPill(cls, text) {
|
||||
const p = $("pLink");
|
||||
p.className = "pill " + cls;
|
||||
p.textContent = text;
|
||||
}
|
||||
|
||||
function showTakeover(on) { $("takeover").hidden = !on; }
|
||||
|
||||
/* The TLS build's page on an http: origin is a page nobody legitimate served: a
|
||||
* KR_TLS=1 board answers :80 with a redirect and never with these bytes, so an
|
||||
* http: tab is a cache, a downgrade, or somebody else's server. Refuse before the
|
||||
@@ -482,7 +525,21 @@ function tokenValue() {
|
||||
return t;
|
||||
}
|
||||
|
||||
function connect() {
|
||||
function connect() { openSession(false); }
|
||||
|
||||
/**
|
||||
* One socket builder for both gestures: ordinary connect, and the deliberate
|
||||
* take-over. `takeover` means: after the passphrase send `X` and nothing else
|
||||
* -- no H, no state, no keepalive -- because one frame is one command and one
|
||||
* click is one intent. Before the click nothing about the bridge is sent; after
|
||||
* it the page is an ordinary controller, starting DISARMED, because the handover
|
||||
* itself lands disarmed (control_server.cpp handOverClaim) and only an `A1` typed
|
||||
* from here on arms. `H` on an ordinary socket doubles as the probe that says
|
||||
* which one we are: a controller answers it with a status frame, the board's one
|
||||
* pending-claim slot answers it with E6 and a drop -- and the drop is what
|
||||
* onGone() reads as "held by another controller".
|
||||
*/
|
||||
function openSession(takeover) {
|
||||
if (servedInClear()) {
|
||||
log("this page arrived in the clear; reload over https", "e");
|
||||
return;
|
||||
@@ -490,21 +547,69 @@ function connect() {
|
||||
const tok = tokenValue();
|
||||
if (!tok) { log("enter the board's passphrase first", "w"); return; }
|
||||
sessionStorage.setItem("keyrelay.token", tok);
|
||||
showTakeover(false);
|
||||
if (ws) { try { ws.close(); } catch (e) {} ws = null; }
|
||||
const url = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/ws";
|
||||
log("connecting to " + location.host);
|
||||
log(takeover ? "taking the bridge on " + location.host : "connecting to " + location.host);
|
||||
gotOk = false; gotStatus = false;
|
||||
pendingTakeover = takeover;
|
||||
takenOver = false;
|
||||
autoReconnect = true; // a deliberate click restarts the ordinary behaviour
|
||||
ws = new WebSocket(url);
|
||||
ws.onopen = () => { send("T" + tok); send("H"); authed = false; };
|
||||
ws.onmessage = ev => onFrame(ev.data);
|
||||
ws.onclose = () => {
|
||||
authed = false; setArmed(false, "link lost");
|
||||
$("pLink").className = "pill bad"; $("pLink").textContent = "link down";
|
||||
log("link down -- retrying in 2s", "e");
|
||||
reconnectAt = Date.now() + 2000;
|
||||
ws.onopen = () => {
|
||||
send("T" + tok);
|
||||
if (takeover) send("X"); // claim the bridge: T then X, in that order, nothing else
|
||||
else send("H");
|
||||
authed = false;
|
||||
};
|
||||
ws.onmessage = ev => onFrame(ev.data);
|
||||
ws.onclose = onGone;
|
||||
ws.onerror = () => { /* onclose follows and does the work */ };
|
||||
}
|
||||
|
||||
/* The socket went -- and which sentence is true depends on what the board said
|
||||
* before it did. This is where a refused or un-adopted upgrade stops being a
|
||||
* 2-second retry loop: a page that keeps re-claiming against a controller that
|
||||
* is not coming back just occupies the board's one claim slot, so the page comes
|
||||
* down and waits for one deliberate click instead. */
|
||||
function onGone() {
|
||||
authed = false;
|
||||
if (takenOver) {
|
||||
/* E12 already said the full sentence and disarmed everything; the pill keeps
|
||||
* the fact after the close, and no reconnect follows. */
|
||||
busyKnown = true;
|
||||
setPill("bad", "taken over");
|
||||
showTakeover(true);
|
||||
return;
|
||||
}
|
||||
setArmed(false, "link lost");
|
||||
if (gotOk && !gotStatus) {
|
||||
/* Authenticated, never adopted: the board put us in its spare slot as a
|
||||
* pending claim, which IS the board reporting busy -- a controller holds the
|
||||
* keyboard and the status frame that would have said so belongs to it alone.
|
||||
* Counter-and-link-state terms only: we are told that it exists, never who. */
|
||||
busyKnown = true;
|
||||
setPill("bad", "held by another controller");
|
||||
showTakeover(true);
|
||||
autoReconnect = false;
|
||||
log("another controller holds this bridge -- press TAKE OVER to take it. The "
|
||||
+ "handover releases every key and every mouse button and lands DISARMED.", "w");
|
||||
return;
|
||||
}
|
||||
if (busyKnown && !gotStatus) {
|
||||
busyKnown = true;
|
||||
setPill("bad", "held by another controller");
|
||||
showTakeover(true);
|
||||
autoReconnect = false;
|
||||
log("still held by another controller -- this tab stays down until you press "
|
||||
+ "TAKE OVER", "w");
|
||||
return;
|
||||
}
|
||||
setPill("bad", "link down");
|
||||
log("link down -- retrying in 2s", "e");
|
||||
reconnectAt = Date.now() + 2000;
|
||||
}
|
||||
|
||||
function send(text) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) { ws.send(text); return true; }
|
||||
return false;
|
||||
@@ -518,14 +623,41 @@ function pushState() {
|
||||
}
|
||||
|
||||
function onFrame(raw) {
|
||||
if (raw[0] === "S") { try { applyStatus(JSON.parse(raw.slice(1))); } catch (e) {} return; }
|
||||
if (raw === "OK") { if (!authed) { authed = true; $("pLink").className = "pill on";
|
||||
$("pLink").textContent = "linked"; log("authenticated", "g"); } return; }
|
||||
if (raw[0] === "S") {
|
||||
let s = null;
|
||||
try { s = JSON.parse(raw.slice(1)); } catch (e) {}
|
||||
if (s) { gotStatus = true; applyStatus(s); }
|
||||
return;
|
||||
}
|
||||
if (raw === "OK") { gotOk = true; if (!authed) { authed = true; setPill("on", "linked");
|
||||
log("authenticated", "g"); } return; }
|
||||
if (raw[0] === "E") {
|
||||
const code = raw[1], text = raw.slice(3);
|
||||
if (code === "1") { authed = false; $("pLink").className = "pill bad";
|
||||
$("pLink").textContent = "no passphrase"; }
|
||||
else if (code === "3") setArmed(false, "another controller took over");
|
||||
/* The code runs to the ':', not one character: E10, E11 and E12 are two digits,
|
||||
* and a single-char read turned them into "1" -- telling a perfectly authenticated
|
||||
* page that its passphrase had failed, and hiding the takeover (E12) the dropped
|
||||
* operator most needs to read. The board renders codes at full width. */
|
||||
const c = raw.indexOf(":");
|
||||
const code = raw.slice(1, c > 0 ? c : undefined), text = c > 0 ? raw.slice(c + 1) : "";
|
||||
if (code === "1") { authed = false; setPill("bad", "no passphrase"); }
|
||||
else if (code === "3") {
|
||||
/* E3 is "you were REFUSED": the board has no room for a second claim right
|
||||
* now. It is not E12 -- this page never lost a session it held -- and the
|
||||
* distinction is the whole reason the two codes are separate. */
|
||||
setArmed(false, "no room for a claim: another controller holds the bridge");
|
||||
}
|
||||
else if (code === "12") {
|
||||
/* E12 is "you were DROPPED": another controller took this bridge, and it
|
||||
* arrived through the release funnel -- the board has already released every
|
||||
* key and every mouse button and is DISARMED. The local mirror is the same
|
||||
* gesture: setArmed(false) ends the typing run, the keys and the mouse here,
|
||||
* and the log carries the reason the operator of this screen needs to read.
|
||||
* Then this page stays down; auto-reclaiming is how two tabs become a fight,
|
||||
* and coming back is one deliberate click on TAKE OVER. */
|
||||
takenOver = true; busyKnown = true; autoReconnect = false;
|
||||
setArmed(false, "another controller took over");
|
||||
setPill("bad", "taken over");
|
||||
showTakeover(true);
|
||||
}
|
||||
// 4 not armed, 5 no target, 7 released and disarmed, 8 buffer full, 9 untypeable:
|
||||
// every one of those means the rest of a run must not be offered to the board.
|
||||
if (typing && "45789".includes(code)) abortTyping("board said E" + code);
|
||||
@@ -558,6 +690,21 @@ function paintChannel(s) {
|
||||
function applyStatus(s) {
|
||||
status = s;
|
||||
paintChannel(s);
|
||||
if (pendingTakeover) {
|
||||
/* The frame the handover promised: this page asked for the bridge with one
|
||||
* click, the board ran the funnel, and what it says now is the truth about
|
||||
* the state we start in -- DISARMED. Saying it out loud is the difference
|
||||
* between "it works again" and "it works again, from zero". */
|
||||
pendingTakeover = false;
|
||||
log("bridge taken over -- the board is DISARMED; arm again on purpose", "g");
|
||||
}
|
||||
// The link pill's third honest state, for a page that is not the controller --
|
||||
// e.g. a frame still in flight when this session was displaced. `busy` is a
|
||||
// link state, never an identity: the page says "someone holds it", not who.
|
||||
if (!authed && s.busy) {
|
||||
busyKnown = true;
|
||||
setPill("bad", "held by another controller");
|
||||
}
|
||||
$("ver").textContent = "v" + (s.v || "?");
|
||||
$("sMode").textContent = s.net === "sta" ? "station" : (s.net === "ap" ? "access point" : "down");
|
||||
$("sIp").textContent = s.ip || "\u2014";
|
||||
@@ -1169,6 +1316,7 @@ $("logClear").addEventListener("click", () => { logEl.textContent = ""; });
|
||||
|
||||
$("connect").addEventListener("click", connect);
|
||||
$("token").addEventListener("keydown", ev => { if (ev.key === "Enter") connect(); });
|
||||
$("takeover").addEventListener("click", () => openSession(true));
|
||||
$("keepArmed").addEventListener("change", () => {
|
||||
log("stay-armed-on-blur " + ($("keepArmed").checked ? "ON (not recommended)" : "off"));
|
||||
});
|
||||
@@ -1176,7 +1324,11 @@ $("keepArmed").addEventListener("change", () => {
|
||||
/* Reconnect loop, and a keepalive so the board can tell a sleeping laptop from a
|
||||
* living one (it disarms on silence, see config.h kNoClientTimeoutMs). */
|
||||
setInterval(() => {
|
||||
if ((!ws || ws.readyState !== WebSocket.OPEN) && Date.now() >= reconnectAt && tokenValue()) connect();
|
||||
// autoReconnect is off exactly when the board has said another controller holds
|
||||
// the bridge (claim refused, or E12): the fix is one deliberate click, not a
|
||||
// loop that re-claims the slot and turns two tabs into a fight.
|
||||
if (autoReconnect && (!ws || ws.readyState !== WebSocket.OPEN) &&
|
||||
Date.now() >= reconnectAt && tokenValue()) connect();
|
||||
if (ws && ws.readyState === WebSocket.OPEN) send("K");
|
||||
}, 2000);
|
||||
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ images=(
|
||||
)
|
||||
|
||||
suites=(test_keystate test_proto test_text test_mouse test_output test_ws_codec
|
||||
test_tls_pem test_tls_session test_conn)
|
||||
test_tls_pem test_tls_session test_conn test_claim)
|
||||
|
||||
build_and_run() {
|
||||
local cxx="$1"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// tests for the pending takeover claim: tests/test_claim.cpp
|
||||
//
|
||||
// Phase 03 task 01 opens one door a crack: the spare socket may now complete the
|
||||
// WebSocket handshake and authenticate as a *pending claim* instead of getting a 503.
|
||||
// Nothing else is on the wire yet, so everything that decides what that socket is
|
||||
// allowed to be -- which role, which commands, which clock -- lives in src/claim.h,
|
||||
// and this suite walks it: the whitelist as a matrix (every Cmd, both auth states,
|
||||
// both roles), the deadline arithmetic on both sides of the millis() rollover, and
|
||||
// the claim's buffer against the widest frame it may receive -- refused, not
|
||||
// buffered, measured through the real codec rather than asserted from the constant.
|
||||
#include <string.h>
|
||||
|
||||
#include "claim.h"
|
||||
#include "config.h"
|
||||
#include "proto.h"
|
||||
#include "test.h"
|
||||
#include "ws_codec.h"
|
||||
|
||||
using kr::claim::acceptsUpgrade;
|
||||
using kr::claim::expired;
|
||||
using kr::claim::honours;
|
||||
using kr::claim::ViewerRole;
|
||||
using kr::proto::Cmd;
|
||||
using kr::proto::Message;
|
||||
using kr::proto::parse;
|
||||
|
||||
namespace {
|
||||
|
||||
// Every value proto::Cmd has. The matrix below walks it, so a new command added to
|
||||
// the protocol is a new row that must be judged allowed-or-refused for a claim --
|
||||
// the whitelist decides by name, and this list is what notices a name appears.
|
||||
constexpr Cmd kAll[] = {Cmd::None, Cmd::Hello, Cmd::Auth,
|
||||
Cmd::Arm, Cmd::Disarm, Cmd::State,
|
||||
Cmd::SecureAttention, Cmd::Chord, Cmd::Text,
|
||||
Cmd::Mouse, Cmd::ReleaseAll, Cmd::KeepAlive,
|
||||
Cmd::Takeover, Cmd::Bad};
|
||||
constexpr size_t kNumCmds = sizeof(kAll) / sizeof(kAll[0]);
|
||||
|
||||
// One masked client text frame, the only shape the codec accepts, sized for a
|
||||
// payload below 126 bytes (the claim's whole budget is 64).
|
||||
size_t maskedTextFrame(uint8_t *out, const uint8_t *text, size_t n) {
|
||||
const uint8_t key[4] = {0x37, 0xfa, 0x11, 0xde};
|
||||
size_t o = 0;
|
||||
out[o++] = 0x81; // FIN + text
|
||||
out[o++] = static_cast<uint8_t>(0x80 | n); // masked, 7-bit length
|
||||
for (size_t i = 0; i < 4; i++) out[o++] = key[i];
|
||||
for (size_t i = 0; i < n; i++) out[o++] = static_cast<uint8_t>(text[i] ^ key[i & 3]);
|
||||
return o;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
CASE("the whitelist, unauthenticated: T is the only door");
|
||||
for (size_t i = 0; i < kNumCmds; i++) {
|
||||
CHECK_EQ(honours(ViewerRole::Claim, false, kAll[i]), kAll[i] == Cmd::Auth);
|
||||
}
|
||||
|
||||
CASE("the whitelist, authenticated: T, K and the deliberate X -- nothing else");
|
||||
for (size_t i = 0; i < kNumCmds; i++) {
|
||||
const bool allowed = kAll[i] == Cmd::Auth || kAll[i] == Cmd::KeepAlive ||
|
||||
kAll[i] == Cmd::Takeover;
|
||||
CHECK_EQ(honours(ViewerRole::Claim, true, kAll[i]), allowed);
|
||||
}
|
||||
// X is allowed ONLY here, in the authenticated branch: it is the one deliberate act
|
||||
// the door exists for, and control_server.cpp::handOverClaim -- the only path it
|
||||
// triggers -- runs the release funnel and lands the board DISARMED. The claim socket
|
||||
// that reaches it has already proven the passphrase.
|
||||
CHECK(honours(ViewerRole::Claim, true, Cmd::Takeover));
|
||||
// Refused by name, because this list is the safety argument: reaching the target,
|
||||
// disturbing the incumbent, or moving the arming line are all refused here.
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Arm)); // no arm
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Disarm));
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::State)); // no keystrokes
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::SecureAttention)); // no Ctrl+Alt+Del
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Chord));
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Text)); // no paste
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Mouse)); // no pointer
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::ReleaseAll)); // no R either
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Hello)); // no status
|
||||
CHECK(!honours(ViewerRole::Claim, true, Cmd::Bad)); // not a conversation
|
||||
|
||||
CASE("a Readonly slot honours no frame at all, authenticated or not");
|
||||
for (size_t i = 0; i < kNumCmds; i++) {
|
||||
CHECK(!honours(ViewerRole::Readonly, false, kAll[i]));
|
||||
CHECK(!honours(ViewerRole::Readonly, true, kAll[i]));
|
||||
}
|
||||
// Including the takeover ask: a slot that never did the upgrade cannot claim a bridge.
|
||||
CHECK(!honours(ViewerRole::Readonly, true, Cmd::Takeover));
|
||||
|
||||
CASE("one claim at a time; a read-only slot may be turned into one");
|
||||
CHECK(acceptsUpgrade(ViewerRole::Readonly));
|
||||
CHECK(!acceptsUpgrade(ViewerRole::Claim));
|
||||
|
||||
CASE("deadlines: authenticate on the controller's clock, then wait our own");
|
||||
{
|
||||
const uint32_t anchor = 1234567;
|
||||
// Unauthenticated: exactly the controller's kAuthTimeoutMs, strictly greater.
|
||||
CHECK(!expired(false, anchor, anchor));
|
||||
CHECK(!expired(false, anchor + kr::kAuthTimeoutMs, anchor));
|
||||
CHECK(expired(false, anchor + kr::kAuthTimeoutMs + 1, anchor));
|
||||
// Authenticated: the auth clock no longer applies -- a claim that got its `OK`
|
||||
// at the worst legal moment is still a claim -- and kClaimTimeoutMs governs.
|
||||
CHECK(!expired(true, anchor + kr::kAuthTimeoutMs + 1, anchor));
|
||||
CHECK(!expired(true, anchor + kr::kClaimTimeoutMs, anchor));
|
||||
CHECK(expired(true, anchor + kr::kClaimTimeoutMs + 1, anchor));
|
||||
CHECK(kr::kClaimTimeoutMs > kr::kAuthTimeoutMs); // 15 s for a click, 5 s for T
|
||||
// Across the 49.7-day millis() rollover the arithmetic is elapsed time, not
|
||||
// magnitude: a live claim is not evicted and a dead one is not immortal.
|
||||
const uint32_t nearWrap = 0xFFFFFF00u;
|
||||
CHECK(!expired(false, nearWrap + 4000, nearWrap));
|
||||
CHECK(expired(false, nearWrap + kr::kAuthTimeoutMs + 1, nearWrap)); // wraps
|
||||
CHECK(!expired(true, nearWrap + 14000, nearWrap));
|
||||
CHECK(expired(true, nearWrap + kr::kClaimTimeoutMs + 1, nearWrap)); // wraps
|
||||
}
|
||||
|
||||
CASE("the claim's budget: every legal frame fits, anything larger is refused");
|
||||
{
|
||||
// The sizing claim, pinned as numbers: the widest frame a claim may receive is
|
||||
// `T<token>` at kTokenMax + 1, the cap is that with headroom, and it is exactly
|
||||
// one quarter of the controller's kWsMessageCap (config.h carries the reasoning;
|
||||
// a draft said sixteenth, the arithmetic says four, and the arithmetic wins).
|
||||
CHECK(kr::kTokenMax + 1 <= kr::kClaimMsgCap);
|
||||
CHECK_EQ(kr::kWsMessageCap / kr::kClaimMsgCap, 4u);
|
||||
CHECK(kr::kClaimMsgCap < kr::kWsMessageCap);
|
||||
|
||||
// The widest legal T frame parses, and fits the buffer with room to spare.
|
||||
char widest[ kr::kTokenMax + 2];
|
||||
widest[0] = 'T';
|
||||
memset(widest + 1, 'a', kr::kTokenMax);
|
||||
widest[kr::kTokenMax + 1] = '\0';
|
||||
Message m;
|
||||
CHECK(parse(widest, kr::kTokenMax + 1, m) == Cmd::Auth);
|
||||
CHECK(strlen(widest) < kr::kClaimMsgCap);
|
||||
}
|
||||
|
||||
CASE("an oversized claim frame is refused by the codec, not buffered");
|
||||
{
|
||||
uint8_t msg[kr::kClaimMsgCap + 16];
|
||||
memset(msg, 'z', sizeof(msg));
|
||||
|
||||
// Exactly at the cap: the reader completes (the frame is refused or accepted by
|
||||
// the whitelist later -- the codec's answer is "it fits").
|
||||
uint8_t buf[kr::kClaimMsgCap];
|
||||
kr::ws::Reader r{buf, sizeof(buf)};
|
||||
uint8_t frame[256];
|
||||
size_t n = maskedTextFrame(frame, msg, kr::kClaimMsgCap);
|
||||
kr::ws::In in = kr::ws::In::NeedMore;
|
||||
for (size_t i = 0; i < n && in == kr::ws::In::NeedMore; i++) in = r.feed(frame[i]);
|
||||
CHECK(in == kr::ws::In::Text);
|
||||
|
||||
// One byte over: an error, not a truncation, not a growing buffer. The board's
|
||||
// RAM is not somebody else's allocation request.
|
||||
kr::ws::Reader r2{buf, sizeof(buf)};
|
||||
n = maskedTextFrame(frame, msg, kr::kClaimMsgCap + 1);
|
||||
in = kr::ws::In::NeedMore;
|
||||
for (size_t i = 0; i < n && in == kr::ws::In::NeedMore; i++) in = r2.feed(frame[i]);
|
||||
CHECK(in == kr::ws::In::Error);
|
||||
CHECK(r2.error() == kr::ws::Err::TooLong);
|
||||
}
|
||||
|
||||
CASE("X is the takeover command, and the frames the whitelist names");
|
||||
{
|
||||
// Task 02 put `X` on the wire: it parses to Takeover (trailing bytes are Bad --
|
||||
// test_proto.cpp walks that in full), and it is the only frame that changed side
|
||||
// of the whitelist since task 01. The rest still parse as the whitelist comment
|
||||
// says they do, because that comment and this list are the same argument.
|
||||
Message m;
|
||||
CHECK(parse("X", 1, m) == Cmd::Takeover);
|
||||
CHECK(parse("XX", 2, m) == Cmd::Bad);
|
||||
// And the frames named by the whitelist comment really are the commands named:
|
||||
CHECK(parse("K", 1, m) == Cmd::KeepAlive);
|
||||
CHECK(parse("Tchangeme", 9, m) == Cmd::Auth);
|
||||
CHECK(parse("A1", 2, m) == Cmd::Arm);
|
||||
CHECK(parse("A0", 2, m) == Cmd::Disarm);
|
||||
CHECK(parse("R", 1, m) == Cmd::ReleaseAll);
|
||||
CHECK(parse("C", 1, m) == Cmd::SecureAttention);
|
||||
CHECK(parse("H", 1, m) == Cmd::Hello);
|
||||
CHECK(parse("S=00:", 5, m) == Cmd::State);
|
||||
CHECK(parse("Y=02:04", 7, m) == Cmd::Chord);
|
||||
CHECK(parse("P=0:6162", 8, m) == Cmd::Text);
|
||||
CHECK(parse("M=00:0:0:0:0", 12, m) == Cmd::Mouse);
|
||||
}
|
||||
|
||||
return report("test_claim");
|
||||
}
|
||||
+6
-6
@@ -67,7 +67,7 @@ namespace {
|
||||
// tests/test_relayctl.py ("the page the transport sends"). The page carries one
|
||||
// 19-byte #if KR_TLS build marker and both spellings are the same length, so this
|
||||
// number is build-independent -- a fact test_relayctl pins, not a hope.
|
||||
constexpr size_t kServedPageBytes = 51626;
|
||||
constexpr size_t kServedPageBytes = 59286;
|
||||
|
||||
/** The pump's shape, without a socket: hand out chunks, accept what a peer takes. */
|
||||
struct DrainLog {
|
||||
@@ -311,10 +311,10 @@ int main() {
|
||||
CHECK_EQ(fs.sent(), 20);
|
||||
|
||||
CASE("flash send: the real page, on the real budget");
|
||||
// 51 626 bytes (the count .agents/validate.sh prints) through kPageChunkBytes per write
|
||||
// 59 286 bytes (the count .agents/validate.sh prints) through kPageChunkBytes per write
|
||||
// and kPageBytesPerTick per tick: the exact numbers the device check compares against.
|
||||
{
|
||||
// A stand-in of the served page's size (51 626 B, the `page bytes:` line the gate
|
||||
// A stand-in of the served page's size (59 286 B, the `page bytes:` line the gate
|
||||
// prints). tests/test_relayctl.py pins this constant against the raw string in
|
||||
// src/web_page.h, so if the page changes and this number does not, a check fails --
|
||||
// the same "test the table" rule as the mouse bits and the evdev codes.
|
||||
@@ -342,10 +342,10 @@ int main() {
|
||||
CHECK_EQ(total, sizeof(big));
|
||||
CHECK(worstPiece <= kPageChunkBytes);
|
||||
CHECK(worstTick <= kPageBytesPerTick);
|
||||
// 51 626 / 1460 = 36 chunks; / 5840 per tick = 9 ticks. If either number changes in
|
||||
// 59 286 / 1460 = 41 chunks; / 5840 per tick = 11 ticks. If either number changes in
|
||||
// config.h this line moves, and the report has to move with it.
|
||||
CHECK_EQ((sizeof(big) + kPageBytesPerTick - 1) / kPageBytesPerTick, 9);
|
||||
CHECK_EQ((sizeof(big) + kPageChunkBytes - 1) / kPageChunkBytes, 36);
|
||||
CHECK_EQ((sizeof(big) + kPageBytesPerTick - 1) / kPageBytesPerTick, 11);
|
||||
CHECK_EQ((sizeof(big) + kPageChunkBytes - 1) / kPageChunkBytes, 41);
|
||||
}
|
||||
|
||||
CASE("config: the sizing this layer promises");
|
||||
|
||||
@@ -141,6 +141,9 @@ const expose = `
|
||||
get armed() { return armed; }, set armed(v) { armed = v; },
|
||||
get authed() { return authed; }, set authed(v) { authed = v; },
|
||||
get typing() { return typing; },
|
||||
get autoReconnect() { return autoReconnect; },
|
||||
get busyKnown() { return busyKnown; },
|
||||
get takenOver() { return takenOver; },
|
||||
};`;
|
||||
|
||||
const headerPath = path.join(__dirname, "..", "src", "web_page.h");
|
||||
@@ -626,6 +629,116 @@ checkEq(!!x.inPageField({ target: null }), false, "a missing target does not thr
|
||||
checkEq(byId("certFp").textContent.includes("00aa11bb22cc33dd"), true,
|
||||
"a new status re-renders the new fingerprint");
|
||||
|
||||
console.log("the busy board and the take-over");
|
||||
// Phase 03, page side. The board reports "held" to a socket it authenticated but
|
||||
// did not adopt (the status frame belongs to the controller alone), and to a
|
||||
// session it displaces (E12). The page's whole job is to make that visible, offer
|
||||
// one deliberate click, and NOT fight: no retry loop, no auto-reclaim, and --
|
||||
// until the click -- not one frame about the bridge.
|
||||
|
||||
// `busy` in a status frame, seen by a page that is not the controller, is the
|
||||
// link pill's third honest state. A controller reading busy:1 is reading itself,
|
||||
// and keeps "linked" -- the OK handler says so; applyStatus must not contradict it.
|
||||
byId("takeover").hidden = true;
|
||||
x.authed = false; x.armed = false;
|
||||
x.onFrame('S{"armed":0,"usb":1,"typing":0,"busy":1}');
|
||||
checkEq(byId("pLink").textContent, "held by another controller",
|
||||
"a busy status frame drives the link pill's third state");
|
||||
check(byId("pLink").className.includes("bad"),
|
||||
"and it is not the calm 'linked' state");
|
||||
checkEq(x.busyKnown, true, "and the page now knows the board is held");
|
||||
x.onFrame("OK"); // adopted: the ordinary path
|
||||
checkEq(byId("pLink").textContent, "linked",
|
||||
"the adopted controller reads its own busy as linked, not as a rival");
|
||||
|
||||
// A refused upgrade: the board parks this socket as a pending claim -- `OK`, then
|
||||
// the E6-and-drop that answers the ordinary `H` on a claim slot -- and the page
|
||||
// must say "held", offer the button, and stop, instead of retrying every 2 s.
|
||||
sandbox.location.protocol = "https:";
|
||||
byId("token").value = "hunter2";
|
||||
FakeWebSocket.instances.length = 0;
|
||||
sentFrames.length = 0;
|
||||
x.authed = false; x.armed = false;
|
||||
x.connect();
|
||||
await wait(5);
|
||||
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
|
||||
inst.onmessage({ data: "OK" }); // authenticated -- as a claim
|
||||
const afterConnect = sentFrames.length; // T + H from the ordinary flow
|
||||
inst.onmessage({ data: "E6:a pending claim may not send that" });
|
||||
inst.onclose(); // and the claim is dropped
|
||||
checkEq(byId("pLink").textContent, "held by another controller",
|
||||
"the un-adopted socket reads the board's silence as 'held'");
|
||||
checkEq(byId("takeover").hidden, false,
|
||||
"and the take-over button appears -- only now that the board said held");
|
||||
checkEq(sentFrames.length, afterConnect,
|
||||
"after the refusal the page sends nothing on its own -- no retry, no X");
|
||||
checkEq(x.autoReconnect, false,
|
||||
"the reconnect loop is off: re-claiming automatically is a fight");
|
||||
checkEq(FakeWebSocket.instances.length, 1, "and no second socket was opened");
|
||||
|
||||
// The one deliberate click: open, authenticate, ask for the bridge. T then X,
|
||||
// in that order, and nothing else -- no arm, no state, not even an H.
|
||||
byId("takeover").fire("click");
|
||||
await wait(5);
|
||||
checkEq(FakeWebSocket.instances.length, 2, "one click opened one takeover socket");
|
||||
const t = FakeWebSocket.instances[1];
|
||||
checkEq(sentFrames.slice(afterConnect).join(","), "Thunter2,X",
|
||||
"the click sends T then X, in that order, and nothing else");
|
||||
checkEq(byId("takeover").hidden, true, "and the button retires once the act is sent");
|
||||
t.onmessage({ data: "OK" }); // adopted: OK, then the status
|
||||
t.onmessage({ data: 'S{"armed":0,"usb":1,"typing":0,"busy":1}' });
|
||||
checkEq(x.armed, false,
|
||||
"the newcomer behaves like any controller, starting DISARMED");
|
||||
checkEq(byId("pLink").textContent, "linked", "and it holds the bridge now");
|
||||
const tlog = logEl.children.map(c => String(c.textContent)).join("\n");
|
||||
check(tlog.includes("bridge taken over") && tlog.includes("DISARMED"),
|
||||
"the log names what happened, including the state it landed in");
|
||||
|
||||
// E3 keeps its meaning next to E12's: "you were refused -- no room for a claim",
|
||||
// not "you were dropped". It still disarms a page that asks; it does not claim
|
||||
// this session was displaced. (Checked BEFORE the E12 walk, so "not a takeover"
|
||||
// means E3 did not set it, not that an earlier takeover was already on record.)
|
||||
x.setArmed(true, "harness: armed");
|
||||
x.onFrame("E3:no room");
|
||||
checkEq(x.armed, false, "E3 still refuses the ask and disarms");
|
||||
checkEq(x.takenOver, false, "but E3 is not a takeover -- nothing displaced anyone");
|
||||
const e3log = logEl.children.map(c => String(c.textContent)).join("\n");
|
||||
check(e3log.includes("no room for a claim"),
|
||||
"and its log line says 'no room', the sentence that distinguishes it from E12");
|
||||
|
||||
// The incumbent's side, which this page now is: E12 disarms, releases the mouse,
|
||||
// ends a typing run mid-flight, marks the pill, and keeps the page down -- the
|
||||
// same gesture the board itself ran through the funnel.
|
||||
x.setArmed(true, "harness: armed");
|
||||
x.takeMouse();
|
||||
await wait(2);
|
||||
checkEq(x.mCaptured, true, "set-up: this session holds the mouse");
|
||||
byId("paste").value = "run".repeat(100); // three frames' worth
|
||||
sentFrames.length = 0;
|
||||
p = x.sendPaste();
|
||||
await wait(5);
|
||||
x.onFrame("E12:session taken over");
|
||||
checkEq(x.armed, false, "E12 disarms the incumbent immediately");
|
||||
checkEq(x.mCaptured, false, "and drops the mouse");
|
||||
x.onFrame('S{"armed":0,"usb":1,"typing":0}'); // release the run's waiter
|
||||
await p;
|
||||
checkEq(x.typing, false, "and ends the typing run");
|
||||
checkEq(frames().length, 1, "the run stops -- no further P= frames after a takeover");
|
||||
checkEq(byId("pLink").textContent, "taken over", "and the pill says what happened");
|
||||
const ilog = logEl.children.map(c => String(c.textContent)).join("\n");
|
||||
check(ilog.includes("another controller took over"),
|
||||
"and the log carries the reason, in the incumbent's own words");
|
||||
checkEq(x.autoReconnect, false, "and this page stays down until someone clicks");
|
||||
inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
|
||||
const nSockets = FakeWebSocket.instances.length;
|
||||
inst.onclose();
|
||||
checkEq(FakeWebSocket.instances.length, nSockets,
|
||||
"the close after E12 opens nothing -- no auto-reclaim");
|
||||
checkEq(byId("pLink").textContent, "taken over", "the pill keeps the fact");
|
||||
checkEq(byId("takeover").hidden, false,
|
||||
"coming back is one deliberate click away, never zero");
|
||||
x.setArmed(false, "harness: done");
|
||||
|
||||
console.log("element ids");
|
||||
const markup = served;
|
||||
const present = new Set();
|
||||
|
||||
+53
-5
@@ -45,9 +45,24 @@ int main() {
|
||||
CHECK(parse("C") == Cmd::SecureAttention);
|
||||
CHECK(parse("A1") == Cmd::Arm);
|
||||
CHECK(parse("A0") == Cmd::Disarm);
|
||||
CHECK(parse("X") == Cmd::Takeover); // the pending claim's one deliberate act
|
||||
// hand-typed clients end lines with \n: tolerated
|
||||
CHECK(parse("A1\n") == Cmd::Arm);
|
||||
CHECK(parse("R\r\n") == Cmd::ReleaseAll);
|
||||
CHECK(parse("X\r\n") == Cmd::Takeover); // tolerated here exactly like the others
|
||||
}
|
||||
|
||||
CASE("the takeover frame: what a claimant puts on the wire is what the board parses");
|
||||
{
|
||||
// There is no formatX(): the client side of `X` is the single byte, and this is
|
||||
// the round trip -- the exact bytes the page or relayctl will send, run through
|
||||
// the firmware's own parser, plus the name the diagnostics print.
|
||||
const char frame[] = "X";
|
||||
Message m;
|
||||
CHECK(kr::proto::parse(frame, sizeof(frame) - 1, m) == Cmd::Takeover);
|
||||
CHECK(std::string(kr::proto::name(Cmd::Takeover)) == "takeover");
|
||||
// Anything else starting with X is not a takeover and not a conversation.
|
||||
CHECK(kr::proto::parse("XK", 2, m) == Cmd::Bad);
|
||||
}
|
||||
|
||||
CASE("garbage is rejected, never half-accepted");
|
||||
@@ -56,6 +71,9 @@ int main() {
|
||||
CHECK(parse("A") == Cmd::Bad); // arm/disarm needs its digit
|
||||
CHECK(parse("A2") == Cmd::Bad);
|
||||
CHECK(parse("A11") == Cmd::Bad);
|
||||
CHECK(parse("XX") == Cmd::Bad); // `X` is one command; trailing bytes are Bad
|
||||
CHECK(parse("X1") == Cmd::Bad); // ... even bytes that look meaningful
|
||||
CHECK(parse("X ") == Cmd::Bad); // ... or not: one frame, one command
|
||||
CHECK(parse("HH") == Cmd::Bad);
|
||||
CHECK(parse("Z") == Cmd::Bad);
|
||||
CHECK(parse("hello") == Cmd::Bad);
|
||||
@@ -464,6 +482,11 @@ int main() {
|
||||
CHECK_STR(buf, "E10:off");
|
||||
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 11, "boot"), 8u);
|
||||
CHECK_STR(buf, "E11:boot");
|
||||
// E12 is the incumbent's sentence ("you were dropped"), and it renders at full
|
||||
// width like 10/11 -- a client that was told "E1" by truncation would tell its
|
||||
// operator the passphrase failed instead of the bridge was taken.
|
||||
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 12, "session taken over"), 22u);
|
||||
CHECK_STR(buf, "E12:session taken over");
|
||||
|
||||
CHECK_EQ(kr::proto::formatError(buf, sizeof(buf), 0, "z"), 4u);
|
||||
CHECK_STR(buf, "E0:z");
|
||||
@@ -549,15 +572,35 @@ int main() {
|
||||
char json[kr::kStatusJsonCap];
|
||||
const char *fp = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
|
||||
|
||||
// A default snapshot: no TLS, and no label for an identity that is not there.
|
||||
// A default snapshot: no TLS, no label for an identity that is not there, and a
|
||||
// free bridge with no handover behind it.
|
||||
size_t n = statusJson(s, json, sizeof(json));
|
||||
CHECK(n > 0);
|
||||
CHECK(n < kr::kStatusJsonCap);
|
||||
CHECK(strstr(json, "\"tls\":0") != nullptr);
|
||||
CHECK(strstr(json, "\"fp\":\"\"") != nullptr);
|
||||
CHECK(strstr(json, "\"busy\":0") != nullptr);
|
||||
CHECK(strstr(json, "\"tkov\":0") != nullptr);
|
||||
CHECK_EQ(kr::kStatusFingerprintBytes, 8);
|
||||
CHECK_EQ(kr::kStatusFingerprintHex, 16);
|
||||
|
||||
// The bridge, in the only terms the board may know: a link boolean and a
|
||||
// counter. busy:1 says "a controller holds the keyboard" -- to a /status poller,
|
||||
// someone else; to the adopted controller, itself -- and tkov counts completed
|
||||
// handovers. There is no field that could say WHO, and walking these two is what
|
||||
// keeps the next "just add the client IP" from passing review unnoticed: the
|
||||
// README's never-stored list is about exactly this temptation.
|
||||
StatusSnapshot held;
|
||||
held.busy = true;
|
||||
held.takeovers = 3;
|
||||
n = statusJson(held, json, sizeof(json));
|
||||
CHECK(n > 0);
|
||||
CHECK(strstr(json, "\"busy\":1") != nullptr);
|
||||
CHECK(strstr(json, "\"tkov\":3") != nullptr);
|
||||
CHECK(strstr(json, "addr") == nullptr); // the only ip here is the board's own
|
||||
CHECK(strstr(json, "agent") == nullptr);
|
||||
CHECK(strstr(json, "peer") == nullptr);
|
||||
|
||||
// The identity renders as `"tls":1` plus a `fp` of exactly 16 hex characters -- the
|
||||
// first kr::kStatusFingerprintBytes of the digest, not the whole 64: this field is a label to
|
||||
// eyeball against `cert`, and the full value is the console's job.
|
||||
@@ -612,10 +655,11 @@ int main() {
|
||||
|
||||
// The longest frame the board can legally put on the wire: a 32-character SSID (the
|
||||
// 802.11 limit, and staSsid is 33 bytes), a 15-character dotted quad, UINT32_MAX in each
|
||||
// of the five counters, UINT16_MAX in `typing`, every LED bit and every mouse button, and
|
||||
// the whole 16-hex `fp`. This is the arithmetic config.h quotes -- measured here rather
|
||||
// than believed, with the headroom asserted as at least one more field's worth so the
|
||||
// next field is a decision and not an overflow nobody looked for.
|
||||
// of the five counters, UINT16_MAX in `typing`, every LED bit and every mouse button,
|
||||
// `busy` set, `tkov` at UINT32_MAX, and the whole 16-hex `fp`. This is the arithmetic
|
||||
// config.h quotes -- measured here rather than believed, with the headroom asserted as
|
||||
// at least one more field's worth so the next field is a decision and not an overflow
|
||||
// nobody looked for.
|
||||
StatusSnapshot worst;
|
||||
worst.version = kr::kFirmwareVersion;
|
||||
worst.uptimeS = 0xFFFFFFFFu;
|
||||
@@ -637,6 +681,8 @@ int main() {
|
||||
worst.ledState = 7; // caps/num/scroll: the three bits a report carries
|
||||
worst.defaultToken = true;
|
||||
worst.apForced = true;
|
||||
worst.busy = true;
|
||||
worst.takeovers = 0xFFFFFFFFu;
|
||||
worst.tls = true;
|
||||
worst.fp = fp;
|
||||
char big[kr::kStatusJsonCap + 16];
|
||||
@@ -650,6 +696,8 @@ int main() {
|
||||
CHECK(strchr(big, '\n') == nullptr); // one frame, one line
|
||||
CHECK(strstr(big, "\"tls\":1") != nullptr);
|
||||
CHECK(strstr(big, "\"fp\":\"ba7816bf8f01cfea\"") != nullptr);
|
||||
CHECK(strstr(big, "\"busy\":1") != nullptr);
|
||||
CHECK(strstr(big, "\"tkov\":4294967295") != nullptr);
|
||||
|
||||
// And the cap means it: one byte short renders nothing at all, which is the difference
|
||||
// between a status that is missing and a status that is wrong.
|
||||
|
||||
+199
-10
@@ -382,6 +382,84 @@ check("const bool wasLinked = _phase == Phase::WebSocket;" in close_fn,
|
||||
"closeLink() treats anything that reached the WebSocket phase as able to leave "
|
||||
"keys down, not only what is still marked authenticated")
|
||||
|
||||
print("the bridge handover (phase 03)")
|
||||
# `X` moves the keyboard from one socket to the other, and the safety sentences about it
|
||||
# are ordering facts in control_server.cpp that no host-runnable test can observe -- they
|
||||
# need two real sockets and a board. Same trade as bug 10's pins above: these are source
|
||||
# pins, deliberately, for the three sentences a later edit can undo silently.
|
||||
server_hdr = (root / "src" / "control_server.h").read_text()
|
||||
claim_src = (root / "src" / "claim.h").read_text()
|
||||
|
||||
hand_at = server_src.index("void ControlServer::handOverClaim")
|
||||
hand = server_src[hand_at:server_src.index("\n}", hand_at)]
|
||||
# Code lines only -- the function's comment is full of the words it forbids itself, and
|
||||
# a check that flagged the comment would be worse than no check at all.
|
||||
hand_code = "\n".join(l for l in hand.splitlines()
|
||||
if not l.strip().startswith(("//", "*", "/*")))
|
||||
|
||||
# (a) The funnel runs BEFORE the newcomer is adopted. closeLink() is the release funnel
|
||||
# (it disarms and releases for anything that reached the WebSocket phase -- bug 10's
|
||||
# fix -- and runs _onLinkChange(false), which in main.cpp is disarm("controller
|
||||
# disconnected") -> releaseEverything()); adopting the claimant is `_authed = true`.
|
||||
# If those two ever swap order, the newcomer inherits a held key and an armed
|
||||
# bridge, and nothing on a host would ever notice.
|
||||
check("closeLink(\"taken over\")" in hand_code,
|
||||
"the handover runs the existing release funnel, not a copy of it")
|
||||
check("_authed = true" in hand_code,
|
||||
"and it is the code that adopts the claimant as the controller")
|
||||
check(hand_code.index("closeLink(\"taken over\")") < hand_code.index("_authed = true"),
|
||||
"the funnel runs BEFORE the claimant is adopted -- a takeover can never inherit "
|
||||
"a held key or an armed bridge")
|
||||
check("if (!linked) disarm(\"controller disconnected\");" in main_src,
|
||||
"and the funnel's link change is the one main.cpp disarms (releases every key "
|
||||
"and every mouse button) on")
|
||||
|
||||
# (b) No arm is reachable from the `X` handler. The board lands DISARMED by design; the
|
||||
# newcomer arms with an ordinary authenticated A1 or never. `arm(`/`setArmed`/"armed =
|
||||
# true" must not appear in the handover's code lines, nor in the controller-side `X`
|
||||
# case in main.cpp (which is a no-op answered OK).
|
||||
for tok in ("setArmed", "armed = true", "arm("):
|
||||
check(tok not in hand_code,
|
||||
f"the handover path contains no {tok!r} -- a takeover cannot produce an "
|
||||
f"armed board")
|
||||
mt_at = main_src.index("case proto::Cmd::Takeover:")
|
||||
mt_case_raw = main_src[mt_at:main_src.index("return;", mt_at)]
|
||||
# Comment lines stripped for the same reason as hand_code: the case's own comment
|
||||
# explains what it must not do, in words that contain the words being searched for.
|
||||
mt_case = "\n".join(l for l in mt_case_raw.splitlines()
|
||||
if not l.strip().startswith(("//", "*", "/*")))
|
||||
check("control.sendOk();" in mt_case,
|
||||
"`X` from the incumbent controller is a no-op answered OK, not E6")
|
||||
check("setArmed" not in mt_case and "arm(" not in mt_case,
|
||||
"and it cannot arm either -- you already hold the bridge, that is all this says")
|
||||
|
||||
# (c) A claim cannot be promoted without `_claimAuthed`. Two halves: the whitelist is
|
||||
# consulted before the Takeover branch in handleClaimFrame, and claim.h admits `X`
|
||||
# only in the authenticated branch, so the ordering has something correct to check.
|
||||
hcf_at = server_src.index("bool ControlServer::handleClaimFrame")
|
||||
hcf = server_src[hcf_at:server_src.index("\n}", hcf_at)]
|
||||
check("claim::honours(_viewerRole, _claimAuthed, cmd)" in hcf,
|
||||
"handleClaimFrame asks the one whitelist, with _claimAuthed in the question")
|
||||
check(hcf.index("claim::honours(") < hcf.index("proto::Cmd::Takeover"),
|
||||
"and it asks BEFORE the Takeover branch -- `X` cannot reach the promotion "
|
||||
"unauthenticated")
|
||||
check_eq(server_src.count("handOverClaim(nowMs);"), 1,
|
||||
"handOverClaim is called from exactly that one place, behind the whitelist")
|
||||
hon_at = claim_src.index("inline bool honours(")
|
||||
hon = claim_src[hon_at:claim_src.index("\n}", hon_at)]
|
||||
check("if (!authed) return cmd == proto::Cmd::Auth;" in hon,
|
||||
"claim.h admits only T before authentication -- an unauthenticated `X` is refused")
|
||||
check(hon.index("if (!authed)") < hon.index("case proto::Cmd::Takeover"),
|
||||
"and `X` lives in the authenticated branch only, after that refusal")
|
||||
|
||||
# E12 is its own sentence: "you were dropped", not E3's "you were refused". The named
|
||||
# code and the incumbent-side send are both pinned so the two can never merge back.
|
||||
check("kErrTakenOver = 12" in server_hdr,
|
||||
"the error code is named at 12, the number the incumbent's page will read")
|
||||
check('sendError(kErrTakenOver, "session taken over")' in hand_code,
|
||||
"and it is that named code the incumbent is told with, before the funnel closes "
|
||||
"its link")
|
||||
|
||||
print("one routing path, two transports")
|
||||
# Phase 02 task 04 moved the TLS session underneath the routing instead of beside it. The
|
||||
# claims are structural, so they are pinned as source sentences: what they defend is a
|
||||
@@ -401,11 +479,16 @@ check("if (_phase == Phase::WebSocket && _authed) _lastInboundMs = millis();" in
|
||||
"readSome() stamps the silence clock only for an authenticated controller on the "
|
||||
"WebSocket, whatever transport the bytes came over")
|
||||
check("_lastInboundMs" in read_fn, "readSome() is still the code that notices inbound bytes")
|
||||
# ... and it is the ONLY inbound path that may. Three writers of that clock would mean a
|
||||
# route that can keep the board armed: readSome, and closeLink stamping on the way out.
|
||||
# ... and it is the ONLY inbound path that may. Every writer of that clock is a route
|
||||
# that can keep the board armed, so the count is pinned: readSome, the authentication,
|
||||
# closeLink stamping on the way out, and the bridge handover ADOPTING an already-
|
||||
# authenticated claim (phase 03 -- the claim was checked against the same passphrase,
|
||||
# so from that instant a controller exists and is speaking; the adoption stamps exactly
|
||||
# the stamp an auth would). A fifth writer needs its sentence written here first.
|
||||
stamps = [l.strip() for l in server_src.splitlines() if "_lastInboundMs = millis()" in l]
|
||||
check_eq(len(stamps), 3,
|
||||
"exactly three writers of the controller-silence clock: auth, readSome, closeLink")
|
||||
check_eq(len(stamps), 4,
|
||||
"exactly four writers of the controller-silence clock: auth, readSome, "
|
||||
"closeLink, handover adoption")
|
||||
check(any("the controller exists from here" in l for l in stamps),
|
||||
"one of them is the authentication itself")
|
||||
# The spare slot must not be able to reach that clock even by accident: it reads the Conn
|
||||
@@ -590,17 +673,33 @@ for bad in ("", "deadbeef", "zz" * 32, "ab" * 31, "ab" * 32 + "0"):
|
||||
FAKE_DER = b"-----BEGIN FAKE CERTIFICATE----- der-bytes pinning never sees"
|
||||
|
||||
|
||||
# The status an adopted controller gets back to back with its `OK` -- the frame
|
||||
# whose ABSENCE after `OK` is how a pending claim learns the board is busy. The
|
||||
# values are the honest post-handover ones: disarmed, busy (with itself), one
|
||||
# completed takeover.
|
||||
FAKE_STATUS = b'S{"v":"1.0.0","armed":0,"usb":1,"typing":0,"busy":1,"tkov":1}'
|
||||
|
||||
|
||||
class FakeBoardSock:
|
||||
"""Records every byte this side sends; answers the upgrade and `T<token>`.
|
||||
|
||||
The recorded frame list is the point: "the pin aborted before T<token>" can
|
||||
only be shown by what did NOT go out.
|
||||
|
||||
`claim_slot=True` makes the board a BUSY one: `T` authenticates into the spare
|
||||
slot as a pending claim (`OK`, and deliberately no adoption status -- the status
|
||||
frame belongs to the controller alone), and the board adopts the client only
|
||||
when it sends the one deliberate `X`. That asymmetry is the whole of phase 03
|
||||
from this side of the socket, so the fake spells it out instead of collapsing
|
||||
both answers into "OK".
|
||||
"""
|
||||
|
||||
def __init__(self, host, port, close_early=False, bad_accept=False, flood=False):
|
||||
def __init__(self, host, port, close_early=False, bad_accept=False, flood=False,
|
||||
claim_slot=False):
|
||||
self.host, self.port = host, port
|
||||
self.bad_accept = bad_accept # answer the upgrade with a wrong accept hash
|
||||
self.flood = flood # answer with headers that never terminate
|
||||
self.claim_slot = claim_slot # busy board: park the newcomer as a claim
|
||||
self.sent = [] # every sendall payload, in order
|
||||
self.inbox = bytearray()
|
||||
self.upgraded = False
|
||||
@@ -639,6 +738,20 @@ class FakeBoardSock:
|
||||
del self.post[:6 + length]
|
||||
if payload.startswith(b"T"):
|
||||
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, b"OK")
|
||||
if not self.claim_slot:
|
||||
# The adoption status a real controller gets back to back with
|
||||
# the OK -- the auth path sends both on one write.
|
||||
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
|
||||
elif payload == b"X":
|
||||
# Only a claim has anything to take; this fake only ever sees X
|
||||
# from one. The handover lands disarmed, and the adopted session
|
||||
# gets exactly the greeting a fresh authentication gets.
|
||||
if self.claim_slot:
|
||||
self.claim_slot = False
|
||||
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, b"OK")
|
||||
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
|
||||
elif payload == b"H":
|
||||
self.inbox += relayctl_server_frame(relayctl.OP_TEXT, FAKE_STATUS)
|
||||
|
||||
def recv(self, n):
|
||||
if self.inbox:
|
||||
@@ -706,14 +819,15 @@ class FakeSsl:
|
||||
|
||||
def connect_with(fingerprint="", ca="", insecure=False, plain=False, port=None,
|
||||
der=FAKE_DER, token="sekrit", wrap_error=None, close_early=False,
|
||||
empty_der=False, bad_accept=False, flood=False,
|
||||
empty_der=False, bad_accept=False, flood=False, claim_slot=False,
|
||||
take_over=False,
|
||||
real_create=socketmod.create_connection, real_ssl=relayctl.ssl):
|
||||
fake = FakeSsl(der, wrap_error=wrap_error, empty_der=empty_der)
|
||||
socks = []
|
||||
|
||||
def fake_create(addr, timeout):
|
||||
s = FakeBoardSock(addr[0], addr[1], close_early=close_early,
|
||||
bad_accept=bad_accept, flood=flood)
|
||||
bad_accept=bad_accept, flood=flood, claim_slot=claim_slot)
|
||||
socks.append(s)
|
||||
return s
|
||||
|
||||
@@ -725,7 +839,8 @@ def connect_with(fingerprint="", ca="", insecure=False, plain=False, port=None,
|
||||
with contextlib.redirect_stderr(stderr):
|
||||
relay = relayctl.Relay("keyrelay.local", port, token, 1.0,
|
||||
fingerprint=fingerprint, ca=ca,
|
||||
insecure=insecure, plain=plain)
|
||||
insecure=insecure, plain=plain,
|
||||
take_over=take_over)
|
||||
except relayctl.RelayError as exc:
|
||||
err = exc
|
||||
finally:
|
||||
@@ -835,10 +950,47 @@ frames = client_frame_payloads(socks[0])
|
||||
check(frames and frames[0] == b"Tsekrit",
|
||||
"the rollback session authenticates the same way")
|
||||
|
||||
print("the busy board and --take-over")
|
||||
# Phase 03, from the terminal side. A busy board authenticates this tool into its
|
||||
# one spare slot as a pending claim -- `OK` and then the silence that means `busy`,
|
||||
# because the status frame belongs to the controller alone. The default is a
|
||||
# refusal that names the flag; the flag is one deliberate `X`; a free board is a
|
||||
# no-op. The frames that DO go out are asserted, in order, because "takeover can
|
||||
# never arm" is a sentence about what a session looks like at the moment it takes
|
||||
# over: T, X, and nothing that types.
|
||||
|
||||
# Default against a busy board: refuse, name --take-over, send no X, free the slot.
|
||||
relay, err, socks, fake, warns = connect_with(plain=True, claim_slot=True)
|
||||
check(isinstance(err, relayctl.RelayError), "a busy board refuses by default")
|
||||
check(err is not None and "--take-over" in str(err), "and the refusal names the flag")
|
||||
check(err is not None and "DISARMED" in str(err),
|
||||
"and says what the handover lands on, before anyone takes it")
|
||||
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit"],
|
||||
"the refusal sends no X -- the act needs the flag, not just the passphrase")
|
||||
check(socks and socks[0].closed, "and the claim slot is freed rather than parked on")
|
||||
|
||||
# With the flag: the claim's one deliberate frame, in the right order, alone.
|
||||
relay, err, socks, fake, warns = connect_with(plain=True, claim_slot=True,
|
||||
take_over=True)
|
||||
check(err is None and relay is not None, f"--take-over takes a busy board ({err})")
|
||||
check(relay is not None and relay.took_over, "and the session knows it took the bridge")
|
||||
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit", b"X"],
|
||||
"T then X, in that order, and nothing else -- one intent, no arm, no state")
|
||||
|
||||
# --take-over against a free board is a no-op, not an error, and never wastes an X
|
||||
# on a board that has nothing to take.
|
||||
relay, err, socks, fake, warns = connect_with(plain=True, take_over=True)
|
||||
check(err is None and relay is not None, f"--take-over on a free board succeeds ({err})")
|
||||
check(relay is not None and relay.busy is False and not relay.took_over,
|
||||
"and it reports the board was free")
|
||||
check_eq(client_frame_payloads(socks[0]), [b"Tsekrit"],
|
||||
"a free board is never sent an X it did not need")
|
||||
|
||||
# --- main(): the refusals that need no board -----------------------------------
|
||||
|
||||
|
||||
def run_main(argv, fp_env=None, token_env="hunter2secret"):
|
||||
def run_main(argv, fp_env=None, token_env="hunter2secret", claim_slot=False,
|
||||
socks_out=None):
|
||||
"""Run main() with the transport faked: create_connection records the address
|
||||
instead of dialing, and ssl is the fake from above. Any credential that
|
||||
reaches the returned text is a test failure, so the caller never has to
|
||||
@@ -847,7 +999,10 @@ def run_main(argv, fp_env=None, token_env="hunter2secret"):
|
||||
|
||||
def record_create(addr, timeout):
|
||||
calls.append(addr)
|
||||
return FakeBoardSock(addr[0], addr[1])
|
||||
s = FakeBoardSock(addr[0], addr[1], claim_slot=claim_slot)
|
||||
if socks_out is not None:
|
||||
socks_out.append(s)
|
||||
return s
|
||||
|
||||
old_argv = sys.argv
|
||||
old_env = os.environ.get("KEYRELAY_TLS_FP"), os.environ.get("KEYRELAY_TOKEN")
|
||||
@@ -919,6 +1074,40 @@ code, out, err_text, calls = run_main(["paste"], fp_env="ab" * 32)
|
||||
check_eq(code, 4, "paste still needs --yes before any connection")
|
||||
check(calls == [], "and that check still does not require a reachable board")
|
||||
|
||||
# The whole takeover gesture through main(): take the held bridge, then read the
|
||||
# board. T, X, H -- claim, handover, status -- with the terminal told which path
|
||||
# ran, and no credential in anything it prints.
|
||||
board = []
|
||||
code, out, err_text, calls = run_main(["--plain", "--take-over", "status"],
|
||||
token_env="sekrit", claim_slot=True,
|
||||
socks_out=board)
|
||||
check_eq(code, 0, f"--take-over status against a busy board runs ({err_text})")
|
||||
session = client_frame_payloads(board[0]) if board else []
|
||||
check_eq([f for f in session if f in (b"Tsekrit", b"X", b"H")],
|
||||
[b"Tsekrit", b"X", b"H"],
|
||||
"the busy session's frames are T, then X, then H -- claim, handover, status")
|
||||
check("took the bridge" in out and "DISARMED" in out,
|
||||
"and the terminal is told what the handover did before anything else runs")
|
||||
check("sekrit" not in out and "sekrit" not in err_text,
|
||||
"and the passphrase stays out of the terminal")
|
||||
|
||||
board = []
|
||||
code, out, err_text, calls = run_main(["--plain", "--take-over", "status"],
|
||||
token_env="sekrit", socks_out=board)
|
||||
check_eq(code, 0, f"--take-over against a free board still runs ({err_text})")
|
||||
check("nothing to take" in out, "and says it was a no-op, in those words")
|
||||
check_eq([f for f in (client_frame_payloads(board[0]) if board else []) if f == b"X"],
|
||||
[], "with no X on the wire at all")
|
||||
|
||||
# The default against a busy board, through main(): exit 1, the refusal names
|
||||
# --take-over, and the wire never carried an X.
|
||||
code, out, err_text, calls = run_main(["--plain", "status"], token_env="sekrit",
|
||||
claim_slot=True, socks_out=board)
|
||||
check_eq(code, 1, "a busy board is a refusal through main() too")
|
||||
check("--take-over" in err_text, "and the refusal names the flag in the terminal")
|
||||
check_eq([f for f in (client_frame_payloads(board[-1]) if board else [])
|
||||
if f == b"X"], [], "and refused means refused: no X went out anyway")
|
||||
|
||||
print("the target-side verifier")
|
||||
# Twice now the verifier itself was the bug -- mislabelled LEDs, unnamed key codes that
|
||||
# looked like lost keystrokes -- so the thing that decides what "it arrived" means gets
|
||||
|
||||
+89
-4
@@ -31,6 +31,7 @@ without one -- this tool will not be the component that quietly downgrades:
|
||||
relayctl.py --fingerprint $FP --host keyrelay.local listen # status heartbeats
|
||||
relayctl.py --fingerprint $FP --host keyrelay.local disarm
|
||||
wl-paste | relayctl.py --fingerprint $FP paste --yes # type what is on the clipboard
|
||||
relayctl.py --fingerprint $FP --take-over status # take a bridge left held, then use it
|
||||
|
||||
The passphrase is taken from --token, then $KEYRELAY_TOKEN. It is never written to
|
||||
disk and never put in a URL (the /status HTTP endpoint takes it as a query parameter
|
||||
@@ -54,6 +55,15 @@ reversible by definition. There is no gesture recording, no auto-click and no "m
|
||||
OK button", because the moment this tool could actuate a target on its own it would be a
|
||||
different and worse device than a keyboard.
|
||||
|
||||
A busy board is a refusal, never a fallback. If another controller holds the bridge,
|
||||
this tool authenticated into the board's one pending-claim slot and says so -- and
|
||||
names --take-over, which is the same one deliberate act the page's TAKE OVER button
|
||||
sends: `X` after the passphrase. The handover runs through the board's release funnel
|
||||
-- the incumbent is told (E12), every key and every mouse button is released -- and the
|
||||
session continues DISARMED. `--take-over` against a free board is a no-op, not an error.
|
||||
Neither can arm anything: the takeover cannot produce an armed board, and the arm still
|
||||
needs its own deliberate command, exactly as it did before anyone was displaced.
|
||||
|
||||
What the TLS in this tool does NOT do is make anything safe by itself. An encrypted session is
|
||||
still only a controller that has to know the passphrase, and a session that drops is a lost link
|
||||
the board answers by releasing every key and every mouse button. The certificate says *which*
|
||||
@@ -98,6 +108,14 @@ MOUSE_DELTA_MAX = 127 # one signed byte, per axis, per frame
|
||||
MOUSE_BUTTON_MASK = 0x1f # five buttons: all the report can carry
|
||||
MOUSE_FRAME_MS = 8 # the pace the controller page sends at, matched here
|
||||
|
||||
# How long to wait after `OK` for the board's adoption status. A controller gets
|
||||
# `OK` and a status frame back to back (the firmware's auth path sends both); a
|
||||
# socket the board parked as a PENDING CLAIM gets `OK` and silence, because the
|
||||
# status frame belongs to the controller alone. Half a second is orders of
|
||||
# magnitude more than one LAN round-trip, and it is the board's own behaviour --
|
||||
# this tool never sends a frame to guess at an answer it was already owed.
|
||||
ADOPT_PROBE_S = 0.5
|
||||
|
||||
# Button name -> the report's bit, in the order the HID report descriptor lays them out.
|
||||
# NOTE the DOM's MouseEvent.button numbers are NOT this order (there 1 is middle and 2 is
|
||||
# right), which is why src/web_page.h carries its own mapping and both are pinned by
|
||||
@@ -466,10 +484,17 @@ class Receiver:
|
||||
class Relay:
|
||||
def __init__(self, host: str, port=None, token: str = "", timeout: float = 5.0,
|
||||
fingerprint: str = "", ca: str = "", insecure: bool = False,
|
||||
plain: bool = False) -> None:
|
||||
plain: bool = False, take_over: bool = False) -> None:
|
||||
self.host = host
|
||||
self.timeout = timeout
|
||||
self.token = token
|
||||
# A status frame that arrives while this side is waiting for some OTHER
|
||||
# reply is the freshest truth about the board there is -- and the adoption
|
||||
# probe below depends on not losing it: the firmware sends a controller's
|
||||
# `OK` and its status back to back, often in one batch, and abandoning the
|
||||
# batch at the OK would make a free board look busy for exactly the wrong
|
||||
# reason. Held here, read once by the probe.
|
||||
self._pending_status = None
|
||||
|
||||
# The transport decision happens before a socket exists: "no trust
|
||||
# argument" must refuse in the terminal, not connect and find out.
|
||||
@@ -565,14 +590,55 @@ class Relay:
|
||||
if reply != "OK":
|
||||
raise RelayError(f"authentication refused: {reply or 'no reply'}")
|
||||
|
||||
# Did the board ADOPT us? `OK` means the passphrase was right; the frame
|
||||
# that follows is the answer to "is the bridge yours". A controller gets its
|
||||
# status immediately (auth sends OK then status in one go); a socket the
|
||||
# board parked in its spare slot as a pending claim -- another controller
|
||||
# holds the bridge -- gets `OK` and nothing else, because the status frame
|
||||
# belongs to the controller alone. Silence here is therefore the board
|
||||
# reporting `busy` in exactly the terms it chose: a link state, never an
|
||||
# identity. Reading is all this costs -- no frame is sent to guess at an
|
||||
# answer a controller was already owed.
|
||||
if self._pending_status is not None:
|
||||
# The status rode in with the OK (the usual case on a LAN): adopted,
|
||||
# no waiting, and no timeout being mistaken for a fact.
|
||||
self._pending_status = None
|
||||
self.busy = False
|
||||
else:
|
||||
self.busy = self._poll(want_status=True, timeout=ADOPT_PROBE_S) is None
|
||||
self.took_over = False
|
||||
if self.busy:
|
||||
if not take_over:
|
||||
# The refusal names the flag rather than letting some later command
|
||||
# earn an E6 nobody can interpret. Free the claim slot for the one
|
||||
# person who decides, promptly.
|
||||
self.sock.close()
|
||||
raise RelayError(
|
||||
"another controller holds this bridge: the board authenticated "
|
||||
"us as a pending claim and kept the keyboard. Re-run with "
|
||||
"--take-over to take it: the incumbent is told (E12), every key "
|
||||
"and every mouse button is released through the release funnel, "
|
||||
"and the board comes back DISARMED -- nothing types until the "
|
||||
"new session arms on purpose.")
|
||||
# The one deliberate act, and the only frame beyond T and K a claim may
|
||||
# send. The board tells the incumbent, runs the funnel, lands DISARMED,
|
||||
# and adopts us: OK, then status, like any other authenticated start.
|
||||
self._send("X")
|
||||
self.took_over = True
|
||||
|
||||
# -- plumbing -----------------------------------------------------------
|
||||
|
||||
def _send(self, text: str) -> None:
|
||||
self.sock.sendall(encode_frame(OP_TEXT, text.encode()))
|
||||
|
||||
def _poll(self, want_status: bool, timeout: float):
|
||||
# The whole batch is drained before any early return: a status frame that
|
||||
# rides in behind the reply this call is waiting for is kept in
|
||||
# _pending_status, not abandoned with the generator. Losing it here is how
|
||||
# the adoption probe would read a free board as busy.
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
hit = None
|
||||
for opcode, payload in self._read_any(max(0.0, deadline - time.time())):
|
||||
if opcode == OP_PING:
|
||||
self.sock.sendall(encode_frame(OP_PONG, payload))
|
||||
@@ -582,14 +648,18 @@ class Relay:
|
||||
text = payload.decode(errors="replace")
|
||||
if text.startswith("S{"):
|
||||
if want_status:
|
||||
return json.loads(text[1:])
|
||||
hit = json.loads(text[1:])
|
||||
else:
|
||||
self._pending_status = json.loads(text[1:])
|
||||
elif text == "OK":
|
||||
if not want_status:
|
||||
return text
|
||||
hit = text
|
||||
elif text.startswith("E"):
|
||||
raise RelayError(f"board said {text!r}")
|
||||
else:
|
||||
print(f"board: {text}", file=sys.stderr)
|
||||
if hit is not None:
|
||||
return hit
|
||||
if not want_status:
|
||||
return None
|
||||
return None
|
||||
@@ -861,6 +931,13 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
||||
ap.add_argument("--plain", action="store_true",
|
||||
help="plaintext ws:// on port 80: only for boards built with "
|
||||
"-D KR_TLS=0 (the rollback); a TLS board refuses this")
|
||||
ap.add_argument("--take-over", action="store_true",
|
||||
help="if another controller holds the bridge, take it (X): the "
|
||||
"incumbent is told (E12), every key and mouse button is "
|
||||
"released through the release funnel, and the board comes "
|
||||
"back DISARMED. Without it a busy board is a refusal; "
|
||||
"against a free board it is a no-op. Neither path arms: "
|
||||
"arm is still its own deliberate command")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("status", help="print what the board reports")
|
||||
@@ -994,10 +1071,18 @@ def main() -> int:
|
||||
try:
|
||||
relay = Relay(args.host, args.port, args.token, args.timeout,
|
||||
fingerprint=args.fingerprint, ca=args.ca,
|
||||
insecure=args.insecure, plain=args.plain)
|
||||
insecure=args.insecure, plain=args.plain,
|
||||
take_over=args.take_over)
|
||||
except (OSError, RelayError) as exc:
|
||||
print(f"cannot use the board at {args.host}: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if args.take_over:
|
||||
# Say out loud which of the two no-surprises paths this took -- the
|
||||
# operator clicked a takeover, and "what happened to the other session"
|
||||
# is the question this answers before any keystroke goes out.
|
||||
print("took the bridge -- the incumbent was told (E12), every key and mouse "
|
||||
"button was released, and the board is DISARMED" if relay.took_over
|
||||
else "the board was free -- --take-over had nothing to take")
|
||||
|
||||
try:
|
||||
if args.cmd == "status":
|
||||
|
||||
Reference in New Issue
Block a user