An ESP32-S3 firmware (ESP-IDF v5.3) that wakes a machine on the LAN with a HMAC-signed Wake-on-LAN request, reachable two ways:
- LAN HTTP on port
48320—/v1/health,/v1/observer,POST /v1/wake - Tailnet UDP on port
48320via a MicroLink tunnel (a Tailscale-compatible WireGuard peer implementation, vendored incomponents/microlink), with the same signed transcript carried as a line-oriented datagram
Extras: live observer telemetry (target probe, RSSI, heap/PSRAM, tunnel state, boot diagnostics persisted in NVS), a 32-entry nonce replay cache, and a self-healing link supervisor (gateway probe → WiFi reconnect; tunnel down → rebind ×3 → reboot).
Compatibility note: the wire-visible protocol names (auth scheme, header names, schema strings, discovery probe, authority identity) are compile-time constants with neutral defaults, documented in
docs/PROTOCOL.md. They can be overridden per build for an authority that runs pre-rename firmware — see "Wire name configuration" indocs/PROTOCOL.md.
- ESP32-S3 dev board (e.g. CH343-USB bridge class), mains powered
- 16 MB flash (partition table in
partitions.csvassumes it: two 3 MB OTA slots + SPIFFS + coredump) - Octal PSRAM (8 MB) — the board's PSRAM is OCTAL; quad mode fails PSRAM init
and boot-loops.
sdkconfig.defaultsalready setsCONFIG_SPIRAM_MODE_OCT=y. Do not "fix" it to quad.
idf.py set-target esp32s3
idf.py build
idf.py -p <port> flash monitorDeliberate single-pass flashing: this project uses
CONFIG_ESPTOOLPY_FLASHSIZE_16MBwith a custom partition table whose offsets must match the bootloader written on the first flash. If you changepartitions.csvor flash geometry, erase the whole flash first (idf.py erase-flash) and re-flash everything in one pass — OTA data and NVS offsets are otherwise stale.
Two git-ignored headers hold everything device-specific:
| File | Contents |
|---|---|
main/secrets.h |
HMAC_SECRET_HEX (256-bit shared key), Tailscale auth key, tailnet device name |
main/target_config.h |
WiFi SSIDs + password, static LAN IPs/mask/gateway, WOL broadcast address, target machine MAC, priority tailnet peer IP |
Copy the .example files and fill in real values:
cp main/secrets.h.example main/secrets.h
cp main/target_config.h.example main/target_config.hBoth are in .gitignore. The HMAC secret must match the secret your client
signs with (openssl rand -hex 32 to generate one).
python3 tools/wake_client.py --ip <board-tailnet-ip> # wake over the tunnel
python3 tools/wake_client.py --ip <board-lan-ip> --mode http # wake over LAN HTTP
python3 tools/wake_client.py --ip <board-ip> --observer # telemetry JSONThe secret is supplied via --secret-hex-file, the WAKE_SECRET_HEX env var,
or an interactive prompt — never embedded in the script (see tools/README.md).
- Every wake request carries a transcript —
method \n target \n timestamp \n nonce \n body-SHA256— signed with HMAC-SHA256 under the shared secret; the header form isAuthorization: <scheme> <hex>(scheme name configurable, seedocs/PROTOCOL.md). Verification uses a constant-time compare. - Clock skew is accepted only within ±60 s of the board's SNTP-synced clock, and the board refuses to serve before its clock is plausibly synced, so captured requests expire quickly.
- A 32-entry nonce replay cache rejects reuse of a nonce within the skew window (cache rotates FIFO).
- Request bodies are size-capped (4096 bytes) and digest-checked before the signature is evaluated.
- Why the config headers are git-ignored:
secrets.hholds the live HMAC key (anyone holding it can wake the target) and a live Tailscale auth key (anyone holding it can join your tailnet).target_config.hholds your LAN topology and the target's MAC. None of it belongs in version control.
docs/PROTOCOL.md is the byte-exact contract, written against the neutral
default names. The names are configurable at compile time (see "Wire name
configuration" there), so the firmware can match an authority running
pre-rename firmware without touching any client.