commit 9bc8d5213c0aa376e8ae8086b5ba3188fd964739 Author: ducoterra Date: Sun Nov 8 00:20:33 2026 -0500 Initial import: keyrelay, a WiFi-controlled USB HID keyboard Browser or terminal controller drives an ESP32-S3 that acts as a Logitech G815 into a target machine running no software. Disarmed by default; arming requires the passphrase, the board button, or the serial console, and every fail-safe path cancels in-flight work. - src/: firmware with pure, host-testable modules (keystate, proto, text, ws_codec, sha1), the WebSocket control server, and the served page - tests/: host suites for the pure modules, relayctl parity, and page JS - tools/: relayctl terminal controller, serial console, bootloader helper, and an evdev target watcher - platformio.ini: bridge (USB HID) and console (no-HID) environments - README.md: single source of truth for behaviour, protocol and safety LLM-Generated: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b890e8b --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# PlatformIO +.pio/ +.pioenvs +.piopm + +# host test build output (tests/run_tests.sh recreates it) +tests/build/ + +# python +__pycache__/ +*.py[cod] + +# editors +*.swp +.DS_Store + +# never commit a passphrase, a capture, or a recording of what was typed +*.env +token +*.capture +*.log diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..92d5042 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,132 @@ +# AGENTS.md — working on keyrelay + +A WiFi-controlled USB HID keyboard: laptop browser → ESP32-S3 board → USB into the target. +The target runs no software and sees a Logitech G815. Everything the board does is gated on +one idea: **the difference between a remote keyboard and an unattended injection device is a +human deciding, live, with the arming light on.** Changes that quietly move that line are the +ones to catch here. + +The README is the single source of truth for behaviour, protocol, safety and measurement. +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, "Nine bugs"). + +## The invariants + +1. **State, not events.** `S=:` restates the *entire* desired key state. Applying + the same state twice is a no-op; any one message resynchronises the target completely. Never + introduce key-up/key-down events, sequence numbers, or a resynchronisation timer. Every + stuck-key horror story in HID injection tooling is an event-protocol bug. +2. **Disarmed is the only default.** Boot, reload, reconnect, focus loss, socket loss, target + detach, bus suspend, queue overflow and 8 s of controller silence all disarm, and disarming + releases every key. Arming needs the passphrase, the board button, or the serial console — + nothing else may gain the ability. +3. **Every fail-safe cancels work in flight; nothing "pauses".** `releaseEverything()` in + `main.cpp` is the single funnel: it clears the report queue, raises the keys, ends the chord + and discards a pasted run. Any new state that can outlive a disconnect (a queue, a timer, a + run) must be cancelled there too, or it will keep acting after the operator stopped asking. +4. **Nothing is stored.** Counters only. No keystrokes, characters, key names or text in NVS, + logs, `Serial.printf`, error messages or status frames — `src/main.cpp` prints *lengths* for + text runs, never content. No macro store, no schedule, no replay, no auto-arm. +5. **Reject, never guess.** A malformed frame leaves the previous state in force. A character + the map cannot express is `E9` and *none* of that frame is typed — a paste that quietly loses + a character is worse than one that refuses. Silent sanitising is never the default. +6. **`Ctrl+Alt+Del` is fixed on the device.** It is never composed from wire bytes; on a Linux + 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. + +## Three tables that must agree + +| table | where | names | pinned by | +|---|---|---|---| +| `CODE_TO_USAGE` | `src/web_page.h` | physical position → usage | `tests/test_relayctl.py` | +| `CODE_TO_USAGE` | `tools/relayctl.py` | same, for the terminal | `tests/test_relayctl.py` | +| `strokeFor()` | `src/text.h` | character → modifier + usage | `tests/test_text.cpp` (whole printable range walked) + tool/board agreement on all 128 ASCII codes | + +Chord buttons carry their **wire bytes** in `data-chord`, not a name — which is why +`tests/test_proto.cpp` asserts every labelled chord against the combination its label promises. +A mislabelled button parses cleanly, is correctly gated, arrives intact and is the wrong key; +Alt+Tab was `05:04` (Ctrl+Alt+A) until that test existed. `TEXT_FRAME_MAX` / `TEXT_JOB_MAX` in +the page and `TEXT_FRAME_MAX` / `MAX_PASTE_CHARS` in `relayctl.py` are hand-copied from +`config.h` and are asserted equal in tests — change one, the suite fails, that is the point. + +## Build, test, flash + +```bash +tests/run_tests.sh # 955 checks, no hardware +SANITIZE=1 tests/run_tests.sh # + ASan/UBSan — use it for anything touching buffers +/tmp/piovenv/bin/python -m platformio run -e bridge -e console +python3 tools/to_bootloader.py # 1200-baud touch → ROM download mode +/tmp/piovenv/bin/python -m platformio run -e bridge -t upload +``` + +Environment facts that will otherwise cost you a cycle: + +- **`pio` on PATH is broken** here (`ModuleNotFoundError: platformio`). Use the venv above. If + it is gone (`/tmp`), find another interpreter with the package: + `find / -maxdepth 6 -name platformio -type d -path '*site-packages*'`. +- **No `g++` on the host.** `run_tests.sh` falls back to a local podman image listed in its + `images[]`. The page suite deliberately runs on the *host* so it works without node in there. +- **Build `console` too.** `ARDUINO_USB_MODE=1` compiles `KR_HAVE_HID == 0`, which is the only + thing that checks the no-HID `#else` branches. +- **The page is served from flash.** A change to `src/web_page.h` is invisible on the network + until the board is reflashed — and a tab that is already open keeps the old page in memory + until it is reloaded. Confirm with `curl -s http://keyrelay.local/ | wc -c` against the size + of the raw string in `src/web_page.h`, and grep the served bytes for the element you added. +- This directory is **not a git repository**, so there is no diff or revert here. Back up + before sweeping edits. + +## Verification standard + +A feature that lives on the device is not done when the source builds — it is done when the +device is running it and something observed it. Concretely: + +- Protocol/page claims → the host suites, plus `curl` against the served page. +- Keystroke delivery → `tools/watch_target.py` on the target (evdev, focus-independent); it is + the only thing that says what the target actually received, including *which* keys a chord + really was. +- Text runs → check the `typed:` summary and press count, not the scrolling stream. + +When you write a check, name the mechanism it exercises. "Silence disarms" sat in the README's +safety list while nothing compared `lastInboundMs()` to `kNoClientTimeoutMs`; the documented +verification passed because killing the controller process closes the socket, and the +socket-close path disarms. The check proved a different property from the one it was named after +and nobody noticed, until a run that keeps typing on its own made the difference matter. It is +wired now — but ask, of every green check, *what would still pass if this were broken?* + +## Editing rules + +- **Pure modules** (`keystate.h`, `proto.h`, `text.h`, `ws_codec.h`, `sha1.h`) include no + Arduino or ESP-IDF headers. That is what lets the identical code run on a host under + sanitisers. Keep it. +- **`web_page.h`** is a C++ raw string: no compiler looks at the HTML or JS. Run + `tests/test_page.js` after touching it (it executes the page against a DOM shim and checks + that every element id the script reaches for exists in the markup). +- **A protocol change is four edits, minimum**: `proto.h` (parser + `format*` + its header + comment), `web_page.h`, `relayctl.py`, and the README Wire protocol table + error-code list. + Tests that pin the wire format are `test_proto.cpp` and `test_relayctl.py`. +- **Docs move with the code.** Update in the same change: README intro bullets and check count, + Safety model, Security model trust-boundary table, Controller page, Wire protocol, + Architecture file map, Tests table, Backlog, and the docstring at the top of whichever module + you changed. This README documents *why* decisions were made — keep the reasoning, don't just + restate the behaviour. +- Secrets: never in a file, a URL, a log or a shell history. The serial `token` command does not + echo it; keep it that way. `/status` takes the token as a query parameter because `curl` is + the point of that endpoint — do not add the token to any other URL. +- `TODO.md` holds **open items only**; clear it when they are done. The durable record is the + README: a fixed bug belongs in "Nine 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. + +## Current defaults worth knowing + +Default passphrase `changeme` (the page and `status` shout about it via `dflt:1`); AP +`keyrelay-ctl` / `keyrelay-ctl-pass`; `http://keyrelay.local` or `192.168.4.1`; paste pace +12 ms/character, 120 characters per frame, 2048 per run; silence disarm 8 s; NVS holds the +station credentials and passphrase and survives reflashing. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5e7325 --- /dev/null +++ b/README.md @@ -0,0 +1,836 @@ +# keyrelay — a WiFi-remote USB keyboard on the M5Stack AtomS3 Lite + +Type on your laptop; the keystrokes land on a *different* machine, over the air, as +genuine USB HID input — including Enter and Ctrl+Alt+Del. The board enumerates on the +target as a plain boot-protocol keyboard, so the target needs no software, no driver +and no network. The controller is a web page served by the board itself. + +``` + your laptop target machine + ┌──────────────────┐ ┌──────────────────┐ + │ browser tab │ WiFi │ │ + │ keydown/keyup ├────WebSocket──►│ AtomS3 Lite │ + │ of the page │ (token) │ keyrelay │ + └──────────────────┘ │ │ + └────────┬─────────┘ + │ USB HID boot keyboard + ▼ + whatever has keyboard focus: + a login prompt, a BIOS setup, + a VT, a text editor +``` + +``` +disarmed LED dark nothing you type goes anywhere +armed LED solid red every keystroke in the tab goes to the target +``` + +* **Nothing types until you arm it.** Boot, reload, reconnect, focus loss, socket loss + and 8 s of silence all disarm, and disarming sends "all keys up". +* **Arming needs a passphrase** (or a thumb on the board's button). The LED is readable + from across the room, so the state is not a secret between you and the screen. +* **The keystrokes are not stored, logged or replayable.** The board keeps counters and + the live key state, and nothing else — no sequence storage, no macro, no script, nothing + it can type later. Pasted text is typed from RAM and forgotten; the serial console has no + command that types anything at all. It is a keyboard, not a payload dropper. +* **955 checks** cover the key-state machine, the wire protocol, the text→keystroke map, + SHA-1/base64, the WebSocket codec and the controller page's own script before anything is + flashed, and the target-side verifier proves what actually arrived. + +--- + +## Contents + +- [Is this the right tool](#is-this-the-right-tool) +- [Quick start](#quick-start) +- [The one USB port, and why the control path is WiFi](#the-one-usb-port-and-why-the-control-path-is-wifi) +- [Safety model](#safety-model) +- [Security model and trust boundaries](#security-model-and-trust-boundaries) +- [Linux notes (the target and the laptop)](#linux-notes-the-target-and-the-laptop) +- [Layout caveats](#layout-caveats) +- [Controller page](#controller-page) +- [Wire protocol](#wire-protocol) +- [Serial console](#serial-console) +- [Architecture](#architecture) +- [Tests](#tests) +- [End-to-end verification](#end-to-end-verification) +- [Measured behaviour](#measured-behaviour) +- [Troubleshooting](#troubleshooting) +- [Design decisions](#design-decisions) +- [Backlog](#backlog) +- [Restoring the sibling firmware](#restoring-the-sibling-firmware) + +--- + +## Is this the right tool + +**Good fits** + +- Driving a machine that has no keyboard and should not get one: a headless box, a + rack server at a console, a kiosk, an instrument, a second machine mid-install. +- Working on a machine you are allowed to touch but not sit at — BIOS/UEFI setup, + bootloader and recovery screens, a login prompt, a full-screen game on a laptop + whose keyboard you would rather not wear out. +- Keeping an agent session live on a machine you are not typing into (that is the + sibling project [`../jiggler`](../jiggler), which is a simpler build of the same board). +- Anything where a KVM-over-IP would be right and you do not need video, mouse or + audio, and you have 8 dollars and a USB port to spare. + +**Bad fits — use something else** + +- **You need the screen.** There is no video path. PiKVM or TinyPilot + do video + keyboard + mouse; this is keyboard only. +- **You want unattended automation.** Deliberately impossible here (see above). If you + want scripted input on a machine you own, `xdotool`/`ydotool`/`uinput` on that machine + is the honest tool, and a Rubber Ducky is what this design is *not*. +- **A machine you are not authorised to input to.** Physical access is the whole + security model of every USB HID tool, and "I could plug it in" is not authorisation. + Use it on your own hardware or where you have explicit permission. + +### What this deliberately is not + +There is no macro store, no auto-arm at boot, no stored or scheduled sequence, no replay, +and no key logging — in firmware or in the tools. Not because it is hard (it is about ten +lines of `Keyboard.print()`), but because the difference between "a remote keyboard" and +"an unattended injection device" is exactly whether a human is deciding, live, with the +arming light on. + +**Where the line sits.** Text you have just pasted and then deliberately sent is a +keyboard doing its job, so there is a command for it: the page's **Send text** box, or +`wl-paste | relayctl paste --yes`. It is gated like everything else — armed, a target +attached, one deliberate press of a button that says how many characters it will type — +and it cannot outlive that decision. The characters sit in RAM until they have gone out +and then they are gone: nothing is stored, nothing is scheduled, and release-all, disarm, +focus loss, a silent controller or a detached target cancels the rest of a run mid-word. +What stays absent is the part that would change what this device *is* — something the +board remembers, assembles on its own, or fires without a human in the loop. + +Two consequences hold either way. The CLI's ordinary commands still press one key per +invocation, which is all verification needs; scripting 30 of them is possible and would +still be 30 deliberate actions with the LED on. And a captured board is a keyboard, not a +recording — pull it out and there is nothing in it to find. + +--- + +## Quick start + +Needs [PlatformIO](https://platformio.org/) and the board's source on this machine. +The board is an ESP32-S3 with no USB-UART bridge, so all flashing is over the USB-C +connector it already uses. + +```bash +cd keyrelay +tests/run_tests.sh # 955 checks, no hardware needed +pio run -e bridge # ~1.1 MB image, ~35% of the 3 MB app slot +python3 tools/to_bootloader.py # 1200-baud touch -> ROM download mode +pio run -e bridge -t upload # flash +``` + +Build `console` as well as `bridge` before flashing: it compiles with `ARDUINO_USB_MODE=1`, +which is the only thing that checks the no-HID `#else` branches. And if `pio` on this machine is +a broken launcher rather than a working command (`ModuleNotFoundError: platformio`), +`python3 -m platformio` from an interpreter that has the package works instead — see +[`AGENTS.md`](AGENTS.md), which lists this and the other environment traps. + +Then, with the board plugged into the **target**: + +```bash +python3 tools/console.py wifi "My SSID" "the wifi passphrase" # optional: join a LAN +python3 tools/console.py token "a-long-passphrase-you-pick" # do this one, always +python3 tools/console.py status +``` + +Now, on the **laptop**: + +| the board is on | open in a browser | because | +|---|---|---| +| your WiFi network | `http://keyrelay.local` (or the `ip=` from `status`) | mDNS, then direct IP | +| its own access point | `http://192.168.4.1` after joining `keyrelay-ctl` | no infrastructure needed | + +Enter the passphrase, press **connect**, press **arm** — the LED goes solid red — and +type in that tab. Enter goes. Ctrl+Alt+Del is the button (twice), because your laptop's +own OS swallows the real combination before a browser can see it. + +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 +board keeps, and **stop typing** abandons the rest. From a terminal it is +`wl-paste | relayctl.py paste --yes` (or `xclip -o -selection clipboard`, `pbpaste`). + +To put the sibling project back on the board: +`cd ../jiggler && python3 .../to_bootloader.py && pio run -t upload`. + +--- + +## The one USB port, and why the control path is WiFi + +The AtomS3 Lite's USB-C is wired straight to the ESP32-S3's native USB pads (GPIO19/20), +and those pads belong to exactly one peripheral and exactly one host at a time. A board +that is a keyboard to machine A therefore cannot simultaneously be a serial modem to your +laptop. That single hardware fact decides the architecture: + +| option | works? | why this project chose as it did | +|---|---|---| +| control over USB, HID to the same laptop | yes, pointless | the target *is* the laptop; that is the `jiggler` use case | +| second USB port / USB host shield | no | the board has one connector, and ESP32-S3 USB-host + device at once is not a thing | +| USB gadget on a Linux SBC (PiKVM-style) | yes | real answer for video + mouse; needs a Pi, a PoE HAT, and 10× the cost | +| **WiFi control, USB HID out** | **yes** | one connector stays free for the target, no host software at all | + +The consequence worth stating plainly: the board draws its power and its USB identity +from the target, and its *instructions* from your network. Pulling it out stops everything +immediately — that is a feature. + +--- + +## Safety model + +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. +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". +3. **Armed is displayed on the device.** Solid red LED while armed and attached. If the + target is not attached the LED blinks red instead, because "armed" without a target + means keystrokes are being dropped, and that deserves to look wrong. +4. **Silence disarms.** No inbound frame for `kNoClientTimeoutMs` (8 s) → all keys up, + disarmed. Covers the laptop sleeping, the browser being killed, the WiFi dying, the + network going down — every way a controller can vanish without saying goodbye. The + browser sends a keepalive every 2 s and answers the board's own 15 s WebSocket pings, + so a live tab holds it open indefinitely; `relayctl` does the same while it types. This + is checked in `loop()` against `ControlServer::lastInboundMs()`, and it is checked + *before* the typewriter is driven: it is the one rule that stops a run of pasted text + when the controller dies without a parting packet. +5. **Focus loss releases every key.** The tab blurring or being hidden sends "all keys + up" and disarms (unless you tick *stay armed when this tab loses focus*, which still + releases the keys — you cannot keep a held Ctrl alive by tabbing away). +6. **The target taking the bus away disarms.** USB detach or host suspend clears the + report queue, releases the keys and disarms; `ARDUINO_USB_*` events drive it. +7. **A full queue is a stop, not a drop.** 48 queued state changes without a drain means + something is outrunning the USB path, so the bridge releases everything, reports `E7` + and disarms rather than typing out of step with what the operator sees. +8. **State, not events.** The controller restates the *whole* key state on every event + (see [Wire protocol](#wire-protocol)), so a lost frame cannot leave a modifier stuck + on the target. This is the single most important design decision in the project: every + "stuck Ctrl" horror story in HID injection tooling is an event-protocol bug. +9. **A paste cancels; it never pauses.** `R` means "stop typing", not "hold on". The run + is dropped on release-all, disarm, target detach or suspend, queue overflow, focus + loss and silence — every fail-safe above routes through the same `releaseEverything()` + that clears the queue, raises the keys and discards what was still to type. The board + never holds a run that outlives the decision to send it. + +The physical kill switch is the button: press it and the keys come up, no network, no +passphrase, no software required. + +--- + +## Security model and trust boundaries + +| actor | can | cannot | +|---|---|---| +| Anyone on the target (its own OS) | see a Logitech G815 keyboard in its device list; read the CDC console; type on itself | arm or drive the bridge remotely, detect that a second controller exists | +| Anyone on the board's network | fetch the page; open a socket; learn that a keyrelay exists | arm, send a key, send a chord, type a run of text, read `/status` — all need the passphrase | +| 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) | +| The owner of the WiFi | join the network, then everything above | — the network *is* the perimeter; 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 | — | + +**What the passphrase does and does not do.** It authenticates the *control* channel; it +is not confidentiality for keystrokes. The page is served over plain HTTP and the +WebSocket is `ws://`, so on a network you do not control, keystrokes are readable by +anyone who can see the traffic and the token is visible at first contact. This is a +deliberate v1 trade: WSS would need either a trusted certificate (no way to get one for +`keyrelay.local`) or certificate-pinning ceremony in a browser (which the target may not +even have). Documented in [Backlog](#backlog) with the two honest fixes. + +Practical rule: **use it on a network you trust**, or on the board's own access point +whose WPA2 passphrase is a separate secret (`kr::kApPass`, change it in `src/config.h`). +Both are better than either alone: the AP means you never have to put the board on +someone else's LAN. + +**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. + +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 +can watch the counters, and `curl` keeps working mid-session. Read-only access is still +gated by what it reveals — `/status` needs the passphrase, the page needs it before it can +arm or type. If that ever goes too far, the `ap off`/network config is the blunt instrument; +there is no per-client ACL. + +**Identity.** The board reports itself as `046d:c33f` "Logitech Gaming Keyboard G815", +matching the sibling `jiggler` firmware so the two are indistinguishable from the host +side and `/dev/input/by-id` names stay stable across reflashes. This makes the device +unremarkable in `lsusb`; it does not hide it from anyone who looks for a keyboard that +appeared at 14:32. It is your hardware and a real Logitech id, so: personal use only. +To make it honest instead, change the five `kUsb*` constants in `src/main.cpp` — nothing +else depends on them. + +**What is never stored:** keystrokes, characters, key names, SSIDs beyond the configured +one, client addresses, or anything else about what was typed. Counters only (`keys`, +`chords`, `dropped`, and `typed`/`typing` for the paste path), which is what the status +display needs to be useful. A run of pasted text exists in RAM from the moment a frame is +accepted until the last character is typed or the run is cancelled, and nothing about it +survives that — no queue across a disconnect, no place to write it. The board has no clock +it trusts and no filesystem it writes to. Turning this into a logger would be a fork, not +a feature flag. + +--- + +## Linux notes (the target and the laptop) + +**Ctrl+Alt+Del on a Linux target can reboot it.** On the graphical session it is usually +inert; on a virtual terminal `systemd` maps it to `ctrl-alt-del.target`, which by default +**reboots the machine** (seven CAD presses in two seconds force it). This is why the +combination is a button with a two-press confirm and a 3-second window, never a forwarded +keypress, and why `relayctl cad` refuses to run without `--yes`. + +**Ctrl+Alt+F1…F7 works** — which is a bigger deal than CAD: it switches the target's +virtual terminal, and a getty on an unused VT with **autologin** enabled will hand you a +shell as that user. This project puts the VT switches on the special-keys pad so they are +deliberate clicks rather than a chord you can fat-finger. + +**Alt+SysRq+b etc.** are reachable too (the magic SysRq key is off by default on most +distros). The pad does not offer them; the keyboard table can produce them, which is what +a keyboard is. + +**Keys the laptop's OS steals** (this is the laptop side, and it is the browser-capture +limitation, not a bug): `Ctrl+Alt+Del`, `Ctrl+Alt+F1…F7`, `Alt+Tab`, `Super`, +`PrintScreen`, and on GNOME/KDE a growing list of media and tiling shortcuts are grabbed +by the compositor and never reach the page. Three answers, in order of preference: + +1. the **special-keys pad** on the page — each of those is one deliberate click, and the + board synthesises the chord so the laptop never sees it; +2. run the laptop's compositor in a mode with fewer grabs while you work; +3. the **OS-wide capture client** (see [Backlog](#backlog)) — an evdev reader on the laptop + that forwards everything before the compositor gets it. The protocol is already + designed for it; it is a client-side addition, not a firmware change. + +**While armed, the page swallows everything** with `preventDefault()`, so `Ctrl+W` will +not close the tab, `Ctrl+R` will not reload it and F11 will not fullscreen it. It also +means you cannot type into the page's own fields while armed — disarm first. + +--- + +## Layout caveats + +`KeyboardEvent.code` names a *physical position* on a US-ANSI keyboard, and those +positions are what travel as HID usage ids. If the **target** is set to a non-US layout, +`KeyQ` still means "the key left of A" and the target maps it to whatever its layout says. +This is identical to what a physical KVM does, and the fixes are the same: + +- set the target's layout to English (US) — the one-line fix; +- or remap `CODE_TO_USAGE` in `src/web_page.h` for your laptop's board (it is one object + literal; `tools/relayctl.py` carries the same table and + `tests/test_relayctl.py` fails if the two drift). + +ISO keyboards: `IntlBackslash` (0x32) is the extra key next to Left Shift on ISO and +`Backslash` (0x31) is the ANSI one; both are in the table. `AltRight` is `AltGr` and +travels as its own modifier bit, which is what layout engines on the target expect. + +**Pasted text goes through a third table**, `strokeFor()` in `src/text.h`: character → +modifier + usage, US-ANSI, and deliberately not position-based, because what is being +pasted is characters. Same caveat, same fix — the target must be on a US layout. A +character with no entry is refused (`E9`) rather than approximated, and `test_text` walks +the entire printable range so an omission cannot hide, while `test_relayctl` proves the +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`, ~35 KB, 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 C++ raw string is +invisible to every compiler and a typo there fails in a browser, with a target live. + +| element | why it is there | +|---|---| +| 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 | "will my keystrokes land, and where" | +| **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 | +| **Release keys** and `Ctrl+Alt+Esc` | panic: every key up, disarm | +| *stay armed if this tab loses focus* | off by default; for the one workflow that needs it | +| **Send text** box, its button and its *stop typing* | paste something and have it typed as keystrokes; the button says how many characters, refuses to be armed-into accidentally, and is the only path text takes | +| *drop characters I cannot type* | off by default: a paste that quietly loses a character is worse than one that refuses | +| held-keys readout, keys/chords/text/chords counters | so "it is typing" is a fact you can see | +| log, *log each key sent*, **clear** | the board's errors verbatim plus one line per key pressed — never one per release, never a blank line, and a repeating line is counted rather than stacked | + +The caps/num indicators are read *from the target* (its HID output reports drive them), +not guessed — a caps-lock desync between controller and target is a real failure mode of +remote keyboards and this design cannot have it. + +--- + +## Wire protocol + +One WebSocket text frame = one command. ASCII, no JSON inbound, trivially hand-typed, +and impossible for one malformed field to desynchronise what follows it. + +| dir | message | meaning | +|---|---|---| +| c → d | `T` | authenticate; must be first (`kAuthTimeoutMs` = 5 s) | +| c → d | `H` | hello: status now | +| c → d | `A1` / `A0` | arm / disarm | +| c → d | `S=:,…` | **the complete key state** | +| c → d | `C` | Ctrl+Alt+Del (fixed in firmware, not read off the wire) | +| c → d | `Y=:,…` | one synthetic chord, `kChordHoldMs` (40 ms) | +| c → d | `P=:` | type pasted text, `` ms per character (0 = board default) | +| c → d | `R` | release every key, stay armed — **and drop a text run in progress** | +| c → d | `K` | keepalive | +| d → c | `OK` | accepted | +| d → c | `E:` | error (codes below) | +| d → c | `S{json}` | status, 1 Hz and on every change, incl. `typing` = characters of the run still to type | + +`` is the modifier byte as two hex digits, `` a HID usage id as two hex digits. +Both are strict: exactly two hex digits, no spaces, no trailing comma, usage `00` is not +a key, and a rejected message leaves the previous state in force — the safe outcome. + +**`S=` carries state, not events.** `S=00:` means "nothing is pressed". A press and its +release are two frames and therefore two HID reports, so a fast tap cannot vanish; +identical consecutive reports collapse, which is what keeps the queue short. A single +message resynchronises the target completely, which is why no key can get stuck. + +**`P=` is text, typed by the board.** `` is the bytes to type, so one frame carries +at most `kTextFrameMax` (120) characters and a longer paste is several frames; `` is +the pace asked for, and the board keeps it — expansion and timing live on the USB side +because a controller's clock, arriving over WiFi in whatever order the network delivers +it, is not a keyboard timing source. Each character is pressed and released on top of +whatever the operator is physically holding, and released back to it, so a paste can +neither let go of a held key nor strand one. The board answers `E9` for a character the +US-ANSI map cannot express and types none of the frame, rather than sending a paste that +quietly lost one; `E8` means it already holds `kTextJobCap` (2048) characters. A +controller sends the next frame when the status says `typing: 0` — the board's own count, +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. + +The same protocol is what a future OS-wide capture client would speak — see +[Backlog](#backlog) — which is why it lives in `src/proto.h` instead of inside the server. + +--- + +## Serial console + +`/dev/ttyACM0` is the CDC half of the same composite USB device, so the console appears +on whatever machine the board is plugged into: + +```bash +python3 tools/console.py # interactive, Ctrl-] to quit +python3 tools/console.py /dev/ttyACM0 status # one command, print the reply +``` + +| command | effect | +|---|---| +| `status` | the same JSON the controller gets, plus link, queue and text-run state (`text N chars typed, M still to go`) | +| `wifi [passphrase]` | station network to join (stored in NVS, reconnects) | +| `wifi clear` | forget it; the access point becomes the only way back | +| `ap auto\|force\|off` | AP policy: when the station is down / always / never | +| `token ` | control passphrase, 6–32 chars, never echoed back | +| `arm` / `disarm` | same as the button | +| `reboot`, `help` | | + +There is deliberately **no command that types anything** — the console is configuration, +not an input path. Replies are printed with a blocking write (the global serial timeout is +0 so logging into a port nobody reads can never stall `loop()`); without that, multi-line +replies truncate at the 128-byte CDC TX ring, which is exactly what happened on the first +build and is why `pollConsole()` bumps the timeout around each reply. + +--- + +## Architecture + +``` +src/ + config.h defaults: network, timeouts, caps, buffers (NVS overrides) + keystate.h pure modifier byte + pressed-set -> 8-byte HID report, rollover + text.h pure pasted text -> US-ANSI keystrokes, paced; run buffer and cancel + proto.h pure the wire protocol: parse/render commands, constant-time compare + sha1.h pure SHA-1 + base64 for the WebSocket handshake + ws_codec.h pure handshake request parsing + RFC 6455 frame codec + web_page.h the controller page, in PROGMEM + control_server.{h,cpp} sockets: HTTP routes, WebSocket lifecycle, auth, heartbeat + main.cpp glue: USB identity/HID, WiFi, report queue, chords, text run, + LED, button, watchdogs, serial console +tools/ + to_bootloader.py 1200-baud touch into ROM download mode (stdlib termios) + console.py serial console, one command or interactive (stdlib) + relayctl.py controller from a terminal: status/arm/disarm/key/chord/listen/paste + watch_target.py on the target: decode the board's evdev events, prove delivery +tests/ + run_tests.sh builds and runs everything (host g++ or a local container) + test_keystate.cpp test_proto.cpp test_text.cpp test_ws_codec.cpp + test_relayctl.py test_page.js (node, skipped when absent) +AGENTS.md operating manual for whoever edits this next: the invariants, the three + tables that must agree, the build traps, the verification standard +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 five modules marked *pure* 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 +before the first flash (a `reset()` that left the reader latched on the previous frame, an +unsequenced `_buf[_got++] = b ^ _mask[_got & 3]`, a missing FIN check that silently +accepted fragmentation, a mask key skipped on zero-length frames, and a +`"sec-websocket-key:"` prefix compared at the wrong length so the accept hash was +computed from `"ey: …"`). + +The keystroke path, end to end: + +``` +keydown/keyup -> full state S=.. -> Reader (RFC6455) -> proto::parse -> auth gate + -> KeyboardState::apply (dirty only if the report changed) + -> ReportQueue (dedupe identical, overflow = release + disarm) + -> drainReports: tud_hid_n_ready(0) ? USBHIDKeyboard::sendReport : keep queued + -> HID interrupt endpoint, boot protocol, 8-byte report -> target +``` + +`USBHID::SendReport` in the core is synchronous (it waits on a semaphore for the transfer +to complete, up to 100 ms), so `drainReports()` takes at most `kMaxReportsPerLoop` = 8 +reports per pass — bounded time in the USB path, and the control plane never waits on it. +Chords (Ctrl+Alt+Del, VT switches) are synthetic: the press is pushed, and after the hold +the *tracked* state is pushed back, so keys that were genuinely held before a chord +survive it and the tracker is never corrupted by something the operator did not type. + +The USB interface is confirmed boot-protocol on real hardware, which is the precondition +for Windows accepting Ctrl+Alt+Del as the secure attention sequence: + +``` +$ for i in /sys/bus/usb/devices/*:1.*/; do echo "$(basename $i) class=$(cat $i/bInterfaceClass) sub=$(cat $i/bInterfaceSubClass) proto=$(cat $i/bInterfaceProtocol)"; done +7-1:1.0 class=03 sub=01 proto=01 <- HID, boot subclass, keyboard protocol +7-1:1.1 class=02 sub=02 proto=00 <- CDC-ACM control +7-1:1.2 class=0a sub=00 proto=00 <- CDC-ACM data +``` + +Two build environments: `bridge` (the real thing, USB-OTG + TinyUSB) and `console` +(`ARDUINO_USB_MODE=1`: no HID, so it cannot type, but WiFi/page/WebSocket/LED/button all +live — for separating "the network side is broken" from "the HID side is broken" without +rebuilding. Arming refuses with `E5` there, on purpose). + +--- + +## Tests + +```bash +tests/run_tests.sh # uses g++ if present, else a local container image +SANITIZE=1 tests/run_tests.sh # + ASan/UBSan (recommended, and what found 5 bugs) +``` + +| suite | checks | what it pins down | +|---|---|---| +| `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_proto` | 193 | every command form, strict rejection of malformed state specs, bounds (cap on keys, token length and text frames), render/parse round-trip, error framing, constant-time compare, **every chord button's bytes matched against the label printed on it**, and the page's text constants pinned to `config.h` | +| `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_relayctl` | 81 | 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, and `TEXT_FRAME_MAX`/`MAX_PASTE_CHARS` pinned to `config.h` | +| `test_page` | 47 | 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, and cancellation by stop/disarm, plus every element id the script reaches for checked against the markup | + +Gaps, stated plainly: nothing here exercises real sockets, real WiFi associations, or real +USB transactions — those are the [end-to-end procedure](#end-to-end-verification) below. +The HTTP routing and auth-lifecycle code in `control_server.cpp` is the least-tested part +of the project; it is deliberately the thinnest layer, but it is not covered by a unit +test. + +--- + +## End-to-end verification + +Three machines' worth of roles on two boxes: the laptop is the controller, the board is +the bridge, and any Linux box (including the laptop itself) is the target. + +```bash +# 1. on the target: what actually arrived, decoded from evdev, focus-independent +python3 tools/watch_target.py --seconds 60 + +# 2. on the laptop: drive it from the terminal +export KEYRELAY_TOKEN='the-passphrase-you-set' +python3 tools/relayctl.py --host keyrelay.local status +python3 tools/relayctl.py --host keyrelay.local arm +python3 tools/relayctl.py --host keyrelay.local key Enter +python3 tools/relayctl.py --host keyrelay.local chord 04 2b # Alt+Tab, by hand +python3 tools/relayctl.py --host keyrelay.local cad --yes +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 +``` + +What a correct run proves, and how to tell: + +| claim | evidence | +|---|---| +| HID reaches the host | `watch_target.py` prints every press/release with a timestamp | +| text is correct | its `typed` line, with case from the shift state | +| Enter works | `Enter press/release` and `` in the typed text | +| Ctrl+Alt+Del works as a *combination* | `>>> Ctrl+Alt+Del <<<`, one line, not three | +| no stuck keys | `still held` is absent at the end of the run | +| **pasted text is keystrokes, not a shortcut** | the run's `typed` line reproduces the text, and the press count is roughly twice the character count — a paste that arrived as nothing, or as a single odd chord, means the character map or the pace, not the target | +| **Alt+Tab is Alt+Tab** | `LAlt press` then `Tab press` — and *not* `LCtrl`. This combination was `Ctrl+Alt+A` on the wire until it was caught by the label-vs-bytes test; `watch_target.py` is what settles any doubt about which keys the board actually sent | +| a paste stops | start a long one, press **stop typing** (or blur the tab, or press the board's button): the `typed` text stops mid-sentence and nothing further appears | +| 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` | + +`watch_target.py` finds the board by USB id, not device name, so it keeps working on a +different port or a renamed input node, and it accepts both the Logitech identity and the +Espressif default so it works on older builds. + +A pasted run prints one line per press *and* release, so a few hundred characters is a +lot of scrolling: watch the `key presses` and `typed` summary lines at the end rather than +the stream, and give `--seconds` enough room for the pace (`2048` characters at the default +12 ms is about 25 s of typing, plus the run's own margins). + +--- + +## Measured behaviour + +_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. + +``` +$ python3 tools/console.py status +status {"v":"1.0.0","up":7,"usb":1,"armed":0,"net":"ap","ssid":"keyrelay-ctl", + "ip":"192.168.4.1","keys":0,"chords":0,"drop":0,"roll":0,"leds":0, + "dflt":1,"ap":0} +link controller=no rejected=0 authfails=0 +queue 0 pending, 0 held, ok + +$ python3 tools/relayctl.py --host status # armed, typing, then disarmed + armed YES -- keystrokes are being forwarded + counters keys=… chords=0 dropped=0 + +$ python3 tools/watch_target.py --seconds 30 # on the same laptop + t= 0.42s A press + t= 0.48s A release + ... + key presses … + typed '...' +``` + +### Confirmed on hardware + +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 | +| footprint | RAM 26.7 % (87 400 / 327 680); Flash 35.5 % of the 3 MB app slot, measured with the paste path in | +| 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 | +| page delivery | `GET /` → 200, `Content-Length: 35164`, zero external references; 404 on unknown paths; 401 on `/status` without the passphrase; `keyrelay.local` resolves over mDNS | +| arming from the browser | the page armed the board over `ws://` on first try (observed as `controller=yes armed=1`) | +| keystroke delivery | 6/6 inert keys arrived on the target with matched press **and** release: F13, F15, F17, ScrollLock, CapsLock ×2 | +| 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 | +| lockout | 5 wrong passphrases → `E2`, for 30 s, from the page's own retry loop | +| queues | `drop=0 roll=0` throughout; `queue 0 pending, 0 held, ok` at rest | + +### Nine bugs this verification caught (all fixed) + +Worth listing, because each one is a class of bug that HID-over-network tools ship with: + +1. **CDC replies truncated at 128 bytes** — `setTxTimeoutMs(0)` meant "drop what does not + fit", and `serial status` came back cut mid-JSON. Replies now temporarily switch to a + blocking write and flush. A diagnostic tool that lies about state is worse than none. +2. **The CLI lowercased the whole handshake header block** — base64 is case-sensitive, so + every `Sec-WebSocket-Accept` compared wrong and the board looked broken while answering + correctly. Header *names* are case-insensitive; values are not. +3. **The CLI reported stale status** — the board heartbeats at 1 Hz and the key helpers do + not read while typing, so `disarm` printed `armed YES` from a heartbeat sent *before* + the command. `arm`/`disarm` now drain, then poll until the board actually reports the + state asked for — confirm, don't assume. +4. **The target-side verifier mislabelled every LED** — its table was copied from the HID + bit numbering, but Linux `EV_LED` indices are zero-based (`LED_CAPSL` is 1, not 2). + Verified against real events. +5. **Unnamed key codes looked like lost keystrokes** — F13–F24 had no names in the + verifier's table, so a perfectly delivered run appeared to drop its first three keys. + Now named, and unknown codes are counted rather than silently skipped. +6. **The single-controller gate was applied at `accept()`**, before the request was even + read — so while a controller was connected, *anything* else that touched the board got + 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. +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 + user who could not work out why the Pi ignored it, and provable because the page stores + the chord bytes on the button instead of a name to look up — so nothing on the browser + side could ever disagree with a label. Every labelled chord is now pinned to the + combination its label promises, by modifier byte and usage id. +8. **`relayctl`'s drain called two methods that do not exist** (`_send_frame`, + `_check_error`) while answering a ping, so any session outliving the board's 15 s ping + died on an `AttributeError` instead of reading its next frame. Only reachable on long + sessions, which is exactly what typing text is; it answered pings the way `_poll` does + and raises on error frames now. +9. **"Silence disarms" was documented and not implemented.** `kNoClientTimeoutMs` existed, + `ControlServer::lastInboundMs()` existed, and nothing ever compared them. Harmless while + the only input was a human keystroke (a vanished controller sends no more keystrokes), + and not harmless once a run can type for twenty seconds on its own: a laptop that slept + mid-paste, with a link that never got a FIN, would keep typing into the target. + `loop()` now checks it, before driving the typewriter, and clients keep-alive while they + type. The lesson is the one the whole project keeps relearning: a safety property that + is only written down is not a safety property. + +### The hazard nobody warns you about + +**When the target is the machine you are working on, your keystrokes land on your own +focused window.** The first live test sent `Escape`, and the target was the laptop running +this agent — so the keystroke interrupted the very session that sent it. That is not a bug +in the bridge; it is exactly the transparency the thing is designed for, pointed the wrong +way. So: + +- verify with keys that nothing reacts to — **F13–F24 and the Lock keys** are inert almost + everywhere, and they exercise the same code path as Enter; +- before arming against your own machine, decide what has focus; a terminal will *execute* + what you send; +- the page releases keys and disarms when its tab loses focus, which is also how you get + control back: click anything else, then click back. + +--- + +## Troubleshooting + +| symptom | cause, and what to do | +|---|---| +| the page does not have a feature the README describes | **the page is served from the board's flash**, so the image you built is not the image on the device. `curl -s http://keyrelay.local/ \| wc -c` and compare with the page in `src/web_page.h`; if they differ, reflash (`to_bootloader.py` then upload) and *reload the tab* — one already open still holds the old page in memory, passphrase notwithstanding | +| `pio run -t upload` sits at `Connecting…` | the app owns the USB pads. `python3 tools/to_bootloader.py`, or hold the button 3 s while powering up (LED solid red) | +| page will not connect | wrong address: `console.py status` for the real `ip=`; `net:"ap"` means you must join `keyrelay-ctl` | +| `keyrelay.local` does not resolve | mDNS is queried per-process with no cache on glibc (`mdns4_minimal [NOTFOUND=return]`), so a cold probe that misses returns "not known" immediately — **just retry**, or use the IP. Across VLANs/VPN it is often blocked outright | +| `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 | +| `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 | +| 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 | +| a key is stuck on the target | `R` / **Release keys** / the button. Then check whether the tab lost focus without the page noticing (a hidden tab still gets blur on most browsers) | +| Ctrl+Alt+Del does nothing on the target | on Linux this is normal on a graphical session and *fatal* on a VT; on Windows the interface must be boot protocol (confirmed above) | +| target keeps suspending the bus | the target suspends, which disarms by design; disable suspend or plug into a port that stays powered | + +--- + +## Design decisions + +**Why a web page and not a host daemon.** Because "install something on the laptop" is +the difference between a tool you can use in ten minutes on a machine you are borrowing +and a project you set up once at your own desk. The page is served by the board, so the +only software that has to exist is the browser you already opened. + +**Why full state instead of key events.** Covered under [Safety +model](#safety-model) point 8, and it is the decision that makes every other guarantee +here cheap: a lost message is corrected by the next one, so there is no resynchronisation +protocol, no sequence numbers, no stuck-key reconciliation timer. + +**Why the board expands and paces text, not the browser.** It is tempting to turn a paste +into existing `S=` frames client-side and change no firmware at all. Timing is the reason +not to: a controller reaches the board over WiFi, in frames, with whatever gaps the network +inserts, and a character is a press and a release with a hold either side of it on a target +that samples every millisecond. A browser tab that stalls for a second would produce either +a burst of keys or a run that outran the 48-deep report queue and tripped the +overflow-disarm. Expanding on the board also means the page and the CLI cannot disagree +about what a character types, which is the same argument that put the protocol in +`proto.h`. Cost: the character map exists twice — positions in `web_page.h`/`relayctl.py`, +characters in `text.h` — and a test pins all three together. + +**Why a paste refuses rather than sanitises.** Curly quotes, em dashes and accented +characters have no key on a US-ANSI keyboard. Dropping them quietly would send a passphrase, +a `rm` line or a config value that is *nearly* what was pasted, which is the failure mode no +one notices until it has landed. So the board answers `E9` naming the code point and types +none of the frame, the page says the same before it sends anything, and dropping is an +explicit, per-paste opt-in that says how many characters it removed. Fail-closed, and +loud. + +**Why no WebSocket library.** The control channel needs text frames, ping/pong and close. +A library buys fragmentation, extensions, TLS plumbing and many-client handling — attack +surface and heap, none of it wanted. The hand-rolled codec is ~200 lines of pure logic +with 135 host checks behind it, and every deviation from the subset is a hard close. + +**Why HTTP/WS without TLS.** See [Security model](#security-model-and-trust-boundaries). +The honest options in the backlog are a user-installed CA (works, annoying) or a +client-side proxy that terminates TLS with a pinned key. Pretending confidentiality here +would be worse than documenting its absence. + +**Why NVS for configuration.** So the passphrase and network live in the board rather than +in a header you recompile, and survive reflashing — and so a passphrase never has to enter +a shell history, a git diff or a build log. The `token` command deliberately does not echo +it back. + +**Why the sibling project's USB identity.** Two firmwares on one board that are +indistinguishable from the host side, and stable `/dev/input/by-id` names for the tools. +Cost: it is a borrowed VID/PID, so it is documented as personal-use and one edit away +from being honest. + +--- + +## Backlog + +Ordered by what would make the biggest difference first. + +1. **OS-wide capture client** (`tools/keyrelay-capture.py`, laptop side): grab the + keyboard with evdev, forward full state over the same protocol, `uinput` a shadow if + needed. Solves every compositor-stolen key and is the missing piece for + "transparently drive another machine". Firmware unchanged — the protocol was designed + for it. Needs root or `input` group + a `udev` rule; documented as opt-in because it + swallows the laptop's keyboard while running. +2. **TLS with a pinned certificate** (WSS): either an on-board CA the laptop trusts once, + or a `localhost`-terminating proxy. Until then: trusted network or the board's AP. +3. **A second, independent key path for the panic release**: a GPIO or button + double-click that force-releases without waiting for the loop's disarm path, so the + panic release does not depend on the code that just failed. +4. **Per-connection rate limiting** — a token bucket on state messages, so a wedged or + hostile client cannot pump the queue into the overflow-disarm path as a nuisance + attack (currently it disarms: safe, but noisy), and a cap on page fetches now that the + page is served even while a controller is live (each is ~35 KB, and `no-store` means a + determined client will ask for it again and again). +5. **mDNS also on the AP interface**, verified on each chipset — some builds answer only + on the station interface, which is why the docs say "IP if the name fails". +6. **WPA3/PMF on the SoftAP** and a per-board AP passphrase printed once at first boot, + instead of one shared constant in `config.h`. +7. **Report-rate telemetry**: measure the achieved report interval against the host's + poll rate, so the queue-depth choice is measured rather than reasoned. +8. **Mouse.** The descriptors and the queue would take a second HID interface, and every + safety argument above still holds. It is out because the request was a keyboard, and + half the care in this project went into *not* growing it. +9. **Pastes longer than one run.** `kTextJobCap` is 2048 characters and a controller that + wants to send more has to split the job itself. The flow-control hook already exists — + `typing` in the status frame is the board's own remaining count — so what is missing is + only a client policy that refills as the board drains, plus the `E8` retry that goes + with it. Deliberately not done yet: unbounded buffering on a keyboard bridge buys nothing + and enlarges the window in which text sits in RAM waiting to be typed. +10. **A paste map that is not US-ANSI.** `strokeFor()` in `src/text.h` is the single table + for characters; positions live in `web_page.h`/`relayctl.py`. A second layout means a + second character table *and* a second `CODE_TO_USAGE`, with the existing refusal + semantics intact (no entry → `E9`, never a guess). Until then the answer is "set the + target to US", which is one line on the target and zero bytes on the board. + +--- + +## Restoring the sibling firmware + +```bash +cd ../jiggler && pio run -t upload # after tools/to_bootloader.py, if needed +``` + +The `jiggler` source is untouched by this project; they are separate PlatformIO projects +for one board, and switching is one flash each way. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..4640904 --- /dev/null +++ b/TODO.md @@ -0,0 +1 @@ +# TODO diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..e124770 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,81 @@ +; keyrelay -- WiFi-controlled USB HID keyboard bridge for the M5Stack AtomS3 Lite. +; +; The board is an ESP32-S3FN8 (8 MB flash, no PSRAM) with NO USB-to-UART bridge: +; the USB-C connector is wired straight to the ESP32-S3 native USB (GPIO19/20), and +; those pads belong to ONE peripheral at a time: +; +; ARDUINO_USB_MODE=1 -> built-in USB-Serial/JTAG (console only, cannot type) +; ARDUINO_USB_MODE=0 -> USB-OTG + TinyUSB (HID keyboard + CDC console) +; +; That single-port fact is the whole reason the control path is WiFi: the board can +; be attached to exactly one machine over USB, so the controller on the laptop has to +; reach it over the network instead. See README "Why the control path is WiFi". +; +; Datasheet: https://docs.m5stack.com/en/core/AtomS3-Lite + +[platformio] +default_envs = bridge + +[usb] +; The core USB library compiles its MSC/FS sources together with the HID ones, and +; those include FS.h / vfs_api.h, which LDF will not resolve by itself (it tries the +; registry and fails). Only the include path is needed: nothing here references the +; MSC/FS symbols, so the linker drops those objects again. Carried over from the +; jiggle firmware on the same board. +fs_include = -I${platformio.packages_dir}/framework-arduinoespressif32/libraries/FS/src + +[env] +platform = espressif32@8.0.0 +board = esp32-s3-devkitc-1 +framework = arduino + +; Nominal for USB CDC (the USB link ignores baud); 921600 is what this board takes. +upload_speed = 921600 +monitor_speed = 115200 +monitor_filters = esp32_exception_decoder, time + +; WiFi + TinyUSB + the embedded controller page do not fit the default 1.3 MB app +; slot of the standard two-OTA layout. This board has no OTA path in this project -- +; it is reflashed over USB, always -- so one 3 MB app partition is the right trade. +board_build.partitions = huge_app.csv + +lib_deps = + adafruit/Adafruit NeoPixel@^1.15.0 + +; --------------------------------------------------------------------------- +; Default: the full bridge. The board is a composite USB HID keyboard + USB serial +; on the target, a web server and WebSocket endpoint on the network. +; --------------------------------------------------------------------------- +[env:bridge] +build_flags = + ${usb.fs_include} + -DARDUINO_USB_MODE=0 + ; CDC_ON_BOOT=0 is what makes the Logitech identity possible: with 1, + ; cores/esp32/main.cpp calls USB.begin() from app_main() before setup() runs, the + ; first tinyusb_init() fixes the VID/PID/strings as Espressif's, and every + ; USB.VID()/productName() setter afterwards is a no-op. With 0 this environment + ; starts the stack itself, after setting the identity (src/main.cpp: kUsb*). The + ; console is unaffected: main.cpp owns a USBCDC instance, so /dev/ttyACM0 works. + -DARDUINO_USB_CDC_ON_BOOT=0 + ; Optional build-time station credentials, so a first bring-up can skip the serial + ; console. Quoting is part of the macro: -D KR_STA_SSID='"MyNet"'. + ; Prefer the `wifi ` console command: it stores into NVS and keeps + ; the passphrase out of the image and out of shell history. + ; -D KR_STA_SSID='" ssid "' + ; -D KR_STA_PASS='" passphrase "' +build_unflags = + ; esp32-s3-devkitc-1 pins ARDUINO_USB_MODE=1 in its board JSON; drop it so the + ; value above is the only one on the command line. + -DARDUINO_USB_MODE=1 + +; --------------------------------------------------------------------------- +; Diagnostic: no USB-OTG, so no HID -- the board cannot type, but the whole control +; plane (WiFi, page, passphrase, WebSocket, LED, button) is live. Use it to separate +; "the network side is broken" from "the HID side is broken" without rebuilding. +; Arming refuses with E5 here, on purpose. +; --------------------------------------------------------------------------- +[env:console] +build_flags = + ${usb.fs_include} + -DARDUINO_USB_MODE=1 + -DARDUINO_USB_CDC_ON_BOOT=1 diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..60de4fc --- /dev/null +++ b/src/config.h @@ -0,0 +1,102 @@ +/** + * keyrelay -- build-time defaults. + * + * Everything here is a DEFAULT. The values stored in NVS win at runtime, so the + * network and the control passphrase can be changed from the serial console + * without reflashing (`wifi`, `ap`, `token` -- see README "First bring-up"). + * + * Nothing secret belongs in this file. The default passphrase is deliberately + * weak and loudly announced as such on the serial console and in the web page, + * because a keyboard that anyone on the LAN can arm is an open door. + */ +#pragma once + +#include +#include + +namespace kr { + +constexpr const char kFirmwareName[] = "keyrelay"; +constexpr const char kFirmwareVersion[] = "1.0.0"; + +// --------------------------------------------------------------------------- +// Network +// --------------------------------------------------------------------------- +// Control plane only: the keystrokes travel WiFi -> board -> USB. The target +// machine provides nothing but 5 V and a USB port, and never sees a network. +constexpr uint16_t kHttpPort = 80; +constexpr char kHostname[] = "keyrelay"; // http://keyrelay.local + +// Fallback access point, started whenever the station interface is not up, so +// the board is reachable with no infrastructure at all (air-gapped target). +constexpr char kApSsid[] = "keyrelay-ctl"; +constexpr char kApPass[] = "keyrelay-ctl-pass"; // >= 8 chars, WPA2. CHANGE IT. +constexpr char kApGw[] = "192.168.4.1"; + +// Build-time station credentials (optional, still only a default). Override with +// pio run -e bridge -D KR_STA_SSID='"MyNet"' -D KR_STA_PASS='"hunter2"' +// without ever committing them. +#ifndef KR_STA_SSID +#define KR_STA_SSID "" +#endif +#ifndef KR_STA_PASS +#define KR_STA_PASS "" +#endif + +constexpr uint32_t kStaTimeoutMs = 8000; // give the association attempt this long at boot +constexpr uint32_t kStaRetryMs = 20000; // ... then retry in the background at this rate + +// --------------------------------------------------------------------------- +// Control passphrase +// --------------------------------------------------------------------------- +// Checked on every WebSocket connection and on /status. The page cannot arm the +// bridge without it, and nothing reaches the target until the bridge is armed. +constexpr char kDefaultToken[] = "changeme"; +constexpr size_t kTokenMax = 32; +constexpr uint8_t kTokenMaxFails = 5; // then stop evaluating the token ... +constexpr uint32_t kTokenLockMs = 30000; // ... for this long + +// --------------------------------------------------------------------------- +// Keystroke path +// --------------------------------------------------------------------------- +constexpr size_t kMaxKeysPerMessage = 8; // protocol cap (HID carries 6) +constexpr uint32_t kCadHoldMs = 60; // how long Ctrl+Alt+Del is held down +constexpr uint32_t kChordHoldMs = 40; // ditto for the other special chords +constexpr uint32_t kMinReportGapUs = 1000; // 1 kHz floor: the host polls at 1 kHz +constexpr size_t kReportQueueDepth = 48; // bursts from a fast typist + +// --------------------------------------------------------------------------- +// Pasted text +// --------------------------------------------------------------------------- +// `P=:` (src/text.h) types a run of characters that a human pasted and +// then deliberately sent. The board owns the character map and the pacing: a +// controller's clock, arriving over WiFi in whatever order the network delivers +// it, is not a keyboard timing source. The controller asks for a pace; the board +// keeps it. +// +// One controller frame is still one command, so a frame carries at most +// kTextFrameMax characters and a long paste arrives as several frames. The board +// holds whatever it has been given and says so in `typing` in the status frame, +// which is how a controller knows it may send the next piece. +constexpr size_t kTextFrameMax = 120; // characters per P= frame +constexpr size_t kTextJobCap = 2048; // characters held for one paste job +constexpr uint16_t kTextPeriodMsDefault = 12; // ms per character when asked for 0 +constexpr uint16_t kTextPeriodMaxMs = 250; // slowest pace a controller may ask for +constexpr uint16_t kTextHoldMaxMs = 6; // how long one character is held down + +// Safety: a bridge that has heard nothing from its controller for this long is +// a bridge that should not be typing. Arming never survives it (laptop slept, +// cable unplugged, browser killed). +constexpr uint32_t kNoClientTimeoutMs = 8000; + +// Status heartbeat to the controller, and the interval of the held-key watchdog. +constexpr uint32_t kStatusIntervalMs = 1000; + +// --------------------------------------------------------------------------- +// Control-server buffers +// --------------------------------------------------------------------------- +constexpr size_t kHttpHeaderCap = 1536; // reject a request line bigger than this +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 + +} // namespace kr diff --git a/src/control_server.cpp b/src/control_server.cpp new file mode 100644 index 0000000..6b50243 --- /dev/null +++ b/src/control_server.cpp @@ -0,0 +1,466 @@ +/** + * keyrelay -- control server implementation. See control_server.h for the shape + * and the reasoning; this file is the socket mechanics. + */ +#include "control_server.h" + +#include + +#include "web_page.h" + +namespace kr { + +namespace { + +// HTTP responses are built by hand: one page, one JSON endpoint, one upgrade. A web +// framework would add a request object, a route table and a buffer pool to serve +// four URLs. +void httpResponse(WiFiClient &c, const char *status, const char *contentType, + const char *body, size_t len) { + char head[192]; + const int n = snprintf(head, sizeof(head), + "HTTP/1.1 %s\r\n" + "Content-Type: %s\r\n" + "Content-Length: %u\r\n" + "Cache-Control: no-store\r\n" + "X-Content-Type-Options: nosniff\r\n" + "Connection: close\r\n\r\n", + status, contentType, (unsigned)len); + if (n > 0) c.write((const uint8_t *)head, (size_t)n); + if (body && len) c.write((const uint8_t *)body, len); +} + +void httpError(WiFiClient &c, const char *status, const char *text) { + httpResponse(c, status, "text/plain; charset=utf-8", text, strlen(text)); +} + +// SSIDs are user data on a JSON boundary: keep it printable and quote-free rather +// than trusting whatever a neighbouring access point is called. +void jsonSafe(char *dst, size_t cap, const char *src) { + size_t o = 0; + for (; src && *src && o + 1 < cap; src++) { + const char ch = *src; + if (ch == '"' || ch == '\\' || ch < 32 || ch == 127) continue; + dst[o++] = ch; + } + dst[o] = '\0'; +} + +constexpr char kPathIndex[] = "/"; +constexpr char kPathIndexHtml[] = "/index.html"; +constexpr char kPathStatus[] = "/status"; +constexpr char kPathWs[] = "/ws"; + +} // namespace + +size_t statusJson(const StatusSnapshot &s, char *out, size_t cap) { + char ssid[40]; + jsonSafe(ssid, sizeof(ssid), s.ssid); + const int n = snprintf( + out, 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}", + 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); + return (n < 0 || (size_t)n >= cap) ? 0 : (size_t)n; +} + +void ControlServer::begin(OnMessage onMessage, OnLinkChange onLinkChange, + FillStatus fillStatus, GetToken getToken, void *ctx) { + _onMessage = onMessage; + _onLinkChange = onLinkChange; + _fillStatus = fillStatus; + _getToken = getToken; + _ctx = ctx; + _server.begin(kHttpPort); + _server.setNoDelay(true); + MDNS.addService("http", "tcp", kHttpPort); +} + +void ControlServer::tick(uint32_t nowMs) { + acceptNew(nowMs); + if (_viewer) serveViewer(nowMs); + + switch (_phase) { + case Phase::Http: serveHttp(nowMs); break; + case Phase::WebSocket: serveWebSocket(nowMs); break; + default: break; + } + + // A socket that opens and never speaks is a scan, not a controller. + if (_phase == Phase::WebSocket && !_authed && + nowMs - _connectedAtMs > kAuthTimeoutMs) { + sendError(1, "passphrase required"); + closeLink("auth timeout"); + return; + } + + if (_phase == Phase::WebSocket && _authed) { + if (nowMs - _lastStatusMs >= kStatusIntervalMs) sendStatus(); + // A WebSocket ping every 15 s keeps the path alive through any idle timeout, and + // the browser answers it automatically -- which is what refreshes _lastInboundMs + // and therefore keeps the armed state alive while the tab is merely open. + if (nowMs - _lastPingMs >= 15000) { + _lastPingMs = nowMs; + sendPing(); + } + } + + if (_client && !_client.connected() && _phase != Phase::None) closeLink("socket closed"); +} + +void ControlServer::acceptNew(uint32_t nowMs) { + if (!_server.hasClient()) return; + WiFiClient fresh = _server.accept(); + if (!fresh) return; + + if (_phase != Phase::None && _client && _client.connected()) { + // The bridge is driven by somebody. The incumbent keeps the *keyboard*; this socket + // is still allowed to look at the board. Only a second /ws upgrade -- a controller + // claim -- is refused, and serveViewer() is what decides that. + if (_viewer) stopViewer(); + _viewer = fresh; + _viewer.setNoDelay(true); + _viewerLen = 0; + _viewerHttp[0] = '\0'; + _viewerAtMs = nowMs; + return; + } + fresh.setNoDelay(true); + _client = fresh; + _phase = Phase::Http; + _httpLen = 0; + _authed = false; + _connectedAtMs = nowMs; + _lastInboundMs = nowMs; + _reader.reset(); +} + +size_t ControlServer::readSome(uint8_t *dst, size_t cap) { + if (!_client || !_client.available()) return 0; + const int n = _client.read(dst, cap); + if (n > 0) { + _lastInboundMs = millis(); + return (size_t)n; + } + return 0; +} + +void ControlServer::serveHttp(uint32_t nowMs) { + // Read until the header block is complete, then route. A request line larger + // than the buffer is refused instead of buffered, so the board cannot be pushed + // into allocating around an attacker's header size. + for (;;) { + const size_t space = sizeof(_http) - _httpLen - 1; + if (space == 0) { + httpError(_client, "431 Request Header Fields Too Large", "header block too large\n"); + closeLink("header too large"); + return; + } + const size_t got = readSome((uint8_t *)_http + _httpLen, space); + if (got == 0) { + if (nowMs - _connectedAtMs > 10000) closeLink("http stalled"); + return; + } + _httpLen += got; + _http[_httpLen] = '\0'; + + ws::Request req; + const int r = ws::parseRequest(_http, _httpLen, req); + if (r == 0) continue; + if (r < 0) { + httpError(_client, "400 Bad Request", "malformed request\n"); + closeLink("malformed request"); + return; + } + + // ---- routes --------------------------------------------------------------- + if (req.upgradeWebsocket && strcmp(req.path, kPathWs) == 0 && req.wsKey[0]) { + char accept[64]; + if (!ws::acceptFor(req.wsKey, strlen(req.wsKey), accept, sizeof(accept))) { + httpError(_client, "400 Bad Request", "bad websocket key\n"); + closeLink("bad ws key"); + 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) { closeLink("handshake failed"); return; } + _client.write((const uint8_t *)head, (size_t)n); + _phase = Phase::WebSocket; + _reader.reset(); + _connectedAtMs = nowMs; + _lastStatusMs = nowMs; + _lastPingMs = nowMs; + return; + } + + if (req.upgradeWebsocket) { + // An upgrade for anything other than a well-formed /ws is not a controller and + // not a page either; there is nothing to route it to. + httpError(_client, "400 Bad Request", "malformed websocket request\n"); + closeLink("bad ws request"); + return; + } + + serveReadonly(_client, req); + closeLink("served"); + return; + } +} + +// The read-only routes: the controller page, the JSON status, favicon, 404. Shared by +// the ordinary path above and the viewer path below on purpose -- "who may drive the +// keyboard" and "who may see this board" are different questions, and the answer to the +// second one must not be able to drift from the first. +void ControlServer::serveReadonly(WiFiClient &c, const ws::Request &req) { + if (strcmp(req.method, "GET") != 0) { + httpError(c, "405 Method Not Allowed", "GET only\n"); + return; + } + + if (strcmp(req.path, kPathIndex) == 0 || strcmp(req.path, kPathIndexHtml) == 0) { + char head[192]; + const int n = snprintf(head, sizeof(head), + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + "Content-Length: %u\r\n" + "Cache-Control: no-store\r\n" + "Connection: close\r\n\r\n", + (unsigned)kIndexHtmlLen); + if (n > 0) c.write((const uint8_t *)head, (size_t)n); + c.write_P(kIndexHtml, kIndexHtmlLen); + return; + } + + if (strcmp(req.path, kPathStatus) == 0) { + // curl-able status, but the passphrase is still required: the numbers say + // whether a machine is being typed on. + char token[kTokenMax + 1] = ""; + const char *want = _getToken ? _getToken(_ctx) : ""; + if (!ws::tokenFromQuery(req.query, strlen(req.query), token, sizeof(token)) || + !proto::constTimeEq(token, strlen(token), want, strlen(want))) { + httpError(c, "401 Unauthorized", "token required\n"); + return; + } + StatusSnapshot s; + if (_fillStatus) _fillStatus(_ctx, s); + char json[320]; + const size_t len = statusJson(s, json, sizeof(json)); + httpResponse(c, "200 OK", "application/json", json, len); + return; + } + + if (strcmp(req.path, "/favicon.ico") == 0) { + httpResponse(c, "204 No Content", "text/plain", nullptr, 0); + return; + } + + httpError(c, "404 Not Found", "keyrelay: / (page), /ws (websocket), " + "/status?token=... (json)\n"); +} + +// A connection that arrived while the controller slot was occupied: serve it read-only +// 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 second /ws upgrade, because that is a controller claim. +void ControlServer::serveViewer(uint32_t nowMs) { + if (!_viewer) { + stopViewer(); + return; + } + + while (_viewer.available() && _viewerLen + 1 < sizeof(_viewerHttp)) { + const int b = _viewer.read(); + if (b <= 0) break; + _viewerHttp[_viewerLen++] = (char)b; + _viewerHttp[_viewerLen] = '\0'; + } + + ws::Request req; + const int r = ws::parseRequest(_viewerHttp, _viewerLen, req); + if (r == 0) { + if (_viewerLen + 1 >= sizeof(_viewerHttp)) { + httpError(_viewer, "431 Request Header Fields Too Large", "header block too large\n"); + stopViewer(); + return; + } + if (nowMs - _viewerAtMs > 10000) stopViewer(); // stalled; drop it, no reply owed + return; + } + if (r < 0) { + httpError(_viewer, "400 Bad Request", "malformed request\n"); + stopViewer(); + return; + } + + if (req.upgradeWebsocket) { + // One controller. The incumbent keeps the bridge; the newcomer is told why. + httpError(_viewer, "503 Service Unavailable", + "keyrelay: another controller already holds this bridge.\n"); + _rejected++; + stopViewer(); + return; + } + + serveReadonly(_viewer, req); + stopViewer(); +} + +void ControlServer::stopViewer() { + if (_viewer) { + _viewer.flush(); + _viewer.stop(); + } + _viewer = WiFiClient(); + _viewerLen = 0; +} + +void ControlServer::serveWebSocket(uint32_t nowMs) { + (void)nowMs; + uint8_t chunk[64]; + for (;;) { + const size_t got = readSome(chunk, sizeof(chunk)); + if (got == 0) return; + for (size_t i = 0; i < got; i++) { + const ws::In r = _reader.feed(chunk[i]); + if (r == ws::In::NeedMore) continue; + if (r == ws::In::Error) { + sendError(6, ws::describe(_reader.error())); + closeLink("frame error"); + return; + } + if (!handleWsFrame(nowMs)) return; // link closed or frame consumed + } + } +} + +bool ControlServer::handleWsFrame(uint32_t nowMs) { + const uint8_t opcode = _reader.opcode(); + + if (opcode == ws::kOpPing) { + // Echo the payload back as a pong. It has to be copied out first: the payload + // lives inside _wsBuf, which is the reader's buffer, and control payloads are + // capped at 125 bytes by the reader, so both buffers here are small and bounded. + uint8_t payload[128]; + uint8_t frame[136]; + const size_t plen = _reader.payloadLen() < sizeof(payload) ? _reader.payloadLen() + : sizeof(payload); + memcpy(payload, _reader.payload(), plen); + _reader.reset(); + const size_t n = ws::encodeFrame(ws::kOpPong, payload, plen, frame, sizeof(frame)); + if (n && _client && _client.connected()) _client.write(frame, n); + return true; + } + if (opcode == ws::kOpPong) { _reader.reset(); return true; } + if (opcode == ws::kOpClose) { closeLink("controller closed"); return false; } + + proto::Message msg; + const proto::Cmd cmd = + proto::parse((const char *)_reader.payload(), _reader.payloadLen(), msg); + _reader.reset(); + + if (cmd == proto::Cmd::Bad) { + sendError(6, "unrecognised command"); + return true; + } + + // ---- authentication -------------------------------------------------------- + if (!_authed) { + // While locked out the token is not even looked at: no oracle, and no point + // burning cycles on a brute-force attempt. + if (_lockUntilMs != 0 && (int32_t)(nowMs - _lockUntilMs) < 0) { + sendError(2, "too many failures, try later"); + closeLink("locked out"); + return false; + } + if (cmd != proto::Cmd::Auth) { + sendError(1, "authenticate first"); + closeLink("unauthenticated"); + return false; + } + const char *want = _getToken ? _getToken(_ctx) : ""; + if (!proto::constTimeEq(msg.text, strlen(msg.text), want, strlen(want))) { + _authFails++; + if (_authFails >= kTokenMaxFails) _lockUntilMs = nowMs + kTokenLockMs; + sendError(1, "wrong passphrase"); + closeLink("auth failed"); + return false; + } + _authed = true; + _authFails = 0; + _lockUntilMs = 0; + writeTextFrame("OK", 2); + sendStatus(); + if (_onLinkChange) _onLinkChange(_ctx, true); + return true; + } + + if (_onMessage) _onMessage(_ctx, msg); + return true; +} + +void ControlServer::sendPing() { + if (!_client || !_client.connected()) return; + uint8_t frame[8]; + const size_t n = ws::encodeFrame(ws::kOpPing, nullptr, 0, frame, sizeof(frame)); + if (n) _client.write(frame, n); +} + +void ControlServer::writeTextFrame(const char *text, size_t len) { + uint8_t frame[kWsMessageCap + 8]; + const size_t n = ws::encodeFrame(ws::kOpText, text, len, frame, sizeof(frame)); + if (n == 0) return; + if (!_client || !_client.connected()) return; + _client.write(frame, n); +} + +void ControlServer::sendStatus() { + if (_phase != Phase::WebSocket || !_authed) return; + StatusSnapshot s; + if (_fillStatus) _fillStatus(_ctx, s); + char json[320]; + const size_t len = statusJson(s, json, sizeof(json)); + if (len == 0) return; + char framed[340]; + framed[0] = 'S'; // 'S' prefix, then the JSON body + memcpy(framed + 1, json, len); + writeTextFrame(framed, len + 1); +} + +void ControlServer::sendError(uint8_t code, const char *text) { + if (_phase != Phase::WebSocket) return; + char line[96]; + const size_t len = proto::formatError(line, sizeof(line), code, text); + if (len) writeTextFrame(line, len); +} + +void ControlServer::closeLink(const char *why) { + (void)why; + const bool wasLinked = linked(); + if (_client && _client.connected()) { + uint8_t frame[16]; + const size_t n = ws::encodeFrame(ws::kOpClose, nullptr, 0, frame, sizeof(frame)); + if (n && _phase == Phase::WebSocket) { + _client.write(frame, n); + _client.flush(); // without this the close frame is often RST'd instead + } + _client.stop(); + } + _phase = Phase::None; + _authed = false; + _httpLen = 0; + _reader.reset(); + if (wasLinked && _onLinkChange) _onLinkChange(_ctx, false); +} + +} // namespace kr diff --git a/src/control_server.h b/src/control_server.h new file mode 100644 index 0000000..d152334 --- /dev/null +++ b/src/control_server.h @@ -0,0 +1,129 @@ +/** + * keyrelay -- the control server: HTTP for the page, WebSocket for the keystrokes. + * + * 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). + * - non-blocking throughout: tick() runs once per loop() and consumes only what is + * already buffered. The keystroke path must not wait on the network stack. + * - nothing is trusted before the passphrase. The socket opens, must present T + * inside kAuthTimeoutMs, and is closed otherwise; repeated failures lock the + * evaluation out for kTokenLockMs. + * + * Bytes in are decoded by kr::ws::Reader (host-tested); commands by kr::proto::parse + * (host-tested). What is left here is routing, framing and lifecycle -- the part that + * genuinely needs a socket to be worth testing. + */ +#pragma once + +#include + +#include "config.h" +#include "proto.h" +#include "ws_codec.h" + +namespace kr { + +// What the board knows about itself, as shown to the controller. The JSON field +// names in StatusJson below are the contract the page in web_page.h reads. +struct StatusSnapshot { + const char *version = "0"; + uint32_t uptimeS = 0; + bool usbHost = false; // TinyUSB is attached to a target + bool armed = false; + bool netUp = false; + const char *net = "down"; // "sta" | "ap" | "down" + const char *ssid = ""; + const char *ip = ""; + uint32_t keysSent = 0; + uint32_t chordsSent = 0; + uint32_t dropped = 0; + uint32_t typedChars = 0; // characters sent by pasted text (a count, never the text) + uint16_t typing = 0; // characters still queued in the paste in progress + bool rollover = false; + uint8_t ledState = 0; // the target's caps/num/scroll LEDs, per its HID reports + bool defaultToken = false; + bool apForced = false; +}; + +/** + * Renders the status frame. Split out of the class so it is callable from the serial + * console too, and so the field names stay in one place. + */ +size_t statusJson(const StatusSnapshot &s, char *out, size_t cap); + +class ControlServer { + public: + // ctx-first function pointers rather than std::function: no allocation, and the + // wiring in setup() reads as a list of the callbacks the server may invoke. + using OnMessage = void (*)(void *ctx, const proto::Message &msg); + using OnLinkChange = void (*)(void *ctx, bool linked); + using FillStatus = void (*)(void *ctx, StatusSnapshot &out); + using GetToken = const char *(*)(void *ctx); + + void begin(OnMessage onMessage, OnLinkChange onLinkChange, FillStatus fillStatus, + GetToken getToken, void *ctx); + void tick(uint32_t nowMs); + + // Push a status frame now. Called on the 1 Hz heartbeat and immediately after an + // arm/disarm, so the page's one source of truth is the board, not its own guess. + void sendStatus(); + void sendError(uint8_t code, const char *text); + + bool linked() const { return _phase == Phase::WebSocket && _authed; } + uint32_t lastInboundMs() const { return _lastInboundMs; } + uint32_t rejected() const { return _rejected; } + uint32_t authFails() const { return _authFails; } + + private: + enum class Phase : uint8_t { None, Http, WebSocket }; + + void acceptNew(uint32_t nowMs); + void serveHttp(uint32_t nowMs); + void serveReadonly(WiFiClient &c, const ws::Request &req); + void serveViewer(uint32_t nowMs); + void stopViewer(); + void serveWebSocket(uint32_t nowMs); + void closeLink(const char *why); + bool handleWsFrame(uint32_t nowMs); + void sendPing(); + void writeTextFrame(const char *text, size_t len); + size_t readSome(uint8_t *dst, size_t cap); + + WiFiServer _server; + WiFiClient _client; + Phase _phase = Phase::None; + + char _http[kHttpHeaderCap]; + size_t _httpLen = 0; + + // A second socket, read-only, for when the controller slot is taken. + WiFiClient _viewer; + char _viewerHttp[kHttpHeaderCap]; + size_t _viewerLen = 0; + uint32_t _viewerAtMs = 0; + + uint8_t _wsBuf[kWsMessageCap]; + ws::Reader _reader{_wsBuf, sizeof(_wsBuf)}; + + bool _authed = false; + uint32_t _connectedAtMs = 0; + uint32_t _lastInboundMs = 0; + uint32_t _lastStatusMs = 0; + uint32_t _lastPingMs = 0; + uint32_t _lockUntilMs = 0; + uint32_t _authFails = 0; + uint32_t _rejected = 0; + + OnMessage _onMessage = nullptr; + OnLinkChange _onLinkChange = nullptr; + FillStatus _fillStatus = nullptr; + GetToken _getToken = nullptr; + void *_ctx = nullptr; +}; + +} // namespace kr diff --git a/src/keystate.h b/src/keystate.h new file mode 100644 index 0000000..d004ab1 --- /dev/null +++ b/src/keystate.h @@ -0,0 +1,194 @@ +/** + * keyrelay -- the key-state tracker. Pure logic: no Arduino, no TinyUSB, so it + * compiles and is unit-tested on a host (tests/test_keystate.cpp). + * + * Why the controller sends the WHOLE state instead of key up/down events + * ---------------------------------------------------------------------- + * A key-up that never arrives (dropped frame, browser tab killed mid-chord, the + * laptop's compositor eating the release) leaves a modifier held down forever on + * the target -- the classic stuck-Ctrl disaster. So the controller restates the + * complete desired state on every event and this module simply *is* that state: + * applying the same state twice is a no-op, and any single message resynchronises + * the target completely. There is no sequence to fall out of step. + * + * Rollover + * -------- + * The boot-protocol report has six key slots. Pressing a seventh is reported the + * way a real keyboard does it: ErrorRollOver (0x01) in every slot. Silently + * dropping the seventh key would be worse -- the host would never see it pressed + * and the state would look clean while the user believed a key was down. + */ +#pragma once + +#include +#include +#include + +namespace kr { + +// USB HID Usage Tables 1.11, section 2.2 "Usage Page 0x07 Keyboard/Keypad", +// section 4.3 for the modifier byte. These are the values on the wire. +enum Modifier : uint8_t { + kModLctrl = 0x01, + kModLshift = 0x02, + kModLalt = 0x04, + kModLgui = 0x08, + kModRctrl = 0x10, + kModRshift = 0x20, + kModRalt = 0x40, + kModRgui = 0x80, + kModAny = 0xFF, +}; + +// The handful of usages this firmware names. Everything else travels as a number +// decided by the controller (src/web_page.h maps KeyboardEvent.code -> usage). +enum Usage : uint8_t { + kUsageNone = 0x00, + kUsageErrorRollover = 0x01, // "more than six keys, good luck" + kUsagePostFail = 0x02, + kUsageA = 0x04, + kUsageZ = 0x1D, + kUsage1 = 0x1E, + kUsage0 = 0x27, + kUsageEnter = 0x28, + kUsageEscape = 0x29, + kUsageBackspace = 0x2A, + kUsageTab = 0x2B, + kUsageSpace = 0x2C, + kUsageF1 = 0x3A, + kUsageF7 = 0x40, + kUsageF8 = 0x41, + kUsageDelForward = 0x4C, // the "Del" of Ctrl+Alt+Del (not backspace) + kUsagePrintScreen = 0x46, + kUsageContextMenu = 0x65, + kUsageKpEnter = 0x58, +}; + +// The 8-byte boot-protocol keyboard report: modifier bitmap, a reserved byte the +// spec says must be zero, then up to six key usages. Byte-identical to TinyUSB's +// hid_keyboard_report_t and Arduino's KeyReport. +struct KeyboardReport { + uint8_t modifiers; + uint8_t reserved; + uint8_t keys[6]; +}; + +static_assert(sizeof(KeyboardReport) == 8, "boot-protocol keyboard report is 8 bytes"); + +constexpr size_t kReportSlots = sizeof(((KeyboardReport *)0)->keys); + +// More pressed keys than a report can carry is legal input, just not reportable; +// track this many so that releasing back under the limit reports the truth again. +constexpr size_t kMaxTrackedKeys = 12; + +// Build a report from scratch. Used for the synthetic chords (Ctrl+Alt+Del) that +// must not disturb the tracked state. +inline KeyboardReport makeReport(uint8_t modifiers, const uint8_t *usages, size_t count) { + KeyboardReport r; + r.modifiers = modifiers; + r.reserved = 0; + memset(r.keys, 0, sizeof(r.keys)); + if (count > kReportSlots) { // same rule as the tracker + memset(r.keys, kUsageErrorRollover, sizeof(r.keys)); + return r; + } + size_t n = 0; + for (size_t i = 0; i < count && n < kReportSlots; i++) { + if (usages[i] == kUsageNone) continue; + bool dup = false; + for (size_t j = 0; j < n; j++) { + if (r.keys[j] == usages[i]) { dup = true; break; } + } + if (!dup) r.keys[n++] = usages[i]; + } + return r; +} + +inline bool reportsEqual(const KeyboardReport &a, const KeyboardReport &b) { + return a.modifiers == b.modifiers && memcmp(a.keys, b.keys, sizeof(a.keys)) == 0; +} + +/** + * The complete state of the emulated keyboard: a modifier byte plus the set of + * currently pressed non-modifier keys, in press order. + */ +class KeyboardState { + public: + /** + * Restate the whole desired state. `usages` are the keys the controller says are + * down (order preserved, duplicates and 0x00 ignored). + * + * `dirty()` becomes true only when the resulting report differs from the + * previous one, and every distinct state gets its own dirty flag: the caller + * queues each of them in order, so a fast press+release is two reports and + * cannot collapse into nothing. Only *identical* consecutive reports collapse, + * and those are safe to send once. + */ + void apply(uint8_t modifiers, const uint8_t *usages, size_t count) { + uint8_t next[kMaxTrackedKeys]; + size_t n = 0; + for (size_t i = 0; i < count && n < kMaxTrackedKeys; i++) { + const uint8_t u = usages[i]; + if (u == kUsageNone) continue; + bool dup = false; + for (size_t j = 0; j < n; j++) { + if (next[j] == u) { dup = true; break; } + } + if (!dup) next[n++] = u; + } + + const KeyboardReport prev = _report; + _mods = modifiers; + _count = n; + if (n) memcpy(_keys, next, n); + rebuild(); + if (!reportsEqual(prev, _report)) _dirty = true; + } + + // All keys up. This is the panic path, so it never leaves anything behind. + void clear() { + const KeyboardReport prev = _report; + _mods = 0; + _count = 0; + rebuild(); + if (!reportsEqual(prev, _report)) _dirty = true; + } + + const KeyboardReport &report() const { return _report; } + bool dirty() const { return _dirty; } + void ackDirty() { _dirty = false; } + + size_t held() const { return _count; } + uint8_t modifiers() const { return _mods; } + + // True while the pressed set is too wide for the report (ErrorRollOver is out). + bool rollover() const { return _count > kReportSlots; } + + // Is this usage currently pressed? (for the controller's held-key readout) + bool isDown(uint8_t usage) const { + for (size_t i = 0; i < _count; i++) { + if (_keys[i] == usage) return true; + } + return false; + } + + private: + void rebuild() { + _report.modifiers = _mods; + _report.reserved = 0; + if (rollover()) { + memset(_report.keys, kUsageErrorRollover, sizeof(_report.keys)); + return; + } + memset(_report.keys, 0, sizeof(_report.keys)); + for (size_t i = 0; i < _count; i++) _report.keys[i] = _keys[i]; + } + + uint8_t _mods = 0; + uint8_t _count = 0; + uint8_t _keys[kMaxTrackedKeys] = {0}; + KeyboardReport _report = {0, 0, {0, 0, 0, 0, 0, 0}}; + bool _dirty = false; +}; + +} // namespace kr diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..46d54a8 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,978 @@ +/** + * keyrelay -- a WiFi-controlled USB keyboard bridge for the M5Stack AtomS3 Lite. + * + * laptop (browser tab) --WiFi--> AtomS3 Lite --USB HID--> target machine + * + * The board enumerates on the target as a plain boot-protocol HID keyboard (and a + * CDC console), so the target needs no software, no network and no driver -- it + * thinks a keyboard is attached, because one is. The laptop is the controller: a + * page served by the board itself, which restates the complete key state on every + * keystroke, over a passphrase-authenticated WebSocket. + * + * press button -> arm / disarm locally (works with no network at all) + * hold 3 s at power-on -> ROM USB download mode, so the board can always be + * reflashed from the host it is currently plugged into + * + * Safety model, in the order it is enforced + * ---------------------------------------- + * 1. Disarmed until somebody says so. Boot, reload, reconnect, socket loss and + * focus loss all leave the bridge disarmed, and disarming sends "all keys up". + * 2. Arming needs the passphrase (or a thumb on the board). Nothing reaches the + * target -- not one key, not one chord -- without it. + * 3. Armed is displayed on the device: solid red LED, and it blinks red instead if + * there is no target attached to type into. + * 4. Silence disarms. If the controller stops talking for kNoClientTimeoutMs (laptop + * slept, browser killed, network gone) the keys go up and the bridge disarms. + * Typing that continues after you stopped typing is the failure this prevents. + * 5. The target taking the bus away (unplug, suspend) disarms and clears the queue. + * A pasted run cancels on every one of these: "release every key" means "and stop + * typing", not "pause". The characters live in RAM until they go out and are never + * written down, so the board still cannot remember anything to type later. + * 6. A full report queue is a fail-safe event, not a dropped keystroke: release + * everything and disarm rather than type out of step. + * + * Ctrl+Alt+Del is a real key sequence here, which is worth being blunt about: on a + * Linux virtual terminal it reboots the machine, on Windows it is the secure + * attention sequence, and the laptop's own OS eats the physical combination before + * a browser can see it. That is why it is a button with a two-press confirm and + * never something a stray keypress can produce. See README (Security model). + * + * Board pins (AtomS3 Lite / C124, ESP32-S3FN8): + * GPIO35 -> WS2812C RGB LED data line (single LED) + * GPIO41 -> front button, active LOW + * GPIO19/20 -> native USB: USB-OTG (TinyUSB) here; USB-Serial/JTAG in the + * 'console' build environment, which has no HID at all + * + * Boot order is load-bearing (it is inherited from the jiggle firmware on the same + * board): identity -> Keyboard.begin() -> USB.begin() -> recovery hold -> WiFi. + * The first tinyusb_init() of the boot is what fixes the USB descriptors, so every + * setter has to run before it. + */ + +#include + +#if !defined(ARDUINO_USB_MODE) +#error "ESP32-S3 native USB is required; build the 'bridge' or 'console' environment" +#endif + +#include + +#if ARDUINO_USB_MODE == 1 +#warning "ARDUINO_USB_MODE=1: control-plane diagnostic build -- no HID keyboard, cannot type" +#define KR_HAVE_HID 0 +#else +#include "USB.h" +#include "USBCDC.h" +#include "esp32-hal-tinyusb.h" // usb_persist_restart() +#include "USBHIDKeyboard.h" +#define KR_HAVE_HID 1 +// This environment builds with ARDUINO_USB_CDC_ON_BOOT=0, so HardwareSerial.h has +// mapped `Serial` to UART0 -- dead pins on this board, which has no USB-to-UART +// bridge. Un-map it and own the CDC interface instead, so the console stays on +// /dev/ttyACM0 and nothing else calls USB.begin() first. +#undef Serial +#endif + +#include +#include +#include + +#include "config.h" +#include "control_server.h" +#include "keystate.h" +#include "proto.h" +#include "text.h" + +#if KR_HAVE_HID +// TinyUSB's per-instance readiness check is a plain C function in the linked stack. +// It is declared here rather than included: the inline tud_hid_ready() wrapper in +// hid_device.h needs tinyusb's internal device struct visible, which drags the whole +// private header set in for one boolean. At file scope, before the anonymous +// namespace, so the code below can call it. +extern "C" bool tud_hid_n_ready(uint8_t instance); +#endif + +namespace { + +// This file is the glue between the project's own modules and the Arduino/ESP32 +// APIs, so kr's members are used unqualified throughout; the ESP32 headers are all +// in the global namespace and nothing in kr collides with them. +using namespace kr; + +constexpr uint8_t kLedPin = 35; // WS2812C data line +constexpr uint8_t kButtonPin = 41; // front button, active LOW +constexpr uint8_t kLedCount = 1; +constexpr uint8_t kBrightness = 56; // 0-255; it sits next to a screen + +constexpr uint32_t kColorOff = 0x000000; +constexpr uint32_t kColorRed = 0xFF0000; +constexpr uint32_t kColorGreen = 0x00FF40; +constexpr uint32_t kColorBlue = 0x0040FF; +constexpr uint32_t kColorAmber = 0xFF8000; +constexpr uint32_t kColorGreenDim = 0x0A3A16; + +constexpr uint32_t kSampleIntervalMs = 5; // button integrator +constexpr uint8_t kDebounceSamples = 4; +constexpr uint32_t kRecoveryHoldMs = 3000; // hold at boot -> ROM download mode +constexpr uint32_t kLedBlinkPeriodMs = 300; // "armed but no target" urgency blink +constexpr size_t kMaxReportsPerLoop = 8; // bound the time in the USB send path +constexpr size_t kConsoleLineCap = 96; + +#if KR_HAVE_HID +USBHIDKeyboard Keyboard; // ctor registers the HID interface with TinyUSB +USBCDC Serial(0); // ctor registers the CDC interface, after Keyboard +volatile bool usbStarted = false; +uint8_t targetLeds = 0; // caps/num/scroll as the TARGET reports them +#endif + +Preferences prefs; +ControlServer control; +kr::KeyboardState keys; +kr::text::Typewriter typer; // the paste in flight, paced here (see text.h) + +// --------------------------------------------------------------------------- +// Persistent settings (NVS). Build-time defaults from config.h are only the +// starting values; changing them here survives reflashing. +// --------------------------------------------------------------------------- +constexpr char kNvsNamespace[] = "keyrelay"; +constexpr char kKeyStaSsid[] = "sta_ssid"; +constexpr char kKeyStaPass[] = "sta_pass"; +constexpr char kKeyToken[] = "token"; +constexpr char kKeyApMode[] = "ap_mode"; // 0 auto, 1 force on, 2 off + +char staSsid[33] = ""; +char staPass[65] = ""; +char token[kr::kTokenMax + 1] = ""; // filled from NVS, or kDefaultToken, in loadSettings +uint8_t apMode = 0; + +void loadSettings() { + prefs.begin(kNvsNamespace, true); + const String nvSsid = prefs.getString(kKeyStaSsid, ""); + const String nvPass = prefs.getString(kKeyStaPass, ""); + const String nvToken = prefs.getString(kKeyToken, ""); + apMode = (uint8_t)prefs.getInt(kKeyApMode, 0); + prefs.end(); + + if (nvSsid.length()) { + strncpy(staSsid, nvSsid.c_str(), sizeof(staSsid) - 1); + strncpy(staPass, nvPass.c_str(), sizeof(staPass) - 1); + } else { + strncpy(staSsid, KR_STA_SSID, sizeof(staSsid) - 1); + strncpy(staPass, KR_STA_PASS, sizeof(staPass) - 1); + } + if (nvToken.length()) strncpy(token, nvToken.c_str(), sizeof(token) - 1); + else strncpy(token, kr::kDefaultToken, sizeof(token) - 1); + token[sizeof(token) - 1] = '\0'; +} + +void saveSta(const char *ssid, const char *pass) { + prefs.begin(kNvsNamespace, false); + prefs.putString(kKeyStaSsid, ssid); + prefs.putString(kKeyStaPass, pass); + prefs.end(); + strncpy(staSsid, ssid, sizeof(staSsid) - 1); + strncpy(staPass, pass, sizeof(staPass) - 1); +} + +void saveToken(const char *fresh) { + prefs.begin(kNvsNamespace, false); + prefs.putString(kKeyToken, fresh); + prefs.end(); + strncpy(token, fresh, sizeof(token) - 1); +} + +void saveApMode(uint8_t mode) { + prefs.begin(kNvsNamespace, false); + prefs.putInt(kKeyApMode, mode); + prefs.end(); + apMode = mode; +} + +// --------------------------------------------------------------------------- +// Report queue +// --------------------------------------------------------------------------- +// The controller can burst faster than the target's 1 ms poll interval, and every +// report the core sends is synchronous (USBHID::SendReport waits for the transfer +// to complete), so reports are queued here and drained a few per loop pass. +struct ReportQueue { + kr::KeyboardReport items[kr::kReportQueueDepth]; + size_t head = 0; + size_t count = 0; + + bool push(const kr::KeyboardReport &r) { + if (count > 0) { + const size_t last = (head + count - 1) % kr::kReportQueueDepth; + // Two identical reports in a row are one report. Distinct transitions are + // always kept -- collapsing those is how a fast tap would vanish. + if (kr::reportsEqual(items[last], r)) return true; + } + if (count >= kr::kReportQueueDepth) return false; + items[(head + count) % kr::kReportQueueDepth] = r; + count++; + return true; + } + const kr::KeyboardReport *peek() const { return count ? &items[head] : nullptr; } + void pop() { + if (!count) return; + head = (head + 1) % kr::kReportQueueDepth; + count--; + } + void clear() { head = 0; count = 0; } +}; + +ReportQueue queue; + +// A chord (Ctrl+Alt+Del, Ctrl+Alt+F3, ...) is a press that has to be followed by a +// release after a fixed hold, without disturbing the tracked key state -- the keys +// that were genuinely held before the chord must come back afterwards. +struct ChordInFlight { + bool active = false; + uint32_t releaseAtMs = 0; +}; +ChordInFlight chord; + +bool armed = false; +uint32_t keysSent = 0; +uint32_t chordsSent = 0; +uint32_t dropped = 0; +uint32_t typedChars = 0; // a count only: the characters themselves are never kept +uint32_t prevHeld = 0; + +#if KR_HAVE_HID +// True when a target is attached and the HID interface is live. Nothing can be +// armed without it -- arming against nothing would only blink. +bool canType() { return usbStarted; } +#else +bool canType() { return false; } +#endif + +// --------------------------------------------------------------------------- +// LED +// --------------------------------------------------------------------------- +Adafruit_NeoPixel led(kLedCount, kLedPin, NEO_GRB + NEO_KHZ800); +uint32_t shownColor = kColorOff; +uint32_t flashUntilMs = 0; +uint32_t flashColor = kColorOff; + +void showColor(uint32_t color) { + if (color == shownColor) return; // the WS2812 latches; write on change only + led.setPixelColor(0, color); + led.show(); + shownColor = color; +} + +void flash(uint32_t color, uint32_t ms) { + flashColor = color; + flashUntilMs = millis() + ms; +} + +void ledSelfTest() { + const uint32_t colours[] = {kColorRed, kColorGreen, kColorBlue}; + for (uint32_t c : colours) { + led.setPixelColor(0, c); + led.show(); + delay(60); + } + led.setPixelColor(0, kColorOff); + led.show(); + shownColor = kColorOff; +} + +void updateLed(uint32_t now) { + if (now < flashUntilMs) { showColor(flashColor); return; } + if (armed) { + // Solid red only means what it should mean: keystrokes are going to a target. + if (canType()) { showColor(kColorRed); return; } + const bool on = (now / kLedBlinkPeriodMs) % 2 == 0; + showColor(on ? kColorRed : kColorOff); // armed with nothing to type into + return; + } + if (control.linked()) { showColor(kColorGreenDim); return; } + showColor(kColorOff); +} + +// --------------------------------------------------------------------------- +// Arming +// --------------------------------------------------------------------------- +void releaseEverything() { +#if KR_HAVE_HID + queue.clear(); // drop any press still waiting to go out + keys.clear(); // all keys up, modifiers included + chord.active = false; + // "Release every key" has to mean "and stop typing", not "pause". Every fail-safe + // route (R, disarm, target detached, queue overflow) comes through here, so a + // pasted run cannot outlive any of them. + typer.cancel(); +#endif +} + +void setArmed(bool on, const char *why); + +void disarm(const char *why) { setArmed(false, why); } + +void setArmed(bool on, const char *why) { + if (on && !canType()) { + control.sendError(5, "no USB host attached -- nothing to type into"); + Serial.println(F("[arm] refused: no USB host")); + return; + } + if (on == armed) { + control.sendStatus(); + return; + } + armed = on; + if (!on) { + releaseEverything(); + prevHeld = 0; // the press counter starts from nothing pressed + } + Serial.printf("[arm] %s (%s)\n", on ? "ARMED -- keystrokes go to the target" : "disarmed", + why ? why : ""); + flash(on ? kColorRed : kColorGreen, 220); + control.sendStatus(); +} + +// --------------------------------------------------------------------------- +// WiFi +// --------------------------------------------------------------------------- +bool apUp = false; +uint32_t lastStaAttemptMs = 0; + +void startAp() { + if (apUp) return; + WiFi.softAPConfig(IPAddress(192, 168, 4, 1), IPAddress(192, 168, 4, 1), + IPAddress(255, 255, 255, 0)); + apUp = WiFi.softAP(kr::kApSsid, kr::kApPass); + Serial.printf("[wifi] access point %s %s (%s)\n", kr::kApSsid, + apUp ? "up" : "FAILED", kr::kApGw); + if (apUp) flash(kColorBlue, 150); +} + +void stopAp() { + if (!apUp) return; + WiFi.softAPdisconnect(true); + apUp = false; + Serial.println(F("[wifi] access point down")); +} + +void connectSta() { + lastStaAttemptMs = millis(); + Serial.printf("[wifi] joining \"%s\"\n", staSsid); + WiFi.disconnect(); + WiFi.begin(staSsid, staPass); +} + +void wifiBringUp() { + WiFi.persistent(false); + WiFi.setSleep(false); // 20 ms beacon sleep is jitter a keyboard feels + WiFi.mode(WIFI_STA); + WiFi.setHostname(kr::kHostname); + + if (staSsid[0] == '\0') { + Serial.println(F("[wifi] no station configured -- access point only")); + WiFi.mode(WIFI_AP_STA); + startAp(); + } else { + connectSta(); + const uint32_t started = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - started < kr::kStaTimeoutMs) { + delay(50); + } + if (WiFi.status() != WL_CONNECTED) { + Serial.println(F("[wifi] station did not come up -- starting the access point too")); + WiFi.mode(WIFI_AP_STA); + startAp(); + } + } + if (WiFi.status() == WL_CONNECTED) { + Serial.printf("[wifi] station up ip=%s rssi=%d\n", WiFi.localIP().toString().c_str(), + WiFi.RSSI()); + flash(kColorBlue, 150); + } + MDNS.begin(kr::kHostname); // http://keyrelay.local -- addService() is the +} // control server's job, in begin() + +// Station preferred, access point as the fallback -- and never silently dropped +// while it is the only way back to the board. +void wifiMaintain(uint32_t now) { + const bool staUp = WiFi.status() == WL_CONNECTED; + + if (staSsid[0] != '\0') { + if (!staUp && now - lastStaAttemptMs >= kr::kStaRetryMs) connectSta(); + if (staUp && apUp && apMode != 1) stopAp(); + if (!staUp && !apUp && apMode != 2) { + WiFi.mode(WIFI_AP_STA); + startAp(); + } + } else if (!apUp && apMode != 2) { + WiFi.mode(WIFI_AP_STA); + startAp(); + } +} + +const char *netMode() { + if (WiFi.status() == WL_CONNECTED) return "sta"; + if (apUp) return "ap"; + return "down"; +} + +// --------------------------------------------------------------------------- +// Status / control callbacks +// --------------------------------------------------------------------------- +// WiFi.SSID() and IPAddress::toString() return temporaries, so the snapshot cannot +// point at them -- these buffers outlive the call. +char ssidBuf[40]; +char ipBuf[20]; + +void fillStatus(void *, kr::StatusSnapshot &s) { + s.version = kr::kFirmwareVersion; + s.uptimeS = millis() / 1000; +#if KR_HAVE_HID + s.usbHost = usbStarted; + s.ledState = targetLeds; +#endif + s.armed = armed; + s.netUp = WiFi.status() == WL_CONNECTED || apUp; + s.net = netMode(); + if (WiFi.status() == WL_CONNECTED) { + snprintf(ssidBuf, sizeof(ssidBuf), "%s", WiFi.SSID().c_str()); + snprintf(ipBuf, sizeof(ipBuf), "%s", WiFi.localIP().toString().c_str()); + s.ssid = ssidBuf; + } else if (apUp) { + s.ssid = kr::kApSsid; + snprintf(ipBuf, sizeof(ipBuf), "%s", WiFi.softAPIP().toString().c_str()); + } else { + s.ssid = ""; + ipBuf[0] = '\0'; + } + s.ip = ipBuf; + s.keysSent = keysSent; + s.chordsSent = chordsSent; + s.dropped = dropped; + s.typedChars = typedChars; + s.typing = (uint16_t)typer.pending(); // flow control: how much is left to type + s.rollover = keys.rollover(); + s.defaultToken = strcmp(token, kr::kDefaultToken) == 0; + s.apForced = apMode == 1; +} + +void linkChanged(void *, bool linked) { + Serial.printf("[link] controller %s\n", linked ? "authenticated" : "gone"); + flash(linked ? kColorBlue : kColorAmber, 180); + // A controller that vanished must not leave its last key state latched on the + // target: whoever picks up the keyboard next starts from nothing pressed. + if (!linked) disarm("controller disconnected"); +} + +#if KR_HAVE_HID +void pushTrackedState() { + if (!keys.dirty()) return; + keys.ackDirty(); + if (!armed) return; + if (!queue.push(keys.report())) { + // Fail safe, not a dropped key: something is outrunning the queue, so stop + // typing rather than type out of step with what the operator sees. + dropped++; + releaseEverything(); + control.sendError(7, "report queue overflow -- released and disarmed"); + setArmed(false, "queue overflow"); + } +} +#endif + +void startChord(uint8_t mods, const uint8_t *usages, size_t n, uint32_t holdMs, + const char *label) { +#if KR_HAVE_HID + if (!armed) { control.sendError(4, "not armed"); return; } + if (!canType()) { control.sendError(5, "no USB host attached"); return; } + if (chord.active) { control.sendError(6, "a chord is still being held"); return; } + + const kr::KeyboardReport press = kr::makeReport(mods, usages, n); + if (!queue.push(press)) { + dropped++; + releaseEverything(); + setArmed(false, "queue overflow"); + return; + } + chord.active = true; + chord.releaseAtMs = millis() + holdMs; + chordsSent++; + Serial.printf("[hid] chord %s (%u ms)\n", label, (unsigned)holdMs); +#else + (void)mods; (void)usages; (void)n; (void)holdMs; (void)label; + control.sendError(5, "this build has no HID interface"); +#endif +} + +void onMessage(void *, const proto::Message &msg) { + switch (msg.cmd) { + case proto::Cmd::Hello: + control.sendStatus(); + return; + + case proto::Cmd::KeepAlive: + return; // freshness is already recorded + + case proto::Cmd::Arm: + setArmed(true, "controller"); + return; + + case proto::Cmd::Disarm: + setArmed(false, "controller"); + return; + + case proto::Cmd::ReleaseAll: +#if KR_HAVE_HID + releaseEverything(); +#endif + return; + + case proto::Cmd::State: { + if (!armed) { control.sendError(4, "not armed"); return; } +#if KR_HAVE_HID + keys.apply(msg.mods, msg.usages, msg.nUsages); + const uint32_t held = (uint32_t)keys.held(); + if (held > prevHeld) keysSent += held - prevHeld; // count presses, not frames + prevHeld = held; + pushTrackedState(); +#endif + return; + } + + case proto::Cmd::SecureAttention: { + // Fixed on the device side, not taken from the wire: the combination that + // reboots a Linux VT or unlocks a Windows box is not something a malformed + // frame should be able to turn into something else. + const uint8_t del = kr::kUsageDelForward; + startChord(kr::kModLctrl | kr::kModLalt, &del, 1, kr::kCadHoldMs, "Ctrl+Alt+Del"); + return; + } + + case proto::Cmd::Chord: + startChord(msg.mods, msg.usages, msg.nUsages, kr::kChordHoldMs, "chord"); + return; + + case proto::Cmd::Text: { + // A pasted run is still gated by everything else: armed, and a target to type + // into. What it is NOT is stored -- the characters live in RAM until they have + // gone out, and cancel() is reachable from every fail-safe. + if (!armed) { control.sendError(4, "not armed"); return; } +#if KR_HAVE_HID + if (!canType()) { control.sendError(5, "no USB host attached"); return; } + // Accepted as a unit: a frame that cannot be taken contributes nothing, so the + // controller can retry exactly the same characters. + const kr::text::Outcome o = typer.feed(msg.payload, msg.nPayload); + if (o.result == kr::text::Result::Full) { + control.sendError(8, "text buffer full -- wait for the run to drain"); + return; + } + if (o.result == kr::text::Result::Untypeable) { + // Named by code, never by content: say what could not be typed, and type + // none of it, rather than send a paste that quietly lost a character. + char why[80]; + snprintf(why, sizeof(why), + "0x%02X at offset %u has no US-ANSI keystroke -- nothing typed", + o.byte, (unsigned)o.index); + control.sendError(9, why); + return; + } + typer.start(millis(), msg.periodMs); + // Length and pace only. Nothing here records what the characters were. + Serial.printf("[text] %u chars queued, %u ms per character\n", + (unsigned)typer.pending(), (unsigned)typer.periodMs()); + control.sendStatus(); +#else + control.sendError(5, "this build has no HID interface"); +#endif + return; + } + + default: + control.sendError(6, "unrecognised command"); + return; + } +} + +const char *getToken(void *) { return token; } + +// --------------------------------------------------------------------------- +// USB +// --------------------------------------------------------------------------- +#if KR_HAVE_HID +// The identity the target sees in lsusb / its input device list. Kept identical to +// the jiggle firmware on this same board so the two are indistinguishable from the +// host's point of view, which also keeps the by-id device names the tools use +// stable across reflashes. Personal use only: 0x046D belongs to Logitech. +constexpr uint16_t kUsbVid = 0x046D; // Logitech, Inc. +constexpr uint16_t kUsbPid = 0xC33F; // G815 Mechanical Keyboard +constexpr char kUsbManufacturer[] = "Logitech"; +constexpr char kUsbProduct[] = "Gaming Keyboard G815"; +constexpr char kUsbSerial[] = "00000000"; + +void setUsbIdentity() { + const bool ok = USB.VID(kUsbVid) && USB.PID(kUsbPid) && + USB.manufacturerName(kUsbManufacturer) && + USB.productName(kUsbProduct) && USB.serialNumber(kUsbSerial); + Serial.printf("[usb] identity %04x:%04x \"%s\" \"%s\"%s\n", USB.VID(), USB.PID(), + USB.manufacturerName(), USB.productName(), + ok ? "" : " <-- SETTERS RAN TOO LATE"); +} + +void onUsbEvent(void *, esp_event_base_t base, int32_t id, void *) { + if (base != ARDUINO_USB_EVENTS) return; + switch (id) { + case ARDUINO_USB_STARTED_EVENT: + usbStarted = true; + Serial.println(F("[usb] target attached")); + flash(kColorBlue, 200); + control.sendStatus(); + break; + case ARDUINO_USB_STOPPED_EVENT: + usbStarted = false; + Serial.println(F("[usb] target detached")); + releaseEverything(); + disarm("target detached"); + control.sendStatus(); + break; + case ARDUINO_USB_SUSPEND_EVENT: + Serial.println(F("[usb] target suspended the bus")); + releaseEverything(); + disarm("target suspended"); + control.sendStatus(); + break; + case ARDUINO_USB_RESUME_EVENT: + Serial.println(F("[usb] bus resumed")); + break; + default: break; + } +} + +// The target drives the keyboard LEDs (caps lock and friends). Showing its real +// state in the controller beats guessing, because the toggle physically happens on +// the target, not here. +void onKeyboardLed(void *, esp_event_base_t, int32_t, void *event_data) { + auto *data = static_cast(event_data); + targetLeds = data->leds; +} + +void drainReports() { + for (size_t i = 0; i < kMaxReportsPerLoop; i++) { + const kr::KeyboardReport *r = queue.peek(); + if (!r) return; + if (!usbStarted) { // nobody to receive them + dropped += (uint32_t)queue.count; + queue.clear(); + return; + } + if (!tud_hid_n_ready(0)) return; // endpoint still busy; try next loop + KeyReport wire; + wire.modifiers = r->modifiers; + wire.reserved = 0; + memcpy(wire.keys, r->keys, sizeof(wire.keys)); + Keyboard.sendReport(&wire); // synchronous: returns once transmitted + queue.pop(); + } +} +#endif // KR_HAVE_HID + +// --------------------------------------------------------------------------- +// Button +// --------------------------------------------------------------------------- +bool readButton() { return digitalRead(kButtonPin) == LOW; } + +/** + * GPIO0 is not broken out on this board, and once TinyUSB owns GPIO19/20 the host + * cannot pulse the board into its bootloader by itself, so the firmware does it: + * holding the button from power-on hands the USB pads back and forces a + * download-mode boot without touching GPIO0. (Same path the jiggle firmware uses.) + */ +void waitForRecoveryRequest() { +#if KR_HAVE_HID + if (!readButton()) return; + const uint32_t startedAt = millis(); + while (millis() - startedAt < kRecoveryHoldMs) { + if (!readButton()) return; + led.setPixelColor(0, kColorRed); + led.show(); + delay(20); + } + Serial.println(F("[boot] hold detected -- entering ROM USB download mode")); + for (int i = 0; i < 6; i++) { + led.setPixelColor(0, (i & 1) ? kColorOff : kColorRed); + led.show(); + delay(80); + } + usb_persist_restart(RESTART_BOOTLOADER); // does not return + esp_restart(); +#endif +} + +bool buttonPressed = false; +bool buttonCandidate = false; +uint8_t buttonCandidateCount = 0; +uint32_t lastSampleMs = 0; + +void updateButton(uint32_t now) { + if (now - lastSampleMs < kSampleIntervalMs) return; + lastSampleMs = now; + const bool pressed = readButton(); + if (pressed != buttonCandidate) { + buttonCandidate = pressed; + buttonCandidateCount = 1; + } else if (buttonCandidateCount < kDebounceSamples && + ++buttonCandidateCount >= kDebounceSamples && pressed != buttonPressed) { + buttonPressed = pressed; + // The button at the device is the kill switch: it can always disarm, and it is + // the only way to arm when there is no network in the room. + setArmed(!armed, "button"); + } +} + +// --------------------------------------------------------------------------- +// Serial console (CDC on the same composite device, /dev/ttyACM0) +// --------------------------------------------------------------------------- +char line[kConsoleLineCap]; +size_t lineLen = 0; + +void printHelp() { + Serial.println(F( + "commands:\n" + " status what the board and the target look like\n" + " wifi [passphrase] station network to join (empty ssid clears)\n" + " wifi clear forget the station network\n" + " ap auto|force|off access point policy (auto = when station is down)\n" + " token control passphrase, max 32 chars\n" + " arm | disarm same as the button, from the console\n" + " reboot\n" + "There is deliberately no command that types anything. Keystrokes come from\n" + "a human at a keyboard, through the controller page. Pasted text is a controller\n" + "action too: it needs the bridge armed, and nothing is remembered by the board.\n")); +} + +void printStatus() { + kr::StatusSnapshot s; + fillStatus(nullptr, s); + char json[320]; + kr::statusJson(s, json, sizeof(json)); + Serial.printf("status %s\n", json); + Serial.printf("link controller=%s rejected=%u authfails=%u\n", + control.linked() ? "yes" : "no", (unsigned)control.rejected(), + (unsigned)control.authFails()); + Serial.printf("queue %u pending, %u held, %s\n", (unsigned)queue.count, + (unsigned)keys.held(), keys.rollover() ? "ROLLOVER" : "ok"); + Serial.printf("text %u chars typed, %u still to go\n", (unsigned)typedChars, + (unsigned)typer.pending()); +} + +// Split a line into `verb` and the rest of the line, trimmed. Returns false if the +// line is empty. +bool splitLine(char *buf, char *&verb, char *&rest) { + char *p = buf; + while (*p == ' ') p++; + if (*p == '\0') return false; + verb = p; + while (*p && *p != ' ') p++; + rest = nullptr; + if (*p) { *p++ = '\0'; while (*p == ' ') p++; rest = *p ? p : nullptr; } + return true; +} + +void handleConsoleLine(char *buf) { + char *verb = nullptr, *rest = nullptr; + if (!splitLine(buf, verb, rest)) return; + + if (strcmp(verb, "help") == 0 || strcmp(verb, "?") == 0) { printHelp(); return; } + if (strcmp(verb, "status") == 0) { printStatus(); return; } + if (strcmp(verb, "reboot") == 0) { Serial.println(F("[boot] rebooting")); delay(50); + esp_restart(); return; } + if (strcmp(verb, "arm") == 0) { setArmed(true, "console"); return; } + if (strcmp(verb, "disarm") == 0) { setArmed(false, "console"); return; } + + if (strcmp(verb, "wifi") == 0) { + if (!rest) { Serial.println(F("usage: wifi [passphrase] | wifi clear")); return; } + char *pass = strchr(rest, ' '); + if (pass) { *pass++ = '\0'; while (*pass == ' ') pass++; } + if (strcmp(rest, "clear") == 0) { + saveSta("", ""); + WiFi.disconnect(true); + Serial.println(F("[wifi] station forgotten; the access point is the way back in")); + } else { + saveSta(rest, pass ? pass : ""); + Serial.printf("[wifi] station set to \"%s\" -- reconnecting\n", rest); + WiFi.mode(WIFI_STA); + connectSta(); + } + return; + } + + if (strcmp(verb, "ap") == 0) { + if (!rest) { Serial.printf("ap mode: %s\n", + apMode == 0 ? "auto" : (apMode == 1 ? "force" : "off")); + return; } + if (strcmp(rest, "auto") == 0) { saveApMode(0); Serial.println(F("[wifi] ap: auto")); } + else if (strcmp(rest, "force") == 0) { + saveApMode(1); WiFi.mode(WIFI_AP_STA); startAp(); + Serial.println(F("[wifi] ap: always on")); + } else if (strcmp(rest, "off") == 0) { + saveApMode(2); stopAp(); + Serial.println(F("[wifi] ap: OFF -- if the station network fails you can only " + "change this over serial")); + } else { + Serial.println(F("usage: ap auto|force|off")); + } + return; + } + + if (strcmp(verb, "token") == 0) { + if (!rest) { + Serial.printf("token: %s\n", strcmp(token, kr::kDefaultToken) == 0 + ? "DEFAULT -- change it" : "set (not shown)"); + return; + } + if (strlen(rest) < 6 || strlen(rest) > kr::kTokenMax) { + Serial.printf("usage: token <6..%u chars>\n", (unsigned)kr::kTokenMax); + return; + } + saveToken(rest); + // Never echo the passphrase back into a serial log or a shell history. + memset(rest, '*', strlen(rest)); + Serial.println(F("[auth] passphrase stored")); + return; + } + + Serial.print(F("unknown command \"")); + Serial.print(verb); + Serial.print(F("\" -- try `help`\n")); +} + +void pollConsole() { + while (Serial.available()) { + const int c = Serial.read(); + if (c < 0) return; + const char ch = (char)c; + if (ch == '\r' || ch == '\n') { + if (lineLen) { + line[lineLen] = '\0'; + // A reply is only ever written because a host just sent us bytes, so the + // host is there and reading: let the write block until it is flushed. The + // global timeout is 0 (never wait, drop instead) because logging into a port + // nobody has opened must not stall loop(), but that same setting truncates a + // multi-line reply at the 128-byte TinyUSB CDC TX ring -- measured, in the + // middle of a word. Replies are not allowed to be truncated. + Serial.setTxTimeoutMs(500); + handleConsoleLine(line); + Serial.flush(); + Serial.setTxTimeoutMs(0); + lineLen = 0; + } + continue; + } + if (ch == '\b' || ch == 0x7f) { if (lineLen) lineLen--; continue; } + if (lineLen < sizeof(line) - 1) line[lineLen++] = ch; + else lineLen = 0; // absurdly long line: drop it whole + } +} + +} // namespace + +void setup() { + pinMode(kButtonPin, INPUT_PULLUP); + + led.begin(); + led.setBrightness(kBrightness); + led.clear(); + led.show(); + + Serial.begin(115200); + Serial.setTxTimeoutMs(0); // never stall the loop because nobody is reading + +#if KR_HAVE_HID + USB.onEvent(onUsbEvent); + setUsbIdentity(); // must precede USB.begin() + Keyboard.begin(); // descriptors freeze at USB.begin() + Keyboard.onEvent(onKeyboardLed); + if (!USB.begin()) Serial.println(F("[usb] FAILED to start the TinyUSB device stack")); +#endif + + waitForRecoveryRequest(); + +#if KR_HAVE_HID + for (uint32_t t = millis(); !usbStarted && millis() - t < 1000; delay(10)) {} +#endif + + loadSettings(); + ledSelfTest(); + + Serial.println(); + Serial.printf("%s %s -- %s\n", kr::kFirmwareName, kr::kFirmwareVersion, + KR_HAVE_HID ? "USB HID keyboard bridge" + : "CONTROL PLANE ONLY (this build cannot type)"); + wifiBringUp(); + + control.begin(onMessage, linkChanged, fillStatus, getToken, nullptr); + + if (strcmp(token, kr::kDefaultToken) == 0) { + Serial.println(F("!! the control passphrase is still the default -- set one with" + " `token ` before plugging into a target")); + } + Serial.print(F("url http://")); + Serial.println(WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() + : String(kr::kApGw)); + Serial.println(F("button short press = arm / disarm. Console: `help`")); +} + +void loop() { + const uint32_t now = millis(); + + updateButton(now); + control.tick(now); + pollConsole(); + wifiMaintain(now); + + // Silence disarms (config.h kNoClientTimeoutMs). A live controller says something + // every couple of seconds -- the page's keepalive, or its automatic ping replies -- + // so this only ever fires when the controller is gone without having said goodbye: + // laptop slept, browser killed, WiFi died without a FIN. That is exactly the moment + // a pasted run must stop typing, which is why this is checked before the typewriter + // is driven rather than after. + if (armed && control.linked() && + static_cast(now - control.lastInboundMs()) > + static_cast(kr::kNoClientTimeoutMs)) { + disarm("controller silent"); + } + +#if KR_HAVE_HID + // Release the synthetic chord after its hold, back to exactly what is tracked. + // Nothing else touches the tracker, so the operator's held keys survive a chord. + if (chord.active && now >= chord.releaseAtMs) { + chord.active = false; + if (armed && usbStarted) { + if (!queue.push(keys.report())) { + dropped++; + releaseEverything(); + setArmed(false, "queue overflow"); + } + } + } + // A pasted run, typed on this clock and no other. text.h emits at most one report + // transition per call, so a fast pace cannot deepen the queue, and the queue's own + // overflow path releases and disarms if the target stops taking reports. + for (size_t i = 0; i < kMaxReportsPerLoop && typer.active(); i++) { + if (!armed || !usbStarted) { typer.cancel(); break; } + kr::KeyboardReport r; + bool pressed = false; + if (!typer.step(now, keys, r, pressed)) break; + if (!queue.push(r)) { + dropped++; + releaseEverything(); + control.sendError(7, "report queue overflow -- released and disarmed"); + setArmed(false, "queue overflow"); + break; + } + if (pressed) typedChars++; + } + + drainReports(); +#endif + + updateLed(now); +} diff --git a/src/proto.h b/src/proto.h new file mode 100644 index 0000000..6014cc8 --- /dev/null +++ b/src/proto.h @@ -0,0 +1,379 @@ +/** + * keyrelay -- the control message protocol. Pure logic, host-tested + * (tests/test_proto.cpp). + * + * A WebSocket text frame is exactly one command, ASCII, no JSON. Deliberately + * boring: it has to be trivial to type by hand with the test client, trivial to + * audit byte-for-byte on the wire, and impossible for one malformed field to + * desynchronise what follows it. + * + * direction message meaning + * --------- --------------------- ------------------------------------------- + * c -> d T authenticate; must be the first message + * c -> d H hello: reply with status now + * c -> d A1 | A0 arm / disarm the bridge + * c -> d S=:,... the COMPLETE key state (see keystate.h) + * c -> d C Ctrl+Alt+Del, one synthetic burst + * c -> d Y=:,... one synthetic burst of a chord the laptop's + * own OS steals (VT switch, Alt+Tab, PrtSc) + * c -> d P=: type pasted text, ms per character + * c -> d R release every key (kept armed) + * c -> d K keepalive + * d -> c OK accepted, or the reply to H + * d -> c E: error, e.g. E4:arm required + * d -> c S{json} status heartbeat, 1 Hz and on change + * + * `` is the modifier byte as two hex digits, `` a HID usage id as two + * hex digits, lowercase or uppercase. Empty key lists are legal (`S=00:` means + * "nothing pressed"), which is what makes the protocol self-healing. + * + * `P=` is the one command whose payload is text rather than a key state: `` + * is the bytes to type (so at most kTextFrameMax characters per frame) and `` + * is the pace in milliseconds per character, 0 for the board's default. The board + * does the expansion and the pacing -- see text.h. `R` stops a run in progress. + * + * Nothing except A, R and the handshake is honoured while the bridge is disarmed. + */ +#pragma once + +#include +#include +#include + +#include "config.h" +#include "keystate.h" +#include "text.h" + +namespace kr { +namespace proto { + +enum class Cmd : uint8_t { + None, + Hello, + Auth, + Arm, + Disarm, + State, + SecureAttention, // Ctrl+Alt+Del + Chord, // one-shot burst of an arbitrary chord + Text, // pasted text to type, paced on the board + ReleaseAll, + KeepAlive, + Bad, +}; + +struct Message { + Cmd cmd; + uint8_t mods; + uint8_t usages[kMaxKeysPerMessage]; + uint8_t nUsages; + char text[kTokenMax + 1]; // Auth + uint8_t payload[kTextFrameMax]; // Text: the characters to type + uint8_t nPayload; + uint16_t periodMs; // Text: ms per character, 0 = board default +}; + +inline void initMessage(Message &m) { + m.cmd = Cmd::None; + m.mods = 0; + m.nUsages = 0; + memset(m.usages, 0, sizeof(m.usages)); + m.text[0] = '\0'; + m.nPayload = 0; + memset(m.payload, 0, sizeof(m.payload)); + m.periodMs = 0; +} + +inline const char *name(Cmd c) { + switch (c) { + case Cmd::Hello: return "hello"; + case Cmd::Auth: return "auth"; + case Cmd::Arm: return "arm"; + case Cmd::Disarm: return "disarm"; + case Cmd::State: return "state"; + case Cmd::SecureAttention: return "secure-attention"; + case Cmd::Chord: return "chord"; + case Cmd::Text: return "text"; + case Cmd::ReleaseAll: return "release-all"; + case Cmd::KeepAlive: return "keepalive"; + default: return "bad"; + } +} + +inline int hexNibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +// Exactly two hex digits (0x00..0xFF), no whitespace, no prefix. +inline bool parseByte(const char *s, size_t len, uint8_t &out) { + if (len != 2) return false; + const int hi = hexNibble(s[0]), lo = hexNibble(s[1]); + if (hi < 0 || lo < 0) return false; + out = static_cast((hi << 4) | lo); + return true; +} + +/** + * Parse `:[[,...]]` -- the colon is required even when no key + * is pressed, so `S=00:` is "nothing down" and `S=00` is a syntax error. + * + * Strict on purpose: every field is exactly two hex digits, a usage id must be + * 1..0xFF (0x00 is "no key", not a key), the list cannot exceed + * kMaxKeysPerMessage, and a trailing comma or a stray space rejects the whole + * message rather than being guessed at. Rejecting means the previous state simply + * stays in effect, which is always the safe outcome. + */ +inline bool parseStateSpec(const char *s, size_t len, uint8_t &mods, uint8_t *usages, + size_t cap, size_t &n) { + n = 0; + mods = 0; + if (len < 3) return false; + if (!parseByte(s, 2, mods)) return false; + if (s[2] != ':') return false; // the delimiter is not optional + + size_t i = 3; + while (i < len) { + size_t j = i; + while (j < len && s[j] != ',') j++; + uint8_t u = 0; + if (!parseByte(s + i, j - i, u)) return false; // exactly two hex digits + if (u == kUsageNone) return false; + if (n >= cap) return false; + usages[n++] = u; + if (j == len) break; + i = j + 1; + if (i == len) return false; // trailing comma + } + return true; +} + +/** + * Parse `P=:` -- the digits are required, the colon is required, the hex + * must be an even count of digits and non-empty, and every byte must decode. The + * characters themselves are NOT validated here: whether a byte is a keystroke this + * board can make is text.h's question, asked once, at the point where accepting it + * is atomic (Typewriter::feed). + * + * A frame is capped at kTextFrameMax characters because one WebSocket frame is one + * command, not a payload; a long paste is the controller's business to split, and + * the board says how much it still has to type. + */ +inline Cmd parseText(const char *data, size_t len, Message &out) { + // `data` starts at the '=', so "P=0:61" arrives here as 5 bytes. + if (len < 5 || data[0] != '=') return Cmd::Bad; + + size_t i = 1; + uint32_t ms = 0; + for (; i < len && data[i] >= '0' && data[i] <= '9'; i++) { + ms = ms * 10u + static_cast(data[i] - '0'); + if (ms > kTextPeriodMaxMs) return Cmd::Bad; // absurdly slow is a typo + } + if (i == 1 || i >= len || data[i] != ':') return Cmd::Bad; + + const char *hex = data + i + 1; + const size_t hn = len - i - 1; + if (hn == 0 || (hn & 1U)) return Cmd::Bad; // whole bytes only + const size_t n = hn / 2; + if (n > kTextFrameMax) return Cmd::Bad; + + for (size_t j = 0; j < n; j++) { + const int hi = hexNibble(hex[j * 2]), lo = hexNibble(hex[j * 2 + 1]); + if (hi < 0 || lo < 0) return Cmd::Bad; + out.payload[j] = static_cast((hi << 4) | lo); + } + out.nPayload = static_cast(n); + out.periodMs = static_cast(ms); + return Cmd::Text; +} + +/** + * Parse one command frame. `len` must be the frame's payload length; a trailing + * CR or LF is tolerated (hand-typed test clients) but nothing else after the + * command is. + */ +inline Cmd parse(const char *data, size_t len, Message &out) { + initMessage(out); + while (len && (data[len - 1] == '\r' || data[len - 1] == '\n')) len--; + if (len == 0 || len > kWsMessageCap) return out.cmd = Cmd::Bad; + + const char c = data[0]; + switch (c) { + case 'H': + if (len != 1) return out.cmd = Cmd::Bad; + return out.cmd = Cmd::Hello; + + case 'K': + if (len != 1) return out.cmd = Cmd::Bad; + return out.cmd = Cmd::KeepAlive; + + case 'R': + if (len != 1) return out.cmd = Cmd::Bad; + return out.cmd = Cmd::ReleaseAll; + + case 'C': + if (len != 1) return out.cmd = Cmd::Bad; + return out.cmd = Cmd::SecureAttention; + + case 'A': + if (len != 2) return out.cmd = Cmd::Bad; + if (data[1] == '1') return out.cmd = Cmd::Arm; + if (data[1] == '0') return out.cmd = Cmd::Disarm; + return out.cmd = Cmd::Bad; + + case 'T': { + const size_t n = len - 1; + if (n == 0 || n > kTokenMax) return out.cmd = Cmd::Bad; + for (size_t i = 0; i < n; i++) { + const char t = data[1 + i]; + const bool ok = (t >= 'a' && t <= 'z') || (t >= 'A' && t <= 'Z') || + (t >= '0' && t <= '9') || t == '.' || t == '_' || t == '-' || + t == ':'; + if (!ok) return out.cmd = Cmd::Bad; // no control characters in a token + } + memcpy(out.text, data + 1, n); + out.text[n] = '\0'; + return out.cmd = Cmd::Auth; + } + + case 'S': + case 'Y': { + // "S=00:" is the shortest legal message: the colon separates the modifier + // byte from the (possibly empty) key list, so it is never optional. + if (len < 5 || data[1] != '=') return out.cmd = Cmd::Bad; + size_t n = 0; + if (!parseStateSpec(data + 2, len - 2, out.mods, out.usages, + kMaxKeysPerMessage, n)) { + return out.cmd = Cmd::Bad; + } + out.nUsages = static_cast(n); + // A state may be completely empty ("nothing is pressed"). A chord may be + // empty of *usages* -- that is how a lone Super/Win press is expressed -- + // but it must press at least a modifier, or it is not a chord at all. + if (c == 'Y' && n == 0 && out.mods == 0) return out.cmd = Cmd::Bad; + return out.cmd = (c == 'S') ? Cmd::State : Cmd::Chord; + } + + case 'P': + return out.cmd = parseText(data + 1, len - 1, out); + + default: + return out.cmd = Cmd::Bad; + } +} + +/** + * Render a state message -- the client side of the protocol is generated here so + * that the host tests and the tools share one definition of the wire format. + * Returns the length written, or 0 if the buffer was too small; on failure the + * buffer is left as the empty string, so a caller cannot send half a command. + */ +inline size_t formatState(char *out, size_t cap, uint8_t mods, const uint8_t *usages, + size_t n) { + static const char kHex[] = "0123456789abcdef"; + if (cap < 6) return 0; // "S=00:" plus its NUL + size_t o = 0; + out[o++] = 'S'; + out[o++] = '='; + out[o++] = kHex[(mods >> 4) & 0xF]; + out[o++] = kHex[mods & 0xF]; + out[o++] = ':'; + for (size_t i = 0; i < n; i++) { + const size_t need = o + (i ? 1 : 0) + 2 + 1; // comma, hex, NUL + if (need > cap) { out[0] = '\0'; return 0; } + if (i) out[o++] = ','; + out[o++] = kHex[(usages[i] >> 4) & 0xF]; + out[o++] = kHex[usages[i] & 0xF]; + } + out[o] = '\0'; + return o; +} + +/** + * Render a chord (`Y=`) the same way, for the test client. + */ +inline size_t formatChord(char *out, size_t cap, uint8_t mods, const uint8_t *usages, + size_t n) { + const size_t r = formatState(out, cap, mods, usages, n); + if (r) out[0] = 'Y'; + return r; +} + +/** + * Render a text message, so that every client -- the page, relayctl, the host + * tests -- produces byte-identical frames for the same paste. `periodMs` 0 means + * "board default" and is written literally as `0`. + * + * Returns 0 and leaves the buffer empty rather than sending half a paste. + */ +inline size_t formatText(char *out, size_t cap, uint16_t periodMs, + const uint8_t *bytes, size_t n) { + static const char kHex[] = "0123456789abcdef"; + if (!bytes || n == 0 || n > kTextFrameMax) return 0; + + // "P=" + the period in decimal + ":" + two hex digits per byte + NUL + char ms[8]; + size_t mn = 0; + // Out of range is refused rather than quietly clamped: a frame must not mean a + // different pace than the caller asked for. + if (periodMs > kTextPeriodMaxMs) return 0; + const uint16_t v = periodMs; + if (v == 0) ms[mn++] = '0'; + for (uint16_t t = v; t; t /= 10) ms[mn++] = static_cast('0' + (t % 10)); + for (size_t a = 0, b = mn - 1; a < b; a++, b--) { const char c = ms[a]; ms[a] = ms[b]; ms[b] = c; } + + const size_t need = 2 + mn + 1 + n * 2 + 1; + if (cap < need) { out[0] = '\0'; return 0; } + + size_t o = 0; + out[o++] = 'P'; + out[o++] = '='; + memcpy(out + o, ms, mn); + o += mn; + out[o++] = ':'; + for (size_t i = 0; i < n; i++) { + out[o++] = kHex[(bytes[i] >> 4) & 0xF]; + out[o++] = kHex[bytes[i] & 0xF]; + } + out[o] = '\0'; + return o; +} + +// Error frames. Codes are stable so a client can branch on them: +// 1 not authenticated 2 locked out (too many failures) +// 3 another controller holds the bridge +// 4 not armed 5 no USB host attached +// 6 bad message 7 too many keys for one report +// 8 text buffer full 9 a character this keyboard map cannot type +inline size_t formatError(char *out, size_t cap, uint8_t code, const char *text) { + const size_t n = strlen(text); + if (cap < 4 || n + 4 > cap) return 0; // never a truncated frame + out[0] = 'E'; + out[1] = static_cast('0' + (code % 10)); + out[2] = ':'; + memcpy(out + 3, text, n); + out[3 + n] = '\0'; + return 3 + n; +} + +/** + * Length-independent comparison of a passphrase. Not cryptographic-grade + * hardening, just enough that a timing walk over a 32-byte token on a LAN is not + * the easiest way in -- and cheap enough to always be on. + */ +inline bool constTimeEq(const char *a, size_t an, const char *b, size_t bn) { + if (an > kTokenMax || bn > kTokenMax) return false; + uint8_t diff = static_cast(an ^ bn); + const size_t common = an < bn ? an : bn; + for (size_t i = 0; i < common; i++) { + diff |= static_cast(a[i] ^ b[i]); + } + // Lengths differ: fold in a non-zero difference without an early return. + diff |= static_cast((an != bn) ? 0x80 : 0x00); + return diff == 0; +} + +} // namespace proto +} // namespace kr diff --git a/src/sha1.h b/src/sha1.h new file mode 100644 index 0000000..48acbf6 --- /dev/null +++ b/src/sha1.h @@ -0,0 +1,144 @@ +/** + * keyrelay -- SHA-1 and base64, for the WebSocket handshake only. + * + * Pure, dependency-free C++ so the exact code that runs on the board also + * compiles and is unit-tested on a host (tests/test_ws_codec.cpp). RFC 6455 + * requires SHA-1 plus standard base64 to turn the client's Sec-WebSocket-Key + * into Sec-WebSocket-Accept; nothing here is used for anything secret, which is + * why SHA-1 (broken for signatures, fine for a handshake checksum) is acceptable. + * + * ESP-IDF ships mbedtls, but linking it in for 20 bytes of hashing while also + * keeping this module host-testable is the worse trade. + */ +#pragma once + +#include +#include +#include + +namespace kr { + +class Sha1 { + public: + Sha1() { reset(); } + + void reset() { + _h[0] = 0x67452301u; _h[1] = 0xEFCDAB89u; _h[2] = 0x98BADCFEu; + _h[3] = 0x10325476u; _h[4] = 0xC3D2E1F0u; + _bits = 0; + _bufLen = 0; + } + + void update(const void *data, size_t len) { + const uint8_t *p = static_cast(data); + _bits += static_cast(len) * 8u; + while (len) { + const size_t take = len < 64 - _bufLen ? len : 64 - _bufLen; + memcpy(_buf + _bufLen, p, take); + _bufLen += take; + p += take; + len -= take; + if (_bufLen == 64) { + processBlock(_buf); + _bufLen = 0; + } + } + } + + void finish(uint8_t digest[20]) { + const uint64_t bits = _bits; + const uint8_t pad = 0x80; + update(&pad, 1); // 0x80 ... + const uint8_t zero = 0x00; + while (_bufLen != 56) update(&zero, 1); // ... 0x00s ... + uint8_t tail[8]; + for (int i = 0; i < 8; i++) tail[i] = static_cast(bits >> (56 - 8 * i)); + update(tail, 8); // ... and the big-endian bit count + + for (int i = 0; i < 5; i++) { + digest[4 * i] = static_cast(_h[i] >> 24); + digest[4 * i + 1] = static_cast(_h[i] >> 16); + digest[4 * i + 2] = static_cast(_h[i] >> 8); + digest[4 * i + 3] = static_cast(_h[i]); + } + reset(); + } + + private: + static uint32_t rol32(uint32_t v, unsigned n) { return (v << n) | (v >> (32 - n)); } + + void processBlock(const uint8_t *p) { + uint32_t w[80]; + for (int i = 0; i < 16; i++) { + w[i] = (uint32_t(p[4 * i]) << 24) | (uint32_t(p[4 * i + 1]) << 16) | + (uint32_t(p[4 * i + 2]) << 8) | uint32_t(p[4 * i + 3]); + } + for (int i = 16; i < 80; i++) w[i] = rol32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + + uint32_t a = _h[0], b = _h[1], c = _h[2], d = _h[3], e = _h[4]; + for (int i = 0; i < 80; i++) { + uint32_t f, k; + if (i < 20) { f = (b & c) | (~b & d); k = 0x5A827999u; } + else if (i < 40) { f = b ^ c ^ d; k = 0x6ED9EBA1u; } + else if (i < 60) { f = (b & c) | (b & d) | (c & d); k = 0x8F1BBCDCu; } + else { f = b ^ c ^ d; k = 0xCA62C1D6u; } + const uint32_t tmp = rol32(a, 5) + f + e + k + w[i]; + e = d; d = c; c = rol32(b, 30); b = a; a = tmp; + } + _h[0] += a; _h[1] += b; _h[2] += c; _h[3] += d; _h[4] += e; + } + + uint32_t _h[5]; + uint64_t _bits; + uint8_t _buf[64]; + size_t _bufLen; +}; + +inline void sha1(const void *data, size_t len, uint8_t digest[20]) { + Sha1 s; + s.update(data, len); + s.finish(digest); +} + +/** + * Standard base64 with '=' padding, no line breaks. + * + * Returns the number of characters written (excluding the NUL), which is 0 only + * for empty input. If `outCap` is smaller than ((len+2)/3)*4 + 1 it returns 0 and + * writes NOTHING at all, so a caller can never send a partial frame. Callers size + * the buffer from the input length (a 20-byte digest needs 32 bytes). + */ +inline size_t base64Encode(const uint8_t *in, size_t len, char *out, size_t outCap) { + static const char kTab[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const size_t need = ((len + 2) / 3) * 4; + if (outCap < need + 1) return 0; + + size_t o = 0; + size_t i = 0; + for (; i + 3 <= len; i += 3) { + const uint32_t v = (uint32_t(in[i]) << 16) | (uint32_t(in[i + 1]) << 8) | in[i + 2]; + out[o++] = kTab[(v >> 18) & 63]; + out[o++] = kTab[(v >> 12) & 63]; + out[o++] = kTab[(v >> 6) & 63]; + out[o++] = kTab[v & 63]; + } + const size_t rem = len - i; + if (rem == 1) { + const uint32_t v = uint32_t(in[i]) << 16; + out[o++] = kTab[(v >> 18) & 63]; + out[o++] = kTab[(v >> 12) & 63]; + out[o++] = '='; + out[o++] = '='; + } else if (rem == 2) { + const uint32_t v = (uint32_t(in[i]) << 16) | (uint32_t(in[i + 1]) << 8); + out[o++] = kTab[(v >> 18) & 63]; + out[o++] = kTab[(v >> 12) & 63]; + out[o++] = kTab[(v >> 6) & 63]; + out[o++] = '='; + } + out[o] = '\0'; + return o; +} + +} // namespace kr diff --git a/src/text.h b/src/text.h new file mode 100644 index 0000000..e2e1901 --- /dev/null +++ b/src/text.h @@ -0,0 +1,280 @@ +/** + * keyrelay -- pasted text, turned into paced keystrokes. Pure logic, host-tested + * (tests/test_text.cpp): no Arduino, no TinyUSB, the same code the board runs. + * + * What this is, and what it deliberately is not + * --------------------------------------------- + * A human pasted something and pressed a button. That is the whole justification + * for this module existing: the difference between "a remote keyboard" and "an + * unattended injection device" is a deliberate human action with the arming light + * on, and this preserves both. What it is NOT is a macro facility -- the board + * cannot store a run of text, cannot be told to type later, and forgets the + * characters completely as soon as they have gone out (or been cancelled). There + * is no queue that survives a disconnect, because a controller that vanished is a + * controller that stopped asking. + * + * Why the expansion lives on the board and not in the controller + * -------------------------------------------------------------- + * A controller reaches the board over WiFi, in frames, with whatever delay the + * network happens to insert. Typing a character is a press and a release with a + * hold either side of it, and the target samples the report on its own 1 ms poll + * schedule -- so the pacing has to be done by the one component that is on the + * USB bus, not by the one looking at a socket. The controller asks for a pace in + * milliseconds per character; the board keeps it. That also means a browser tab + * that stalls for a second mid-paste cannot produce a burst of 200 keys, and the + * CLI and the page get identical timing without duplicating the logic. + * + * The character map is US-ANSI + * ---------------------------- + * Positions, not characters: a usage id names a key on the board, and what the + * target makes of it is the target's keyboard layout. Same caveat every KVM has, + * documented in README ("Layout caveats"). A character this map cannot express -- + * anything outside printable ASCII, plus tab and newline -- is refused rather + * than guessed at or silently dropped, because silently dropping one is how a + * pasted passphrase or a pasted `rm` line quietly stops being what it was. + * + * `\r` types Enter. Normalising CRLF to LF is the controller's job (both the page + * and relayctl do it); doing it here would need a look-ahead that a chunk boundary + * could split. + */ +#pragma once + +#include +#include + +#include "config.h" +#include "keystate.h" + +namespace kr { +namespace text { + +// The result of offering characters to the typewriter. One outcome per frame, and +// it is all-or-nothing: a frame that cannot be accepted contributes nothing, so a +// rejected frame can never leave half a word typed. +enum class Result : uint8_t { Ok, Full, Untypeable }; + +struct Outcome { + Result result; + size_t index; // Untypeable: offset within this frame + uint8_t byte; // Untypeable: the character itself, for the error text +}; + +struct Stroke { + uint8_t mods; // modifier byte needed (LShift, for the shifted glyphs) + uint8_t usage; // HID usage id, usage page 0x07 + bool typable; // false: this map has no keystroke for the character +}; + +/** + * The keystroke that produces one character on a US-ANSI keyboard. + * + * Written as a switch rather than a 256-byte table so that every mapping is + * readable at a glance and auditable against the HID usage table -- the letters + * and digits are arithmetic, everything else is named. Tests cover every code + * point in the range, so a missing case cannot hide. + */ +inline Stroke strokeFor(uint8_t ch) { + Stroke s{0, 0, false}; + + if (ch >= 'a' && ch <= 'z') { + s.usage = static_cast(kr::kUsageA + (ch - 'a')); + s.typable = true; + return s; + } + if (ch >= 'A' && ch <= 'Z') { + s.mods = kr::kModLshift; + s.usage = static_cast(kr::kUsageA + (ch - 'A')); + s.typable = true; + return s; + } + if (ch >= '0' && ch <= '9') { + // '1'..'9' are 0x1E..0x26 in a run; '0' is the one that breaks the pattern. + s.usage = (ch == '0') ? static_cast(kr::kUsage0) + : static_cast(0x1D + (ch - '0')); + s.typable = true; + return s; + } + + uint8_t u = 0, m = 0; + switch (ch) { + case ' ': u = kr::kUsageSpace; break; + case '\t': u = kr::kUsageTab; break; + case '\n': u = kr::kUsageEnter; break; + case '\r': u = kr::kUsageEnter; break; + case '-': u = 0x2D; break; + case '=': u = 0x2E; break; + case '[': u = 0x2F; break; + case ']': u = 0x30; break; + case '\\': u = 0x31; break; + case ';': u = 0x33; break; + case '\'': u = 0x34; break; + case '`': u = 0x35; break; + case ',': u = 0x36; break; + case '.': u = 0x37; break; + case '/': u = 0x38; break; + case '!': u = 0x1E; m = kr::kModLshift; break; + case '@': u = 0x1F; m = kr::kModLshift; break; + case '#': u = 0x20; m = kr::kModLshift; break; + case '$': u = 0x21; m = kr::kModLshift; break; + case '%': u = 0x22; m = kr::kModLshift; break; + case '^': u = 0x23; m = kr::kModLshift; break; + case '&': u = 0x24; m = kr::kModLshift; break; + case '*': u = 0x25; m = kr::kModLshift; break; + case '(': u = 0x26; m = kr::kModLshift; break; + case ')': u = 0x27; m = kr::kModLshift; break; + case '+': u = 0x2E; m = kr::kModLshift; break; + case '_': u = 0x2D; m = kr::kModLshift; break; + case '{': u = 0x2F; m = kr::kModLshift; break; + case '}': u = 0x30; m = kr::kModLshift; break; + case '|': u = 0x31; m = kr::kModLshift; break; + case ':': u = 0x33; m = kr::kModLshift; break; + case '"': u = 0x34; m = kr::kModLshift; break; + case '~': u = 0x35; m = kr::kModLshift; break; + case '<': u = 0x36; m = kr::kModLshift; break; + case '>': u = 0x37; m = kr::kModLshift; break; + case '?': u = 0x38; m = kr::kModLshift; break; + default: break; // no keystroke for this character + } + if (u) { s.usage = u; s.mods = m; s.typable = true; } + return s; +} + +/** + * One run of pasted characters, being typed out at a fixed pace. + * + * The characters are held only until they have gone out. Nothing here is + * persistent, nothing here survives a disconnect, and cancel() is reachable from + * every fail-safe path (release-all, disarm, target detached, queue overflow). + */ +class Typewriter { + public: + /** + * Offer a frame's worth of characters. Either all of them are accepted or none + * are, so a controller that is told "no" can retry the same frame unchanged. + */ + Outcome feed(const uint8_t *chars, size_t n) { + if (n == 0) return {Result::Ok, 0, 0}; + if (_len + n > kTextJobCap) return {Result::Full, 0, 0}; + for (size_t i = 0; i < n; i++) { + if (!strokeFor(chars[i]).typable) return {Result::Untypeable, i, chars[i]}; + } + for (size_t i = 0; i < n; i++) _buf[_len++] = chars[i]; + return {Result::Ok, 0, 0}; + } + + /** + * Begin (or continue) typing. `periodMs` 0 means the board's default pace. + * + * A job already running keeps the pace it started with: the timing of text the + * operator is already watching must not depend on when a later frame happened to + * arrive. A controller that wants a different pace sends no more frames until + * `active()` is false. + */ + void start(uint32_t nowMs, uint16_t periodMs) { + if (periodMs == 0) periodMs = kTextPeriodMsDefault; + if (periodMs > kTextPeriodMaxMs) periodMs = kTextPeriodMaxMs; + if (!_running) { + _periodMs = periodMs; + _phase = Phase::Press; + _nextAtMs = nowMs; + } + _running = _len > _pos; + } + + // Drop everything still pending. Called by every fail-safe: this is the path + // that makes "release every key" mean "and stop typing", not "pause". + void cancel() { + _running = false; + _phase = Phase::Idle; + _pos = 0; + _len = 0; + } + + bool active() const { return _running; } + size_t pending() const { return _len - _pos; } + uint16_t periodMs() const { return _periodMs; } + + // A character is held for at most kTextHoldMaxMs and at most half the period, so + // a fast pace stays honest (press and release both land inside their period) and + // a slow one does not sit on the key for a quarter of a second. + uint16_t holdMs() const { + uint16_t h = _periodMs / 2; + if (h > kTextHoldMaxMs) h = kTextHoldMaxMs; + return h ? h : 1; + } + uint16_t gapMs() const { + const uint16_t h = holdMs(); + return (_periodMs > h) ? static_cast(_periodMs - h) : 0; + } + + /** + * Emit the report that is due at `nowMs`, if one is. At most one transition per + * call, which is what keeps the report queue shallow no matter what pace was + * asked for. + * + * `baseline` is the operator's own tracked key state: each character is typed on + * top of it and released back to it, so a paste can neither let go of a key + * being held nor strand one afterwards. `typedChar` reports a press, which is + * what the status counter counts. + */ + bool step(uint32_t nowMs, const KeyboardState &baseline, KeyboardReport &out, + bool &typedChar) { + typedChar = false; + if (!_running || _pos >= _len) return false; + if (static_cast(nowMs - _nextAtMs) < 0) return false; + + if (_phase == Phase::Release) { + out = baseline.report(); // exactly the restated state, no leftovers + _pos++; + if (_pos >= _len) { + cancel(); // finished: nothing kept, nothing pending + return true; + } + _phase = Phase::Press; + _nextAtMs = nowMs + gapMs(); + return true; + } + + const Stroke s = strokeFor(_buf[_pos]); + out = pressOf(baseline.report(), s); + typedChar = true; + _phase = Phase::Release; + _nextAtMs = nowMs + holdMs(); + return true; + } + + private: + enum class Phase : uint8_t { Idle, Press, Release }; + + // The character added to whatever is already down. More than six keys together + // is reported the way a real keyboard reports it -- ErrorRollOver -- including + // when the baseline got there on its own. + static KeyboardReport pressOf(const KeyboardReport &base, const Stroke &s) { + uint8_t list[kReportSlots + 1] = {0}; + size_t n = 0; + bool rolled = false; + for (size_t i = 0; i < kReportSlots; i++) { + const uint8_t u = base.keys[i]; + if (u == kUsageErrorRollover) rolled = true; // already too wide for a report + else if (u != kUsageNone) list[n++] = u; + } + if (rolled || n == kReportSlots) { + // Seven keys down is seven keys down, whichever of us pressed the seventh. + n = kReportSlots + 1; + } else { + list[n++] = s.usage; + } + return makeReport(static_cast(base.modifiers | s.mods), list, n); + } + + uint8_t _buf[kTextJobCap] = {0}; + size_t _pos = 0; // next character to type + size_t _len = 0; // one past the last character accepted + bool _running = false; + Phase _phase = Phase::Idle; + uint16_t _periodMs = kTextPeriodMsDefault; + uint32_t _nextAtMs = 0; +}; + +} // namespace text +} // namespace kr diff --git a/src/web_page.h b/src/web_page.h new file mode 100644 index 0000000..e20a718 --- /dev/null +++ b/src/web_page.h @@ -0,0 +1,845 @@ +/** + * keyrelay -- the controller page, served by the board itself (PROGMEM). + * + * Why the page lives on the board + * ------------------------------- + * Then the whole system is: plug the board into the target, open one URL on the + * laptop. No host software, no driver, no pairing database, nothing to install on + * a machine you may only be borrowing for ten minutes. The laptop's browser is the + * controller; the board is the wire. + * + * Deliberate UI decisions, all of them load-bearing: + * + * - NOTHING IS FORWARDED UNTIL ARM IS PRESSED. Page load, reconnect and reload + * all start disarmed. The device LED is off until armed and solid red while it + * is, so the state is readable from across the room, not just on screen. + * - Ctrl+Alt+Del is a button, and a hold-to-confirm one. The browser cannot see + * the real Ctrl+Alt+Del (the laptop's OS eats it), and on Linux a single CAD on + * a virtual terminal reboots the machine, so it must never be a typo. + * - Every key event is preventDefault()ed while armed, so the laptop's browser + * does not also act on Ctrl+W, Ctrl+R, F11, etc. + * - Losing focus, visibility or the socket releases every key immediately, disarms, + * 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. + * - Text goes to the target from one place: the box below, by pressing its button, + * while armed. It is not a macro facility -- there is no history, nothing is + * scheduled or repeated, and the board holds the characters only until it has + * typed them. One human, one keyboard, live. + * See README "What this deliberately is not". + * + * Keys typed while the caret is in one of this page's own fields belong to the page + * and are not forwarded -- otherwise the passphrase box, or this very text box, could + * not be typed in while armed. A modifier that went down outside a field is still + * released, so that exemption cannot strand a Ctrl on the target. + * + * Layout: KeyboardEvent.code names a physical position on a US-ANSY layout, and + * those positions are what travel as HID usage ids. If the TARGET is set to a + * non-US layout, positions map to different characters -- the same caveat every + * KVM has. Fix it on the target (set its layout to US) or remap CODE_TO_USAGE. + */ +#pragma once + +#include + +namespace kr { + +// Raw string, so the HTML below is literally what goes on the wire. +static const char kIndexHtml[] PROGMEM = R"HTML( + + + + +keyrelay + + + +
+

keyrelay

+
+ network + target + link + disarmed + caps num +
+
+ +
+ + + + +
+
+

Special keys

+
+ + +
+
+ + + + + + + + +
+
+ + + +
+

+ These are here because the laptop's window manager grabs them before the browser + ever sees them — press Super+Left on the laptop and the laptop tiles its own + window. So the bridge types the chord instead and the machine it is plugged into + is the one that reacts. Which window does what is that machine's own keybinding + (these three are KDE Plasma's: window left, window right, fullscreen). They need + the bridge armed, and each one is a single deliberate click. +

+
+ +
+

State

+
+
mode
—
+
address
—
+
uptime
—
+
keys sent
0
+
text sent
0
+
chords
0
+
dropped
0
+
+

Held now

+
nothing
+
+ +
+

Connection

+
+ + +
+ +

+ 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. +

+
+
+ +
+

Send text

+ +
+ + + + + idle +
+

+ The board types these characters as keystrokes, at a pace it keeps itself, so the + target sees a keyboard and not a paste. It needs the bridge armed, and one press of + this button — there is no history, nothing is saved, and nothing is ever typed + later. stop typing or Ctrl+Alt+Esc abandons the rest of a run. + Layout matters: characters are positions, so the target must be set to a US layout + for these to land as they read here. +

+
+ +
+
+

Log

+ + +
+
+
+
+ +
+ Shortcuts while armed: Ctrl+Alt+Esc release every key and disarm · + Ctrl+Alt+d the Ctrl+Alt+Del confirm · Esc and every other + key are forwarded to the target untouched. + Nothing is forwarded while disarmed; reload, focus loss and socket loss all disarm. +
+ + + + +)HTML"; + +constexpr size_t kIndexHtmlLen = sizeof(kIndexHtml) - 1; // without the NUL + +} // namespace kr diff --git a/src/ws_codec.h b/src/ws_codec.h new file mode 100644 index 0000000..0d3b348 --- /dev/null +++ b/src/ws_codec.h @@ -0,0 +1,443 @@ +/** + * keyrelay -- the HTTP upgrade handshake and the RFC 6455 frame codec. + * + * Pure logic (no WiFi, no sockets) so the same code paths run in the host tests + * (tests/test_ws_codec.cpp) and on the board. control_server.cpp owns nothing but + * bytes in and bytes out. + * + * Why hand-rolled instead of a WebSocket library + * --------------------------------------------- + * The control channel is one controller, a handful of tiny text commands, and a + * 1 Hz status line. A library buys fragmentation, permessage-deflate, TLS and + * many clients -- all of which is attack surface and none of which this needs. + * What is implemented here is exactly the subset the controller uses, and every + * deviation from it is a hard error that closes the socket: + * + * - only text, ping, pong and close frames; binary is an error + * - no fragmentation (an unfinised data frame is an error, not a reassembly) + * - client frames MUST be masked (RFC 6455 5.1); an unmasked one is an error + * - reserved bits must be clear; no extensions are negotiated + * - a payload longer than the caller's buffer is an error, never a truncation + */ +#pragma once + +#include +#include +#include + +#include "sha1.h" + +namespace kr { +namespace ws { + +// RFC 6455 1.3: the fixed GUID appended to Sec-WebSocket-Key before hashing. +constexpr char kGuid[] = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +enum Opcode : uint8_t { + kOpContinuation = 0x0, + kOpText = 0x1, + kOpBinary = 0x2, + kOpClose = 0x8, + kOpPing = 0x9, + kOpPong = 0xA, +}; + +// --------------------------------------------------------------------------- +// Handshake +// --------------------------------------------------------------------------- + +/** + * Sec-WebSocket-Accept for a Sec-WebSocket-Key: base64(SHA1(key + GUID)). + * Writes up to `outCap` (32 bytes is enough: 27 chars + NUL, worst case 28 for a + * 20-byte digest). Returns false, and leaves nothing half-written, if the buffer + * is too small or the key is empty. + */ +inline bool acceptFor(const char *key, size_t keyLen, char *out, size_t outCap) { + if (!key || keyLen == 0 || keyLen > 128 || outCap < 32) return false; + + char joined[160]; + if (keyLen + sizeof(kGuid) - 1 > sizeof(joined)) return false; + memcpy(joined, key, keyLen); + memcpy(joined + keyLen, kGuid, sizeof(kGuid) - 1); + + uint8_t digest[20]; + sha1(joined, keyLen + sizeof(kGuid) - 1, digest); + return base64Encode(digest, sizeof(digest), out, outCap) > 0; +} + +// --------------------------------------------------------------------------- +// Outbound frames (server -> client is never masked, per RFC 6455 5.1) +// --------------------------------------------------------------------------- + +/** + * Encode one unmasked frame. Returns the total frame length, or 0 if `cap` is + * too small (in which case nothing was written). + */ +inline size_t encodeFrame(uint8_t opcode, const void *payload, size_t len, uint8_t *out, + size_t cap) { + const bool extended16 = len >= 126 && len <= 0xFFFF; + const size_t header = 2 + (extended16 ? 2 : 0) + (len > 0xFFFF ? 8 : 0); + if (len > 0xFFFF) return 0; // controller messages are tiny + if (!out || cap < header + len) return 0; + + size_t o = 0; + out[o++] = static_cast(0x80 | (opcode & 0x0F)); // FIN set, no RSV + if (len < 126) { + out[o++] = static_cast(len); + } else { + out[o++] = 126; + out[o++] = static_cast(len >> 8); + out[o++] = static_cast(len); + } + if (len) memcpy(out + o, payload, len); + return header + len; +} + +inline size_t encodeText(const char *text, size_t len, uint8_t *out, size_t cap) { + return encodeFrame(kOpText, text, len, out, cap); +} + +// --------------------------------------------------------------------------- +// Inbound frames +// --------------------------------------------------------------------------- + +enum class In : uint8_t { NeedMore, Text, Ping, Pong, Close, Error }; + +enum class Err : uint8_t { + None = 0, + NotMasked, // a client frame without the mask bit + ReservedBits, // RSV1..3 set: an extension was negotiated by nobody + TooLong, // payload larger than the caller's buffer + Fragmented, // FIN clear on a data frame: reassembly is deliberately absent + BadOpcode, // binary, continuation, or anything unassigned + ControlTooLong, // control payload > 125 bytes (RFC 6455 5.5) + BadControlLen, // 16-bit extended length on a control frame +}; + +inline const char *describe(Err e) { + switch (e) { + case Err::NotMasked: return "frame not masked"; + case Err::ReservedBits: return "reserved bits set"; + case Err::TooLong: return "frame too long"; + case Err::Fragmented: return "fragmented frame not supported"; + case Err::BadOpcode: return "unsupported opcode"; + case Err::ControlTooLong: return "control frame too long"; + case Err::BadControlLen: return "control frame length encoding"; + default: return "ok"; + } +} + +/** + * Incremental inbound frame decoder: feed one byte at a time (which is exactly + * what a non-blocking WiFiClient gives you) and act on the return value. + * + * The payload lands unmasked in the caller's buffer; `payloadLen()` says how much. + * Call reset() after handling a frame to decode the next one. + */ +class Reader { + public: + Reader(uint8_t *buf, size_t cap) : _buf(buf), _cap(cap) { reset(); } + + void reset() { + _state = State::Opcode; + _opcode = 0; + _fin = false; + _masked = false; + _pending = 0; + _extNeeded = 0; + _maskPos = 0; + _got = 0; + _error = Err::None; + _done = Done::Nothing; // without this the reader is stuck on the old frame + } + + // Consume one byte. In::NeedMore until a frame is complete, then the frame's + // type once (subsequent feeds keep returning it until reset()). + In feed(uint8_t b) { + if (_done != Done::Nothing) return _done == Done::Failed ? In::Error : frameResult(); + + switch (_state) { + case State::Opcode: { + _fin = (b & 0x80) != 0; + if (b & 0x70) return fail(Err::ReservedBits); + _opcode = b & 0x0F; + if (_opcode != kOpText && _opcode != kOpPing && _opcode != kOpPong && + _opcode != kOpClose) { + return fail(Err::BadOpcode); // binary / continuation / unknown + } + // No reassembly anywhere: a fragmented message would have to be buffered + // per opcode, and a controller that cannot send one command per frame is + // not a controller this bridge wants to talk to. + if (!_fin) return fail(Err::Fragmented); + _state = State::Length; + return In::NeedMore; + } + + case State::Length: { + _masked = (b & 0x80) != 0; + const uint8_t len7 = b & 0x7F; + const bool control = (_opcode & 0x08) != 0; + if (control && len7 >= 126) return fail(Err::ControlTooLong); + if (len7 < 126) { + _pending = len7; + return startPayload(); + } + if (len7 == 126) { + _extNeeded = 2; + _pending = 0; + _state = State::Ext; + return In::NeedMore; + } + _extNeeded = 8; // 127: 64-bit length + _pending = 0; + _state = State::Ext; + return In::NeedMore; + } + + case State::Ext: { + _pending = (_pending << 8) | b; + if (--_extNeeded == 0) { + const bool control = (_opcode & 0x08) != 0; + if (control) return fail(Err::BadControlLen); + return startPayload(); + } + return In::NeedMore; + } + + case State::MaskKey: + _mask[_maskPos++ & 3] = b; + if ((_maskPos & 3) == 0) { + // A masked frame carries its mask key even with an empty payload, so an + // empty frame is complete the moment the key has been read. + if (_pending == 0) { + _state = State::Idle; + _done = Done::Frame; + return frameResult(); + } + _state = State::Payload; + } + return In::NeedMore; + + case State::Payload: { + // A frame larger than the buffer can never be accepted: partial delivery + // would look like a truncated command to the parser. + if (_got >= _cap) return fail(Err::TooLong); + // The mask index uses the index BEFORE the increment; do it in two + // statements -- `_buf[_got++] = b ^ _mask[_got & 3]` reads and writes _got + // without a sequence point, which is undefined behaviour (and was, until + // -Wsequence-point pointed at it). + const uint8_t plain = static_cast(b ^ _mask[_got & 3]); + _buf[_got] = plain; + _got++; + if (_got == _pending) { + _state = State::Idle; + _done = Done::Frame; + return frameResult(); + } + return In::NeedMore; + } + + default: + return In::NeedMore; + } + } + + // Convenience: feed a whole buffer; stops at the first completed frame so the + // caller can act on it and come back for the rest. + In feedAll(const uint8_t *data, size_t len, size_t *consumed = nullptr) { + for (size_t i = 0; i < len; i++) { + const In r = feed(data[i]); + if (consumed) *consumed = i + 1; + if (r != In::NeedMore) return r; + } + return In::NeedMore; + } + + uint8_t opcode() const { return _opcode; } + const uint8_t *payload() const { return _buf; } + size_t payloadLen() const { return _got; } + Err error() const { return _error; } + bool finished() const { return _done != Done::Nothing; } + + private: + enum class State : uint8_t { Idle, Opcode, Length, Ext, MaskKey, Payload }; + enum class Done : uint8_t { Nothing, Frame, Failed }; + + In frameResult() const { + switch (_opcode) { + case kOpText: return In::Text; + case kOpPing: return In::Ping; + case kOpPong: return In::Pong; + default: return In::Close; + } + } + + In startPayload() { + if (!_masked) return fail(Err::NotMasked); // browsers always mask; nobody else should get in + if (_pending > _cap) return fail(Err::TooLong); + // Always read the 4 mask bytes first -- even for a zero-length frame. + _maskPos = 0; + _got = 0; + _state = State::MaskKey; + return In::NeedMore; + } + + In fail(Err e) { + _error = e; + _done = Done::Failed; + _state = State::Idle; + return In::Error; + } + + uint8_t *_buf; + size_t _cap; + State _state; + uint8_t _opcode; + bool _fin; + bool _masked; + uint64_t _pending; + uint8_t _extNeeded; + uint8_t _mask[4]; + uint16_t _maskPos; + size_t _got; + Err _error; + Done _done; +}; + +// --------------------------------------------------------------------------- +// The upgrade request +// --------------------------------------------------------------------------- + +struct Request { + char method[8]; + char path[96]; + char query[96]; + char wsKey[64]; + bool upgradeWebsocket; + bool complete; // the full header block (CRLF CRLF) was seen +}; + +inline void initRequest(Request &r) { + r.method[0] = r.path[0] = r.query[0] = r.wsKey[0] = '\0'; + r.upgradeWebsocket = false; + r.complete = false; +} + +inline char lower(char c) { return (c >= 'A' && c <= 'Z') ? char(c + 32) : c; } + +inline bool ciEquals(const char *a, const char *b, size_t n) { + for (size_t i = 0; i < n; i++) { + if (lower(a[i]) != b[i]) return false; + } + return true; +} + +inline bool containsCi(const char *hay, size_t hayLen, const char *needle) { + const size_t n = strlen(needle); + if (n > hayLen) return false; + for (size_t i = 0; i + n <= hayLen; i++) { + if (ciEquals(hay + i, needle, n)) return true; + } + return false; +} + +/** + * Parse as much of an HTTP request header block as is buffered. + * Returns 0 when more bytes are needed, 1 on a parsed request, -1 when it is + * malformed or the header block is larger than `cap` (reject, don't buffer). + * + * `len` bytes of `buf` are inspected. Only the fields the bridge routes on are + * extracted: method, path, query, the Upgrade token and Sec-WebSocket-Key. + */ +inline int parseRequest(const char *buf, size_t len, Request &out) { + initRequest(out); + if (len < 4) return 0; + + const char *end = nullptr; + for (size_t i = 0; i + 3 < len; i++) { + if (buf[i] == '\r' && buf[i + 1] == '\n' && buf[i + 2] == '\r' && buf[i + 3] == '\n') { + end = buf + i; + break; + } + } + if (!end) return 0; // headers incomplete + out.complete = true; + const size_t blockLen = static_cast(end - buf); + + // Request line: METHOD SP TARGET SP HTTP/x.y + const char *sp1 = static_cast(memchr(buf, ' ', blockLen)); + if (!sp1) return -1; + const size_t mLen = static_cast(sp1 - buf); + if (mLen == 0 || mLen >= sizeof(out.method)) return -1; + memcpy(out.method, buf, mLen); + out.method[mLen] = '\0'; + + const char *target = sp1 + 1; + const char *sp2 = static_cast(memchr(target, ' ', blockLen - mLen - 1)); + if (!sp2) return -1; + size_t tLen = static_cast(sp2 - target); + const char *q = static_cast(memchr(target, '?', tLen)); + size_t pLen = q ? static_cast(q - target) : tLen; + if (pLen >= sizeof(out.path)) pLen = sizeof(out.path) - 1; + memcpy(out.path, target, pLen); + out.path[pLen] = '\0'; + if (q) { + size_t qLen = tLen - pLen - 1; + if (qLen >= sizeof(out.query)) qLen = sizeof(out.query) - 1; + memcpy(out.query, q + 1, qLen); + out.query[qLen] = '\0'; + } + + // Header lines, case-insensitively, looking only for the two we act on. + const char *nl = static_cast(memchr(buf, '\n', blockLen)); + if (!nl) return -1; // request line not terminated + const char *p = nl + 1; + while (p < end) { + const char *ls = p; + const char *eol = ls; + while (eol < end && *eol != '\r' && *eol != '\n') eol++; + const size_t n = static_cast(eol - ls); + if (n >= 8 && ciEquals(ls, "upgrade:", 8)) { + if (containsCi(ls + 8, n - 8, "websocket")) out.upgradeWebsocket = true; + } else if (n >= 18 && ciEquals(ls, "sec-websocket-key:", 18)) { + const char *v = ls + 18; + size_t vn = n - 18; + while (vn && (*v == ' ' || *v == '\t')) { v++; vn--; } + while (vn && (v[vn - 1] == ' ' || v[vn - 1] == ' ' || v[vn - 1] == '\t')) vn--; + if (vn >= sizeof(out.wsKey)) return -1; + memcpy(out.wsKey, v, vn); + out.wsKey[vn] = '\0'; + } + p = eol; + while (p < end && (*p == '\r' || *p == '\n')) p++; + } + return 1; +} + +/** + * Pull `token=` out of a query string. Returns false when absent. Values are + * taken verbatim (the passphrase alphabet is [A-Za-z0-9._-] by convention; no + * percent-decoding happens here on purpose, so a token containing '%' simply + * cannot be supplied through the query string -- the WebSocket frame path takes + * it verbatim and is the one the page uses). + */ +inline bool tokenFromQuery(const char *query, size_t len, char *out, size_t cap) { + const char *k = "token="; + const size_t kl = strlen(k); + if (len < kl) return false; + for (size_t i = 0; i + kl <= len; i++) { + if (memcmp(query + i, k, kl) != 0) continue; + if (i != 0 && query[i - 1] != '&') continue; + const char *v = query + i + kl; + size_t vn = 0; + while (i + kl + vn < len && v[vn] != '&') vn++; + if (vn == 0 || vn >= cap) return false; + memcpy(out, v, vn); + out[vn] = '\0'; + return true; + } + return false; +} + +} // namespace ws +} // namespace kr diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..d63ee0f --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Host tests for keyrelay's pure logic modules. +# +# tests/run_tests.sh build and run every suite +# tests/run_tests.sh clean remove the build directory +# +# The modules under test (src/keystate.h, src/proto.h, src/text.h, src/ws_codec.h, +# src/sha1.h) have no Arduino or ESP-IDF dependency by design, so the exact code that +# runs on the board is compiled and exercised here before anything is flashed. +# +# A C++ compiler is not always installed on a workstation, so the runner picks one: +# $CXX if you set it, it wins +# g++ on PATH native, fastest +# a local container image podman + the first image that has g++ (see images +# below), with this repo bind-mounted read-write into +# /work -- no system packages installed for a test run +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(dirname "$here")" +out="$here/build" + +if [[ "${1:-}" == "clean" ]]; then + rm -rf "$out" + echo "cleaned $out" + exit 0 +fi + +# Candidate images, tried in order, that already exist locally. Add your own here. +images=( + "localhost/ai-sandbox:ubuntu-9a08b1d500b0" + "docker.io/library/gcc:14" +) + +suites=(test_keystate test_proto test_text test_ws_codec) + +build_and_run() { + local cxx="$1" + local prefix=("$cxx") + local flags=(-std=gnu++17 -O1 -g -Wall -Wextra -Wno-unused-function -Isrc -Itests) + + mkdir -p "$out" + if [[ -n "${SANITIZE:-}" ]]; then + flags+=(-fsanitize=address,undefined -fno-omit-frame-pointer) + echo "sanitizers: on" + fi + + local failed=0 suite + for suite in "${suites[@]}"; do + if ! "${prefix[@]}" "${flags[@]}" "tests/$suite.cpp" -o "$out/$suite" \ + 2>"$out/$suite.log"; then + echo "BUILD FAILED: $suite" + cat "$out/$suite.log" + failed=1 + continue + fi + [[ -s "$out/$suite.log" ]] && cat "$out/$suite.log" + "$out/$suite" || failed=1 + done + + # The Python side (the CLI client's WebSocket codec, and its key table against + # the one embedded in the controller page) needs only stdlib. + if command -v python3 >/dev/null 2>&1; then + python3 tests/test_relayctl.py || failed=1 + else + echo "python3 not found: skipped tests/test_relayctl.py" + fi + + return $failed +} + +# The controller page is a C++ raw string, so no compiler looks at its JavaScript and +# a typo there fails in somebody's browser instead of in a build. Node runs it -- on +# the host, deliberately, because the container that supplies g++ need not have node. +run_page_suite() { + if command -v node >/dev/null 2>&1; then + node "$here/test_page.js" || return 1 + else + echo "node not found: skipped tests/test_page.js" + fi + return 0 +} + +if [[ "${1:-}" == "--inside" ]]; then + cd "$root" || exit 2 + # The container has g++ but not necessarily node; the page suite skips itself. + build_and_run "${CXX:-g++}" + rc=$? + if [[ -z "${KR_QUIET_RESULT:-}" ]]; then + if ((rc)); then echo "RESULT: FAILED"; else echo "RESULT: all suites passed"; fi + fi + exit $rc +fi + +if [[ -n "${CXX:-}" ]]; then + echo "compiler: \$CXX = $CXX" + (cd "$root" && build_and_run "$CXX") + rc=$? + (cd "$root" && run_page_suite) || rc=1 + if ((rc)); then echo "RESULT: FAILED"; else echo "RESULT: all suites passed"; fi + exit $rc +fi + +if command -v g++ >/dev/null 2>&1; then + echo "compiler: $(command -v g++)" + (cd "$root" && build_and_run g++) + rc=$? + (cd "$root" && run_page_suite) || rc=1 + if ((rc)); then echo "RESULT: FAILED"; else echo "RESULT: all suites passed"; fi + exit $rc +fi + +if command -v podman >/dev/null 2>&1; then + for img in "${images[@]}"; do + if ! timeout 60 podman image exists "$img" 2>/dev/null; then + continue + fi + if ! timeout 60 podman run --rm --entrypoint sh "$img" -c 'command -v g++' \ + >/dev/null 2>&1; then + continue + fi + echo "compiler: g++ inside $img (repo mounted at /work)" + # :z relabels for SELinux; the build output lands in tests/build as usual. + # KR_QUIET_RESULT: the host prints the one summary, after its own page suite. + podman run --rm --entrypoint bash -v "$root:/work:z" \ + -e "SANITIZE=${SANITIZE:-}" -e KR_QUIET_RESULT=1 \ + "$img" /work/tests/run_tests.sh --inside + rc=$? + (cd "$root" && run_page_suite) || rc=1 + if ((rc)); then echo "RESULT: FAILED"; exit 1; fi + echo "RESULT: all suites passed" + exit 0 + done +fi + +cat >&2 < +#include + +static int g_checks = 0; +static int g_failures = 0; +static const char *g_section = "unset"; + +#define CASE(name) (g_section = (name)) + +#define CHECK(cond) \ + do { \ + g_checks++; \ + if (!(cond)) { \ + g_failures++; \ + std::printf(" FAIL %s:%d [%s] %s\n", __FILE__, __LINE__, \ + g_section, #cond); \ + } \ + } while (0) + +#define CHECK_EQ(actual, expected) \ + do { \ + g_checks++; \ + const long long _a = (long long)(actual), _e = (long long)(expected); \ + if (_a != _e) { \ + g_failures++; \ + std::printf(" FAIL %s:%d [%s] %s == %s : got %lld, want %lld\n", __FILE__, \ + __LINE__, g_section, #actual, #expected, _a, _e); \ + } \ + } while (0) + +#define CHECK_STR(actual, expected) \ + do { \ + g_checks++; \ + if (std::strcmp((actual), (expected)) != 0) { \ + g_failures++; \ + std::printf(" FAIL %s:%d [%s] %s == %s : got \"%s\", want \"%s\"\n", \ + __FILE__, __LINE__, g_section, #actual, #expected, (actual), \ + (expected)); \ + } \ + } while (0) + +static int report(const char *suite) { + std::printf("%-24s %3d checks, %d failures\n", suite, g_checks, g_failures); + return g_failures == 0 ? 0 : 1; +} diff --git a/tests/test_keystate.cpp b/tests/test_keystate.cpp new file mode 100644 index 0000000..93cf4b5 --- /dev/null +++ b/tests/test_keystate.cpp @@ -0,0 +1,202 @@ +// tests for the key-state tracker: tests/test_keystate.cpp +// +// The properties under test are the ones that decide whether the target machine +// ends up with a key stuck down: idempotence, transition preservation, rollover, +// and that clear() really clears. +#include + +#include "keystate.h" +#include "test.h" + +using kr::KeyboardReport; +using kr::KeyboardState; + +namespace { + +// A report as 8 hex-byte triples, for readable assertions. +void hexreport(const KeyboardReport &r, char out[32]) { + static const char kHex[] = "0123456789abcdef"; + const uint8_t bytes[8] = {r.modifiers, r.reserved, r.keys[0], r.keys[1], r.keys[2], + r.keys[3], r.keys[4], r.keys[5]}; + for (int i = 0; i < 8; i++) { + out[3 * i] = kHex[bytes[i] >> 4]; + out[3 * i + 1] = kHex[bytes[i] & 0xF]; + out[3 * i + 2] = i == 7 ? '\0' : ' '; + } + out[23] = '\0'; +} + +} // namespace + +int main() { + char h1[32], h2[32]; + + CASE("empty state is all zeroes"); + { + KeyboardState s; + CHECK(!s.dirty()); + hexreport(s.report(), h1); + CHECK_STR(h1, "00 00 00 00 00 00 00 00"); + } + + CASE("reserved byte is always zero"); + { + KeyboardState s; + const uint8_t keys[] = {kr::kUsageA}; + s.apply(kr::kModLctrl | kr::kModRgui, keys, 1); + CHECK_EQ(s.report().reserved, 0); + CHECK_EQ(s.report().modifiers, kr::kModLctrl | kr::kModRgui); + } + + CASE("apply is idempotent -- same state twice dirties nothing"); + { + KeyboardState s; + const uint8_t keys[] = {kr::kUsageA, kr::kUsageZ}; + s.apply(kr::kModLshift, keys, 2); + CHECK(s.dirty()); + s.ackDirty(); + s.apply(kr::kModLshift, keys, 2); + CHECK(!s.dirty()); + s.apply(kr::kModLshift, keys, 2); + CHECK(!s.dirty()); + } + + CASE("duplicates collapse, order is preserved"); + { + KeyboardState s; + const uint8_t keys[] = {kr::kUsageA, kr::kUsageA, kr::kUsageZ}; + s.apply(0, keys, 3); + CHECK_EQ(s.held(), 2u); + hexreport(s.report(), h1); + CHECK_STR(h1, "00 00 04 1d 00 00 00 00"); + CHECK(s.isDown(kr::kUsageA)); + CHECK(s.isDown(kr::kUsageZ)); + } + + CASE("usage 0x00 is not a key"); + { + KeyboardState s; + const uint8_t keys[] = {0x00, kr::kUsageEnter, 0x00}; + s.apply(0, keys, 3); + CHECK_EQ(s.held(), 1u); + hexreport(s.report(), h1); + CHECK_STR(h1, "00 00 28 00 00 00 00 00"); + } + + CASE("a distinct transition always produces a distinct report"); + { + // A fast tap: press then release. Both states have to be reportable, or the + // keypress vanishes and the target never sees the character. + KeyboardState s; + const uint8_t down[] = {kr::kUsageA}; + s.apply(0, down, 1); + CHECK(s.dirty()); + s.ackDirty(); + const uint8_t none = 0; + s.apply(0, &none, 0); + CHECK(s.dirty()); + hexreport(s.report(), h1); + CHECK_STR(h1, "00 00 00 00 00 00 00 00"); + } + + CASE("modifiers alone are a reportable change"); + { + KeyboardState s; + s.apply(kr::kModLalt, nullptr, 0); + CHECK(s.dirty()); + CHECK_EQ(s.modifiers(), kr::kModLalt); + s.apply(0, nullptr, 0); + CHECK(s.dirty()); + } + + CASE("six keys is exactly the limit"); + { + KeyboardState s; + const uint8_t keys[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09}; + s.apply(0, keys, 6); + CHECK(!s.rollover()); + CHECK_EQ(s.held(), 6u); + hexreport(s.report(), h1); + CHECK_STR(h1, "00 00 04 05 06 07 08 09"); + } + + CASE("a seventh key reports ErrorRollOver in every slot"); + { + KeyboardState s; + const uint8_t keys[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A}; + s.apply(0, keys, 7); + CHECK(s.rollover()); + hexreport(s.report(), h1); + CHECK_STR(h1, "00 00 01 01 01 01 01 01"); + // ... and releasing back under the limit reports the real keys again, so the + // host is never left with a phantom key held down. + const uint8_t six[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09}; + s.apply(0, six, 6); + CHECK(!s.rollover()); + hexreport(s.report(), h2); + CHECK_STR(h2, "00 00 04 05 06 07 08 09"); + } + + CASE("more keys than we track still cannot wedge the state"); + { + KeyboardState s; + uint8_t keys[40]; + for (size_t i = 0; i < sizeof(keys); i++) keys[i] = static_cast(0x04 + (i % 0x1A)); + s.apply(0xFF, keys, sizeof(keys)); + CHECK(s.rollover()); + CHECK(s.held() <= kr::kMaxTrackedKeys); + s.clear(); + CHECK_EQ(s.held(), 0u); + CHECK_EQ(s.modifiers(), 0); + } + + CASE("clear releases everything; a repeat clear adds nothing"); + { + KeyboardState s; + const uint8_t keys[] = {kr::kUsageA, kr::kUsageSpace}; + s.apply(kr::kModLctrl | kr::kModLshift, keys, 2); + s.ackDirty(); + s.clear(); + CHECK(s.dirty()); // the all-keys-up report still has to go out + hexreport(s.report(), h1); + CHECK_STR(h1, "00 00 00 00 00 00 00 00"); + s.ackDirty(); + s.clear(); + CHECK(!s.dirty()); // nothing changed, so no pointless second report + s.apply(kr::kModLctrl, nullptr, 0); + CHECK(s.dirty()); // ... and a real change after a clear is still seen + } + + CASE("makeReport builds the Ctrl+Alt+Del chord byte-exactly"); + { + const uint8_t del = kr::kUsageDelForward; + const KeyboardReport r = + kr::makeReport(kr::kModLctrl | kr::kModLalt, &del, 1); + hexreport(r, h1); + // 05 = LCtrl|LAlt, 4C = "Delete Forward". This is the exact sequence Windows + // accepts as the secure attention sequence and logind sees as CAD. + CHECK_STR(h1, "05 00 4c 00 00 00 00 00"); + } + + CASE("makeReport rolls over on more than six keys"); + { + const uint8_t keys[] = {0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A}; + const KeyboardReport r = kr::makeReport(0, keys, 7); + CHECK_EQ(r.keys[0], kr::kUsageErrorRollover); + CHECK_EQ(r.keys[5], kr::kUsageErrorRollover); + } + + CASE("reportsEqual distinguishes modifiers and slots"); + { + KeyboardReport a = {0, 0, {4, 5, 0, 0, 0, 0}}; + KeyboardReport b = a; + CHECK(kr::reportsEqual(a, b)); + b.modifiers = kr::kModLshift; + CHECK(!kr::reportsEqual(a, b)); + b = a; + b.keys[2] = 9; + CHECK(!kr::reportsEqual(a, b)); + } + + return report("keystate"); +} diff --git a/tests/test_page.js b/tests/test_page.js new file mode 100644 index 0000000..dc0d475 --- /dev/null +++ b/tests/test_page.js @@ -0,0 +1,278 @@ +// tests/test_page.js -- behaviour tests for the controller page's script. +// +// node tests/test_page.js +// +// The page is the primary interface, and it lives inside a C++ raw string where no +// compiler can see it: a typo there does not fail a build, it fails silently in +// somebody's browser while a target is live. So the script is extracted from +// src/web_page.h and run against a minimal DOM shim -- enough to exercise the rules +// that matter, which are what the log is allowed to contain, which characters may +// leave the tab, and when a run of pasted text stops. +// +// It is a behaviour test, not a rendering test: no CSS, no layout, no real keyboard. +// tests/run_tests.sh skips it when node is not installed. +"use strict"; +const fs = require("fs"); +const path = require("path"); +const vm = require("vm"); + +// --------------------------------------------------------------------------- +// A DOM small enough to read, complete enough to run the page. +// --------------------------------------------------------------------------- + +function makeEl(tagName) { + const el = { + tagName: (tagName || "div").toUpperCase(), dataset: {}, className: "", + textContent: "", innerHTML: "", value: "", checked: false, disabled: false, + readOnly: false, children: [], listeners: {}, + scrollTop: 0, scrollHeight: 0, + classList: { toggle() {}, add() {}, remove() {}, contains() { return false; } }, + get firstChild() { return this.children[0] || null; }, + get firstElementChild() { return this.children.find(c => c.tagName) || null; }, + get lastElementChild() { + const e = this.children.filter(c => c.tagName); + return e.length ? e[e.length - 1] : null; + }, + get childElementCount() { return this.children.filter(c => c.tagName).length; }, + appendChild(c) { this.children.push(c); return c; }, + removeChild(c) { this.children = this.children.filter(x => x !== c); return c; }, + addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }, + getAttribute() { return null; }, setAttribute() {}, focus() {}, + }; + return el; +} + +// The shim hands out an element for any id, which is far less work than a real DOM. +// The weakness in that is obvious, so it is compensated for: every id the script asks +// for is recorded and checked against the ids that are actually in the markup at the +// end. A mistyped id is a null dereference in a browser, and silence here. +const registry = new Map(); +const requestedIds = new Set(); +const byId = id => { + requestedIds.add(id); + if (!registry.has(id)) registry.set(id, makeEl("div")); + return registry.get(id); +}; + +const sentFrames = []; +const idleStatus = () => x.onFrame('S{"armed":1,"usb":1,"typing":0}'); +const busyStatus = n => x.onFrame(`S{"armed":1,"usb":1,"typing":${n}}`); + +class FakeWebSocket { + constructor() { + this.readyState = 1; // OPEN + FakeWebSocket.instances.push(this); + setTimeout(() => this.onopen && this.onopen(), 0); + } + send(text) { sentFrames.push(text); } + close() { this.readyState = 3; } +} +FakeWebSocket.OPEN = 1; +FakeWebSocket.instances = []; + +const sandbox = { + document: { + getElementById: byId, + createElement: t => makeEl(t), + querySelectorAll: () => [], + addEventListener() {}, + body: makeEl("body"), + hidden: false, + }, + window: { addEventListener() {} }, + location: { protocol: "http:", host: "keyrelay.local" }, + sessionStorage: { getItem: () => null, setItem() {} }, + WebSocket: FakeWebSocket, + setInterval: () => 0, // the reconnect/keepalive loop stays shut in a test + setTimeout, clearTimeout, Date, JSON, Math, Promise, TextEncoder, console, + Number, String, Object, Array, Buffer, parseInt, +}; + +// The page keeps its state in script-scope `let`s, so the harness reaches into that +// scope once, here, instead of duplicating the page's logic in the test. +const expose = ` +;globalThis.__x = { + log, logEl, untypeableChars, hexify, describePress, inPageField, pasteText, + sendPaste, refreshSendBox, setArmed, abortTyping, onFrame, connect, + TEXT_FRAME_MAX, TEXT_JOB_MAX, TEXT_PACE_MS, + get ws() { return ws; }, + get armed() { return armed; }, set armed(v) { armed = v; }, + get authed() { return authed; }, set authed(v) { authed = v; }, + get typing() { return typing; }, +};`; + +const headerPath = path.join(__dirname, "..", "src", "web_page.h"); +const headerText = fs.readFileSync(headerPath, "utf8"); +const from = headerText.indexOf("