Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b86fd9c70 | ||
|
|
f15491f233 | ||
|
|
71f588360a | ||
|
|
e6db5428eb | ||
|
|
aeb2043396 | ||
|
|
e5dce12ed4 | ||
|
|
117c694cd4 | ||
|
|
f9ee6eec9c | ||
|
|
5a609fa40b | ||
|
|
4435a7fddd | ||
|
|
00f226dc59 | ||
|
|
a62a655ac1 | ||
|
|
93eef0e676 | ||
|
|
27c54c0a92 | ||
|
|
d4991658b1 | ||
|
|
d8999cd4a9 | ||
|
|
02fdeee345 | ||
|
|
af89dd1bd9 |
@@ -14,7 +14,7 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
|
||||
|
||||
## Development status
|
||||
|
||||
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D plans integrated browser login/logout, an admin-shell terminal mode, typed settings, and contextual quick administration while preserving any browser-held serial writer lease across terminal-mode changes. Configurable STA-only mDNS naming as `sak-<suffix>.local` is implemented with independent NVS persistence; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
|
||||
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D.3 browser login/logout is implemented, host-tested and build-verified; [M1 is validated by user sign-off](docs/phase8d3_implementation.md) after both-role login, mixed-client operation and post-soak evidence. Numeric memory reserve gates remain open. Browser admin-shell mode, typed settings, and contextual administration remain planned, including preserving serial writer ownership across future terminal-mode changes. Configurable STA-only mDNS naming as `sak-<suffix>.local` is implemented with independent NVS persistence; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -77,7 +77,7 @@ Serial, Wi-Fi, and mDNS hostname edits remain in RAM until explicitly saved with
|
||||
|
||||
## Security notes
|
||||
|
||||
The HTTPS interface uses a device-specific self-signed certificate and role-aware HTTP Basic authentication over TLS; there is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the legacy recovery password, HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete.
|
||||
The HTTPS interface uses a device-specific self-signed certificate and a same-origin login page with bounded server-side cookie sessions; HTTP Basic is no longer accepted. Open `/` or `/login`, sign in with a user-database password, and use **Sign out** before switching accounts. Four sessions have a one-hour absolute lifetime, including active serial connections; logout closes only that session's serial access. Login is globally limited to five credential verifications per 60 seconds, with explicit capacity/backoff errors. Direct-IP and mDNS access use separate host-only Secure/HttpOnly/SameSite=Strict cookies. Non-browser clients also require cookies, strict Origin and CSRF for mutations rather than Basic credentials. There is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the legacy recovery password, HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -98,15 +98,29 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li
|
||||
|
||||
`web_server` runs HTTPS only on port 443 using the device-specific self-signed P-256 certificate from `web_security`. Current routes provide the UI, static assets, status, ticket issuance, and serial WebSocket upgrade.
|
||||
|
||||
HTTP Basic authentication uses `user_database`. Before administrator bootstrap, the migrated role-`user` account is synchronized from the legacy credential, so that username/password can authenticate through the database; after bootstrap, the legacy blob is independent recovery material and is no longer consulted for authentication or synchronized into role-based accounts. Both `user` and `admin` roles currently receive the same web status/terminal experience; web administration is not implemented.
|
||||
HTTPS login uses `user_database` and opaque server-side cookie sessions; Basic authentication and its cache are removed in 8D.3. Before administrator bootstrap, the migrated role-`user` account is synchronized from the legacy credential, so that username/password can authenticate through the database; after bootstrap, the legacy blob is independent recovery material and is no longer consulted for authentication or synchronized into role-based accounts. Both roles retain the same shipped web status/serial UI. 8D.5 adds an admin-only backend without a normal UI entry.
|
||||
|
||||
The boot-local Basic-authentication cache has four RAM entries and a five-minute sliding lifetime. It stores a keyed digest of the complete `Authorization` header rather than the raw header, and every hit revalidates principal currentness. Its current lack of locking relies on the single-HTTPD-owner execution model.
|
||||
`web_cookie_auth` owns login/session/logout policy: four 120-second digest-only pre-login challenges, explicit same-origin bootstrap, five credential verifications per 60-second global window, and no live-record eviction. Host-only `__Host-` Secure/HttpOnly/SameSite=Strict cookies have absolute lifetimes. Login consumes a challenge, validates bounded JSON and issues a fresh session; logout invalidates only its originating session. Mutations require CSRF and strict canonical HTTPS Origin; serial/admin upgrades require matching cookie/Origin/ticket, with admin role additionally required by the admin endpoints.
|
||||
|
||||
A WebSocket connection requires a one-time, principal-bound ticket with a maximum 30-second lifetime. Only four tickets can be outstanding; minting another evicts the live entry with the earliest expiry. Ticket issuance and upgrade also validate a supplied `Origin` against `https://<Host>`; absence of `Origin` is accepted for non-browser clients. Tickets are stored as digests, consumed before currentness validation, and are never persisted. An admitted session starts the serial service if necessary, creates a broker client, and opportunistically requests writer ownership. The web transport has two fixed session slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the web transport task mediates broker work through bounded scheduling. The browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect; after a user-paused disconnect it changes to Connect, which resumes connection attempts.
|
||||
`web_session_store` holds four static records with token/origin digests, copied principal, separate CSRF state, one-hour absolute expiry and non-reused 64-bit session IDs. These are live cookie sessions in 8D.3, with no sliding renewal. A portMUX protects short state copies/mutations; database/RNG/SHA calls occur outside it. Resolution rechecks ID/expiry after database validation; issuance also checks an invalidation epoch. Stop wipes records without resetting IDs/epochs. Only admitted HTTPS starts initialize the store; failed starts and accepted stops disable it before cleanup. Authentication/store-init failure now gates HTTPS startup rather than falling back to Basic. Sensitive views must be wiped by callers; snapshots contain only counts and storage sizes. Focused host checks live in `tests/web_session_store/`.
|
||||
|
||||
Serial tickets/slots bind to distinct originating web-session IDs; 8D.3 rejects zero instead of treating it as Basic. Trusted internal mint/upgrade callers supply the ID; bound checks also compare the session's copied principal, with no CSRF export. Mint/consume/admission/input and existing 250 ms owner checks validate session liveness/currentness. Session-specific transport revocation invalidates the store first, then clears matching tickets and flags matching reserved/active slots for existing HTTPD/broker cleanup. Account/global transport revocation now invalidates cookie records even if serial initialization failed; existing console mutation callers reach these hooks unchanged. A non-wrapping transport epoch cancels in-flight ticket publication across revocation and server detach/re-attach. Store/database checks remain authoritative if notification is missed. 8D.3 activates these checks for all browser routes, with five added authentication handlers (14 total), unchanged six HTTPS sockets and no new task.
|
||||
|
||||
A WebSocket connection requires a one-time, principal-bound ticket with a maximum 30-second lifetime. Only four tickets can be outstanding; expired/stale identities are reclaimed and live capacity is rejected with 503/Retry-After, not eviction. Ticket issuance and upgrade require `Origin` matching validated Host after host-case/default-port normalization; missing Origin fails even for non-browser clients. Tickets are stored as digests, consumed before currentness validation, and are never persisted. An admitted session starts the serial service if necessary, creates a broker client, and opportunistically requests writer ownership. The web transport has two fixed session slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the web transport task mediates broker work through bounded scheduling. The browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect; after a user-paused disconnect it changes to Connect, which resumes connection attempts.
|
||||
|
||||
`web_httpd_adapter` is the sole private ESP-IDF 5.5.0 boundary. Its compile-time version guard requires review on upgrades. It validates NUL-separated parsed headers because public getters return only the first field, and rejects duplicates/ambiguous framing. The serial URI is registered as ordinary HTTP GET so cookie/ticket/principal/broker admission precedes explicit 101 and frame-handler installation; automatic IDF WebSocket routing would send 101 too early. Cleanup wipes consumed scratch but preserves right-aligned unread pending data. CMake compiles HTTPD logs above ERROR out to prevent header/ticket logging. No SDK patch or component copy exists. See `docs/phase8d3_implementation.md` for source verification, tests and pending on-wire checks.
|
||||
|
||||
Web serial initialization is failure-isolated from the base HTTPS service: if the transport cannot initialize, `web_server_init()` can still succeed and serve authenticated non-WebSocket routes.
|
||||
|
||||
`web_ui.c` contains authored index/application strings and response policy. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets.
|
||||
`web_ui.c` contains authored index/application strings and response policy; it validates `/api/session` before connect/restore, adds explicit Sign out, and cancels stale work on 401/logout/page exit. `web_login_ui` is a standalone public page without protected-asset dependencies. Both authentication documents and app script are no-store. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets.
|
||||
|
||||
### Browser admin backend
|
||||
|
||||
The 8D.6 document binds retained terminal state to its first validated username/role/session-stable CSRF tuple. Every later session adoption must match, otherwise both terminal hosts are hidden, both sockets/work are closed/fenced and a clean `/` document is required. Pagehide hides scrollback until same-session revalidation; no clearing is needed for unchanged-session restore or mode changes. Terminal-fit readiness uses successful-bounds caching and at most three generation-fenced animation-frame retries per external request, never unbounded polling.
|
||||
|
||||
8D.6 `web_ui.c` now supplies the admin-only Serial/Admin selector using this backend. Selection leaves serial and any open admin socket connected and draining; only focus, displayed terminal and keyboard destination change. Broker identity/lease and writer controls remain serial-owned in both views. Admin opens/reopens explicitly and closes independently. Two page-lifetime terminals have separate 5,000-line scrollbacks and 64 KiB callback-accounted pending output each; browser overflow is dropped with visible counts. Admin input is bounded to 4 KiB admission and 512-byte frames. Logout/expiry/page exit closes both with generation fencing and socket-listener cleanup; bfcache revalidates serial/session but never automatically reopens admin. No server policy/capacity changes or 8D.7 lifecycle parity. See `docs/phase8d6_implementation.md`; the following paragraph describes the original backend boundary, before its UI entry was added.
|
||||
|
||||
8D.5 additionally supplies `web_admin_transport` and `web_admin_tickets`: one optional admin socket, two 30-second digest-only tickets bound to current originating session/principal, the same two shared console slots, no serial broker client. Ticket POST requires cookie/Origin/CSRF/admin; ordinary GET upgrade requires cookie/Origin/admin/ticket and console admission before 101. Six total HTTPS sockets remain, LRU purge is disabled, and two routes bring the handler budget to 16. Optional admin registration/PSRAM allocation failures do not take down M1. A 20 ms ESP timer queues at most one HTTPD poll, with no new task; only HTTPD accesses the 1,552-byte PSRAM-only RX/TX payload or socket IO. Notifiers close the generation-qualified console and flag the socket. HTTPD shuts down the verified current fd directly and owns subsequent read cleanup, avoiding IDF's queued reusable `sock_db *` close race. Detach fences submissions; failed stop retains ownership, and queued state is retired only after successful HTTPD stop. Console dispatcher/prompt and owner input/output/idle checks enforce session and principal currentness. WEB supports deferred self-close only; parsed canonical policy denies unsupported lifecycle/network/account mutations before handler side effects. No normal UI entry, typed settings or lifecycle parity is included. See `docs/phase8d5_implementation.md` for validation limits and exact restrictions.
|
||||
|
||||
### SSH
|
||||
|
||||
@@ -151,6 +165,8 @@ admin SSH line editor ----/ |
|
||||
|
||||
The dispatcher is the sole caller of `esp_console_run()`, serializing UART0 and all admin SSH commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes.
|
||||
|
||||
The 8D.4/8D.5 boundary retains `admin_ssh_console_open_owned()` and adds available-slot admission for runtime SSH/browser owners: copied transport-qualified slot/session/generation identity plus a firmware-lifetime immutable owner adapter. The existing two console slots are shared, not multiplied per frontend; active/executing slots cannot be replaced. Owners serialize per-session input, consume output and enforce transport liveness; completion scratch is claimed nonblockingly across owners. The existing control task calls drain/lifecycle adapters outside console locks. SSH uses generation-checked published snapshots, principal copies and its assigned console index, never wolfSSH from the control task. `SELF_CLOSE` is owner-relative; legacy SSH actions remain SSH-specific and unsupported owner actions are rejected. Dispatcher-side owner `is_current` checks run outside console locks, with full identity recheck after validation. Commands revalidate immediately before the runner; prompts revalidate before publication and after waits (250 ms polling plus check/scheduling latency), rejecting revoked submitted input and stale wakes. SSH preserves close intent through external-close consumption. Consumed output is wiped. These checks do not cancel arbitrary executing handlers or replace owner-side input/output and lifecycle validation.
|
||||
|
||||
For SSH, standard output/error is redirected to the invoking session's bounded output ring. `console_input` routes visible or hidden prompts to UART0 or the active SSH session. `exit` and Ctrl+D on an empty admin SSH line use bounded deferred self-disconnect after their acknowledgement drains; role-`user` SSH remains a binary-transparent serial stream. Session tokens include slot and generation so late queued work cannot attach to a reused SSH slot. Only the SSH owner task moves ring output through wolfSSH.
|
||||
|
||||
Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
|
||||
|
||||
+15
-3
@@ -61,15 +61,26 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
**Responsibility:** serve authenticated HTTPS UI/API, issue WebSocket tickets, and adapt browser serial sessions to broker clients.
|
||||
|
||||
- Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}`
|
||||
- Security files: `src/web_security.{h,c}`
|
||||
- Security files: `src/web_security.{h,c}`, `src/web_cookie_auth.{h,c}`, `src/web_session_store.{h,c}`, `src/web_auth_parse.{h,c}`. Private IDF boundary: `src/web_httpd_adapter.{h,c}`.
|
||||
- Asset files: authored/generated boundary in `src/web_assets_data.{h,c}`, `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py`
|
||||
- Interfaces: web init/start/stop/snapshots; HTTP handlers; ticket mint/consume; attach/detach; targeted session revocation
|
||||
- Called by: startup, ESP-IDF HTTPS server, user administration revocation, console/local UI
|
||||
- Dependencies: user database, secure random, broker, successful Wi-Fi manager initialization at boot, mbedTLS/HTTPS server; actual network reachability is an operational prerequisite, not an initializer invariant
|
||||
- Flow: `browser -> HTTPS Basic auth -> ticket -> WebSocket -> web transport -> broker`
|
||||
- Flow: `browser -> HTTPS login/cookie session -> CSRF-protected ticket -> cookie/Origin/ticket admission -> WebSocket -> web transport -> broker`
|
||||
- Ownership: HTTPD owns socket send/close work; transport task owns broker mediation; two fixed WebSocket slots and four outstanding tickets.
|
||||
- Security constraints: Basic-auth cache hits still revalidate principal currentness; the browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect until Connect is selected. Changes to the authored inline loader must update its hard-coded CSP hash in the same change.
|
||||
- Security constraints: Basic/cache removed; four absolute one-hour cookie sessions revalidate principal currentness. Four pre-login challenges (120 s), five credential attempts/60 s globally, no live session/challenge/ticket eviction. Origin/CSRF required for mutations; Origin/cookie/ticket before upgrade. Disconnect pauses reconnect but retains login; Sign out invalidates its session. Authored loader changes must update their hard-coded CSP hashes atomically.
|
||||
- Session-store boundary: admitted HTTPS start initializes records; auth-init failure gates HTTPS. Failed start/accepted stop disables and wipes state. Tickets/slots require nonzero non-reused session IDs; session/account/global revocation invalidates store records before socket cleanup. RNG/SHA/database calls run outside short portMUX sections; ID/expiry/epoch checks reject stale work. Run `python3 tests/web_session_store/run.py` and its `--serial` integration mode.
|
||||
- 8D.3 HTTP policy: `web_cookie_auth` owns public login/challenge/login POST/session/logout routes and protected-route checks; `web_auth_parse` handles bounded values/JSON. `web_httpd_adapter` alone reads private IDF 5.5.0 header scratch, rejects duplicate fields, defers 101 until transport admission and wipes consumed scratch while preserving right-aligned pending bytes. No SDK patch. `src/CMakeLists.txt` supplies private includes and compiles HTTPD warning/debug logs out. Test with `python3 tests/web_cookie_auth/run.py` and `python3 tests/web_auth_parse/run.py`.
|
||||
- 8D.3 UI: `src/web_login_ui.{c,h}` serves standalone `/login`; `web_ui.c` validates session before serial connect/restore and handles logout/401 safely. Both scripts hash-bound, auth documents/app no-store. Tests: `python3 tests/web_login_ui/run.py` and `python3 tests/web_ui_session/run.py`. Live cutover host-tested/build-verified, M1 validated by user sign-off (numeric reserves open): `docs/phase8d3_implementation.md`.
|
||||
- Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
|
||||
- 8D.6 UI: `web_ui.c` adds admin-only Serial/Admin selection and explicit admin open/close through existing endpoints. Serial socket/client/lease survives mode switches; hidden output drains into independent 5,000-line/64 KiB-pending terminals with visible browser-drop counts. Selected keyboard only; logout/expiry/pagehide closes both with handler cleanup. Session identity changes require a clean document before adopting the view; same-session restore retains hidden-until-validated buffers. Fit readiness retries are bounded to three and cache only success. Focused `tests/web_ui_session/run.py` has 17 groups plus toolbar-order/CSP checks. 8D.6 is user-validated; telemetry, evidence limits and 8D.7 handoff are in `docs/phase8d6_implementation.md`. Numeric reserves remain open; no 8D.7 restriction change.
|
||||
|
||||
### Browser admin backend (8D.5)
|
||||
|
||||
- Files: `src/web_admin_transport.{c,h}`, `src/web_admin_tickets.{c,h}`, protected registration/lifecycle in `web_server.c`, revocation through `web_serial_transport_revoke_*`, diagnostics in `web_console.c`.
|
||||
- Routes: CSRF-protected admin-only `POST /api/admin/ws-ticket`; ordinary `GET /ws/admin` with cookie/Origin/ticket/shared-console admission before explicit 101. No UI entry or broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled, 16 URI handlers.
|
||||
- Ownership: 20 ms ESP timer queues at most one HTTPD poll, no new task; HTTPD owns 1,552 B PSRAM-only payload and IO. Closure uses HTTPD-owned `shutdown`, not IDF's reusable-pointer queued close. Detach fences submitters; only successful HTTPD stop retires queued state before restart. Session/principal currentness and generation checks protect all sensitive boundaries.
|
||||
- Tests: `python3 tests/web_admin_transport/run.py --tickets`, `python3 tests/web_admin_transport/server_lifecycle.py`, `python3 tests/web_cookie_auth/run.py --admin`; manual smoke client/procedure in `tests/web_admin_transport/README.md` and `docs/phase8d5_implementation.md`. Final shutdown fix is host-tested and build-verified by the parent's sequential final `pio run`; target validation remains pending.
|
||||
|
||||
## SSH
|
||||
|
||||
@@ -107,6 +118,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
- Ownership: dispatcher is sole `esp_console_run()` caller; the SSH owner exclusively performs post-initialization wolfSSH runtime calls.
|
||||
- Lifecycle: remote session tokens include slot generation; fixed output/history/prompt state is wiped immediately on idle close or after an executing handler returns. Admin SSH `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort output draining.
|
||||
- Constraint: one slow command or prompt serializes all administration. Admin SSH is unavailable until command registration and UART frontend creation complete; supported deferred actions wait only for a bounded application-buffer drain heuristic.
|
||||
- 8D.4/8D.5 boundary: `admin_ssh_console_open_owned()` retains explicit-index admission; runtime SSH and browser owners use `admin_ssh_console_open_available()` for the same two slots. Copied transport-qualified identity and immutable firmware-lifetime currentness/drain/lifecycle adapters; SSH publishes its allocated console index separately from its physical SSH slot. Owners handle liveness/output; dispatcher and prompt waits additionally require owner currentness (250 ms polling plus check/scheduling latency). SSH publishes locked principal copies; consumed console output is wiped. Completion scratch is nonblockingly serialized. Browser unsupported lifecycle/account mutations are rejected before execution. Focused host command: `python3 tests/admin_console_boundary/run.py`.
|
||||
|
||||
## Wi-Fi
|
||||
|
||||
|
||||
+102
-6
@@ -4,12 +4,61 @@ This file is working memory. Update it during active work and before handoff; do
|
||||
|
||||
## Development state
|
||||
|
||||
- **8D.6 validated by explicit user sign-off (2026-09-06):** After 60-second boot/full-mix/partial-cleanup telemetry, user confirms all discussed remaining checks and requests wrap-up. Closes 8D.6 including Open admin and toolbar-order fixes, superseding older target-pending notes below. Evidence in `docs/phase8d6_implementation.md`: full mix at **230400 baud**, browser admin + admin SSH, two web serial/SSH/USB broker clients; web24 sole writer. Loaded internal/DMA/PSRAM free **33,900 / 26,144 / 8,087,468 B**, minima **6,516 / 1,580 / 8,063,428 B**, largest **24,576 / 24,576 / 7,995,392 B**; SSH stack minimum-free **16,280 B**. Partial cleanup retains serial, internal/DMA largest returns to **31,744 B**. One SSH handshake failure and rejected input byte remain unattributed. Exact revision/browser/counts/soak duration not separately supplied; no invented detailed execution or reserve approval. Numeric reserves and low lifetime minima remain follow-ups, not blockers reopening signed-off 8D.6. Last toolbar build/tests: **7.60 s, 95,580 B RAM / 1,646,489 B flash**, 17 UI groups/CSP/diff pass. This handoff changes documentation only. **Wait for separately requested 8D.7 — bounded web-shell lifecycle parity and M2 acceptance.** Preserve current restrictions until their safe owner paths are implemented; no settings/M2 completion or new implementation is implied.
|
||||
|
||||
- **8D.6 Open admin bug fix (2026-09-06), target retest pending:** Confirmed frontend/backend ticket mismatch: `openAdmin()` required 32 URL-safe characters (serial format), while admin issuance returns 64 lowercase hex characters. Valid POST responses therefore threw before WebSocket construction with the user's exact generic message. Fixed only the admin validator; split the previously shared serial/admin test fixture, assert exact admin query URL and reject malformed/serial-format tickets without closing serial. Correct-format fixture reproduced pre-fix failure; final production-renderer/CSP + 17 UI groups, cookie/admin endpoint integration, transport 19/tickets 12 and lifecycle 11 pass. These are separate frontend and backend harnesses, not real-browser/on-wire integration. CSP loader hash matches the reported `o6St...` policy; rejected UUID/index.js scripts' origin is unverified, not grounds to relax CSP. Final bounded `pio run` passed in **7.96 s**, **95,580 B RAM / 1,646,489 B flash**, unchanged sizes. Diff check passes. No assets, upload, commit, backend/admission changes or target pass. Short bug record in `docs/phase8d6_implementation.md`; parent independent review and user target retest pending.
|
||||
|
||||
- **8D.6 final re-review complete (2026-09-06):** Independent reviewer confirmed session-identity isolation and bounded fit-readiness fixes and their regressions, with no remaining actionable findings in the reviewed changes. Final build/test results below stand; no further source change or build for this documentation update. Browser/target validation and numeric reserves remain pending, not phase sign-off or M2 completion. Stop before 8D.7 until separately requested after the validation decision.
|
||||
|
||||
- **8D.6 independent review fixes implemented / host-tested / build-verified (2026-09-06), target pending:** Fixed both P2 findings without unrelated scope. Before session adoption, compare retained username/role/session-stable CSRF; mismatch hides old terminals, closes both routes/fences work and replaces the document at `/`, including live admin and logout paths. Pagehide keeps buffers hidden until same-session validation; unchanged identities retain scrollback. Fit caches bounds only after valid measurement/success, with three bounded readiness retries, teardown cancellation and generation fencing. Expanded UI harness passes **16 groups**; login eight groups, cookie/admin integration, parser 268 and store/serial reruns pass. Final finite `pio run` after all fixes: **21.35 s, 95,580 B RAM / 1,646,489 B flash**; review delta **0 / +1,376 B**, versus 8D.5 **0 / +9,216 B**, versus 8D.0 **+1,048 / +46,516 B**. `git diff --check` passes. See `docs/phase8d6_implementation.md` for exact regression cases and unchanged pending target checklist. Parent reviews fixes only; no target pass, reserve approval, sign-off or M2 completion. Existing worktree edits preserved; no server/protocol/assets/8D.7/upload/commit action. Supersedes initial 8D.6 counts/build below.
|
||||
|
||||
- **8D.6 implemented / host-tested / build-verified (2026-09-06), target pending:** Admin-only Serial/Admin selector in authored `web_ui.c`, explicit Open/Close admin using existing protected endpoints, lazy separate 5,000-line terminals with 64 KiB pending writes each and visible browser-drop counts. Switching preserves serial socket/client/lease and drains hidden output; selected input only, writer controls visible in both. Admin closure is isolated; logout/expiry/pagehide cancel both with listener cleanup and stale-generation fencing. Existing 8D.5 restrictions/server/capacities unchanged; no assets/upload/commit. 13 UI groups, eight login groups, cookie/admin integration, parser 268, store/serial, admin transport 19/tickets 12, server lifecycle 11 and console boundary suites pass. Final finite `pio run`: **22.22 s, 95,580 B RAM / 1,645,113 B flash**, **0 / +7,840 B** versus 8D.5, **+1,048 / +45,140 B** versus 8D.0. Diff check passes. See `docs/phase8d6_implementation.md` for exact contracts, test limits/resource accounting and pending desktop/mobile/prompt/full-mix/soak checklist. Parent independent review pending; no target execution, numeric reserve approval, 8D.6 sign-off or M2 completion. Prior 8D.5 validation stands. Stop before separately requested 8D.7. This supersedes older planned-selector notes below.
|
||||
|
||||
- **8D.5 validated by explicit user sign-off (2026-09-06):** User supplied settled cold-boot telemetry and reports successful **15-minute full-client-mix active-use soak at 230400 baud**, with a few broker drops under extremely fast/dmesg output, then explicitly closes 8D.5. Supersedes older pending/incomplete notes below. Boot internal/DMA/PSRAM free **70,876 / 63,120 / 8,246,360 B**, minima **59,560 / 51,804 / 8,242,140 B**, largest **31,744 / 31,744 / 8,126,464 B**; SSH stack minimum-free **18,472 B**. Admin initialized/attached with ESP_OK and 167/240/1,552 B static/ticket/payload accounting; no active clients at boot. Full mix/soak is user-reported, not evidenced by the idle snapshot; exact drop count/client, flashed revision and loaded/post-soak/cleanup telemetry not supplied. See `docs/phase8d5_implementation.md`. These limits do not reopen sign-off or imply zero-drop operation. Numeric reserves/runtime socket cost remain open. No new build/device/source action for sign-off. Wait for separate **8D.6** request; M2 not yet complete.
|
||||
|
||||
- **8D.5 backend implemented / host-tested / build-verified (2026-09-06), target pending:** Preserved interrupted tickets/transport/server/shared-console/SSH/policy/revocation work and inherited test tooling. One admin socket, two tickets, existing two shared console slots, 1,552 B PSRAM-only payload, 20 ms ESP timer/no new task, six HTTPS sockets/no LRU eviction, 16 URI handlers. Added real cookie/store/ticket/transport endpoint integration tests. Final IDF lifecycle review found queued `httpd_sess_trigger_close` retains a reusable socket-slot pointer; changed the admin path to HTTPD-owned `shutdown`, with HTTPD read cleanup and retry/reuse regressions. Transport 19 groups + tickets 12, server lifecycle 11, combined endpoints, both console suites, store/serial, cookie/parser (268), login UI (8), serial UI (9) all pass; diff check passes. **Parent reports sequential final `pio run` after the shutdown fix passed in 23.55 s at 95,580 B RAM / 1,637,273 B flash: +416/+9,224 versus prerequisite, +496/+10,100 versus 8D.4, +1,048/+37,300 versus 8D.0.** This supersedes the continuation's earlier pre-fix build; history remains in the implementation record. Parent's final independent security integration reviewer reported no actionable findings. Target ELF payload/slot/ticket sizes 1,552/80/96 B, ticket state+lock 240 B, transport static symbols 167 B before padding, ESP timer internal allocation 32 B before heap overhead. See `docs/phase8d5_implementation.md` for exact commands, inherited-versus-final fixes, resource accounting, restrictions and manual target/client checklist. Client `--help` only exercised in the continuation; this final evidence update changed documentation only, with no source/tests/build/device/network/asset/UI/upload/commit action. **Stop before 8D.6; target acceptance, runtime socket cost, numeric reserves and M2 remain open.** This supersedes the prerequisite-only pause below.
|
||||
|
||||
- **8D.5 prerequisite resumed / host-tested / build-verified (2026-09-06), target pending:** User requested continuation. Preserved existing uncommitted dispatcher/owner-currentness and prompt-cleanup changes in the console/SSH adapter; extended tests to production SSH snapshot/principal publication and wiping. Both console suites pass; independent production review found no actionable defects. `pio run` passes in **38.46 s**, **95,164 B RAM / 1,628,049 B flash** (**+80 / +876 B** versus recorded 8D.4); map confirms 80 B for two principal copies. No new tasks/routes/sockets/UI or backend yet. See `docs/phase8d5_implementation.md` for contract, scope split, test limits and target checklist. **8D.5/M2 incomplete.** Obtain target regression or explicit user decision before stacking live backend work. Exact next increment is still 8D.5 tickets/HTTPD-owned admin transport/protected admission/revocation/restrictions and integration checks, not 8D.6. Previous 8D.4/M1 sign-offs stand; numeric reserves remain open. This supersedes older wait-for-8D.5-request notes below.
|
||||
|
||||
- **8D.4 validated by explicit user sign-off (2026-09-06):** User confirms successful empty Enter and soak testing and explicitly closes Phase 8D.4. This supersedes older pending/in-progress notes below, including the 8D.3 empty-Enter target retest. Boot/full-client-mix evidence and sign-off are in `docs/phase8d4_implementation.md`. Exact soak duration/revision, post-soak/cleanup telemetry and other detailed checklist results were not supplied; these are evidence limitations, not blockers to the user-approved closure or claims of execution. Numeric reserves remain open. No new build/device operation for sign-off. Wait for a separate **8D.5** request; no browser admin backend or M2 completion is claimed.
|
||||
|
||||
- **8D.4 target validation in progress (2026-09-06):** User supplied clean-boot/full-client-mix UART0 evidence, recorded in `docs/phase8d4_implementation.md`. Loaded internal/DMA/PSRAM free **34,632 / 26,876 / 8,089,284 B**, minima **20,648 / 12,892 / 8,077,516 B**, largest **25,600 / 25,600 / 7,995,392 B**; SSH stack minimum-free **16,280 B** (boot **18,472 B**). Four broker clients at 115200 baud: SSH writer, USB and two web observers; user/admin SSH and both browser roles admitted successfully, no reported I/O/transport failures. One cumulative SSH broker revocation retained without diagnosis. Exact flashed revision/duration not supplied. Console-specific regressions, lifecycle/soak/cleanup and explicit phase sign-off remain pending; numeric reserves remain open. No 8D.5 request or implementation.
|
||||
|
||||
- **8D.4 implemented / host-tested / build-verified (2026-09-06), target pending:** User separately requested the small console boundary. Added transport-qualified identity and immutable output-drain/lifecycle owner callbacks in `admin_ssh_console`, retained the SSH admission API in `ssh_transport`, and kept two shared slots, one dispatcher and all capacities/stacks. Shared completion scratch now has nonblocking ownership; occupied-slot replacement and deferred input fail closed. `python3 tests/admin_console_boundary/run.py` and existing policy suite pass; independent review found no defects. `pio run` passes in **43.61 s**, **95,084 B RAM / 1,627,173 B flash** (**-424 / +1,448 B** versus recorded latest 8D.3). See `docs/phase8d4_implementation.md` for contracts, host-test limits, accounting and target checklist. No browser admin routes, uploads or 8D.5 work. Prior M1 sign-off stands; numeric reserves and 8D.4 target regression remain pending. Stop for validation/user decision before separately requested 8D.5.
|
||||
|
||||
- **8D.3/M1 completed by explicit user sign-off (2026-09-06):** User closes M1 after post-soak evidence, superseding all older pending/blocked/in-progress statements below. Four broker clients remain active at 115200 baud (SSH writer; USB/two web observers), both SSH routes and two cookie sessions active. Post-soak internal/DMA/PSRAM free **38,656 / 30,900 / 8,138,320 B**, minima **13,756 / 6,000 / 8,072,744 B**, largest **25,600 / 25,600 / 7,995,392 B**; SSH stack minimum-free **16,288 B**. Minima/largest unchanged from earlier loaded sample; no reported transport failures. Record/evidence limits in `docs/phase8d3_implementation.md`. Sample is still connected, not disconnected cleanup; exact soak duration/revision not supplied. Unrecorded detailed checks do not reopen signed-off M1 or become claims of execution. Numeric reserves remain open; empty-Enter fix target retest remains unrecorded separately. **Wait for a separate 8D.4 request; no M2 implementation performed.**
|
||||
|
||||
- **8D.3 both-role target login confirmed / mixed-client evidence (2026-09-05):** User reports successful HTTPS user+admin login after Origin fix; previous login blocker is resolved. Settled internal/DMA/PSRAM free **71,204 / 63,448 / 8,247,744 B**. Mixed load free **33,868 / 26,112 / 8,089,060 B**, minima **13,756 / 6,000 / 8,072,744 B**, largest **25,600 / 25,600 / 7,995,392 B**; SSH stack minimum-free **16,288 B**. 115200 baud, four broker clients (SSH sole writer, USB + two web observers), user/admin SSH active. No reported web transport or SSH I/O errors; 6 login attempts/3 invalid credentials/1 logout, zero security rejections. Two identical loaded heap samples are not a soak/leak or reserve proof. Full details/provenance in `docs/phase8d3_implementation.md`. **M1 validation in progress, not signed off.**
|
||||
- **Admin SSH empty Enter fix (2026-09-05):** User's empty line was classified as UART0-restricted because `remote_command_allowed` required argc>0. Changed only helper classification to allow empty input through normal quiet IDF handling; currentness and physical-only commands remain protected. New `python3 tests/admin_ssh_policy/run.py` passes 15 cases using production helper/installed parser. Build passes **21.04 s**, **95,508 B RAM / 1,625,725 B flash** (+20 B flash). Not uploaded or target-tested. Ask for empty Enter/normal-command smoke on next flash; no 8D.4 refactor.
|
||||
|
||||
- **8D.3 Origin-null fix (2026-09-05), target retest pending:** User confirmed challenge200/login403 with `Origin: null`, same-origin Fetch Metadata and pre-login cookie; post-attempt counters show 0 password attempts and 7 security rejections. Root cause is non-CORS fetch POST under no-referrer. Login fetch now uses `mode:'cors'`; app helper uses cors for POST tickets/logout, unchanged GET mode. Same-origin credentials/fixed paths/redirect denial/CSP/no-referrer and strict server Origin/CSRF remain intact. Login CSP hash updated atomically. Both UI suites and cookie-policy suite pass; build **95,508 B RAM / 1,625,705 B flash**, **14.30 s** (+16 B flash). No upload; actual Firefox Origin header/login/serial/logout retest and M1 acceptance still pending. See implementation record; supersedes the speculative diagnosis below.
|
||||
|
||||
- **8D.3 target login blocker (2026-09-05):** User supplied clean-boot/60-second settled telemetry, then reports both user/admin login rejected with the page's HTTP-403 message. Record in `docs/phase8d3_implementation.md`: internal free/min/largest 69,004/66,752/31,744 B; DMA 61,248/58,996/31,744 B; PSRAM 8,223,116/8,218,204/8,126,464 B; SSH stack minimum-free 18,464 B; no active sessions/broker clients, UART stopped. Boot counters precede login attempts; no post-attempt result yet. Exact flashed revision not supplied. Production login-renderer/CSP test passes. Browser warnings name other script hashes (possibly injected scripts), plus denied favicon/file URL; do not relax CSP based on these alone. Need failed endpoint/status/error code and nonsecret Origin/Sec-Fetch-Site. Investigation hypothesis: same-origin fetch mode plus no-referrer policy may serialize POST Origin as null; confirm wire headers before changing request policy. **M1 blocked, not signed off.** No corrective firmware change for this report yet.
|
||||
|
||||
- **8D.3 live cutover implemented / host-tested / build-verified (2026-09-05), M1 target pending:** Resumed another agent's uncommitted completed server/browser implementation; preserved it, verified installed IDF header/upgrade semantics, and fixed right-aligned pending-buffer cleanup with an actual-IDF-reader regression. Cookie login/logout is live, Basic/cache removed. Final build **95,508 B RAM / 1,625,689 B flash** (+248 / +23,764 B versus 8D.2). See `docs/phase8d3_implementation.md`. This supersedes older inert/planned statements below. **Stop for M1 target/browser sign-off before 8D.4; numeric reserve gates remain open.**
|
||||
|
||||
- **8D.3 inert login renderer completed (2026-09-05):** User requested continuation after the parser split. Added standalone `web_login_ui.{c,h}` plus production-renderer/Node tests; no live route or Basic-auth change. Build and focused suites pass; 8D.3/M1 remains incomplete. Remaining work is the atomic server/app cutover, then mandatory browser/target validation. See active task below.
|
||||
|
||||
- **8D.3 preparatory parser split (2026-09-05):** User requested continuation. Per the plan's 600–800-line scope review, selected inert private request parsing before the larger atomic login/logout cutover. `src/web_auth_parse.{c,h}` and focused host tests added; no live HTTP callers or authentication changes. See active task below. 8D.3/M1 is **not complete**; Basic remains active. Prior 8D.2 user sign-off stands; numeric reserves remain open.
|
||||
|
||||
- **8D.2 validated by user sign-off (2026-09-05):** User explicitly closed 8D.2 after post-flash boot and full-client-mix samples. This supersedes older target-pending/in-progress statements below. Evidence and limits are in `docs/phase8d2_implementation.md`; unrecorded regression details are not claims of execution or blockers to the signed-off checkpoint. Numeric reserves remain open. Wait for a separate 8D.3 request.
|
||||
|
||||
- **8D.2 target samples received:** User reports post-flash clean boot and full client mix at 115200 baud, recorded in `docs/phase8d2_implementation.md`. Loaded free internal/DMA/PSRAM **39,200 / 31,444 / 8,138,284 B**, minima **18,784 / 11,028 / 8,109,712 B**, SSH stack minimum-free **16,296 B**. Four broker clients confirmed, SSH writer; no reported SSH I/O or web failures. One cumulative SSH broker revocation and an initial console usage error retained without diagnosis. Target validation is in progress, not signed off; lifecycle/revocation/soak/cleanup evidence remains pending. No 8D.3 request.
|
||||
|
||||
- **8D.2 implemented / host-tested / build-verified (2026-09-05), target regression pending:** User requested 8D.2 only after 8D.0/8D.1 sign-off, with numeric reserve gates still pending. Session-bound serial tickets/slots, session-specific cleanup, and account/global store invalidation are implemented; Basic remains the only public auth route. This supersedes older next-task and dormant-ticket-binding statements below. See `docs/phase8d2_implementation.md`. Stop before 8D.3.
|
||||
|
||||
- **8D.1 validated by user sign-off (2026-09-05):** Following boot/full-client-mix samples, the user reports a long-lasting command with output at full **115200-baud line speed and no dropped broker packets**, and explicitly closes 8D.1 validation. This supersedes older in-progress/not-target-verified statements below. Detailed duration/command/byte totals were not supplied; do not invent them or reopen the signed-off phase solely for unrecorded regression details. Numeric reserve gates remain open; cookie authentication remains dormant/planned. Stop before 8D.2 until requested. See `docs/phase8d_baseline.md` for evidence and sign-off.
|
||||
|
||||
- **8D.1 target validation in progress (2026-09-05):** User supplied fresh-boot (UART stopped) and full-client-mix (115200 baud, SSH writer, admin SSH, USB and two web observers) samples, recorded separately in `docs/phase8d_baseline.md`. Loaded internal/DMA/PSRAM free bytes **41,420 / 33,664 / 8,162,824**, minima **19,384 / 11,628 / 8,115,028**; SSH stack minimum-free **16,288 B**. No reported SSH I/O or web failures; one cumulative SSH broker revocation retained without diagnosis. These supersede older statements below that no target samples exist, but are not full 8D.1 sign-off. Exact changed-firmware hash, lifecycle/HTTPS restart and soak/cleanup coverage remain pending. No continuation to 8D.2 requested.
|
||||
|
||||
- **8D.0 sign-off (2026-09-05):** User marked the baseline validated and identified tested source as latest checked-in revision `d8999cd4a96e477fabd392ced02d810c3cd22d0f`. Current samples and authoritative sign-off are in `docs/phase8d_baseline.md`; they supersede older baseline measurements/provenance notes below. User attributes SSH I/O errors to out-of-spec **460400-baud** testing, distinct from transcript UART configuration **460800**. Numeric reserve approval and 8D.1 post-change target validation remain pending; do not reopen 8D.0 solely for unrecorded checklist details or claim those checks were executed.
|
||||
|
||||
Based on checked-in source plus `README.md` and `docs/roadmap.md`:
|
||||
|
||||
- Hardware characterization, serial service, session broker, USB CDC, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and local display/control are implemented and documented as target-hardware validated.
|
||||
- Phase 8A role-based user storage/UART0 administration and Phase 8B role-aware HTTPS/SSH authentication and targeted revocation are documented as target-hardware validated.
|
||||
- Phase 8C admin SSH is implemented in source, uses the shared `esp_console` registry, and has passed target-hardware validation.
|
||||
- Phase 8D integrated web administration is planned, not implemented. Its ordered scope combines browser login/logout sessions, a shared admin-shell terminal route, typed settings, and contextual Serial/Wi-Fi/client/writer controls; changing terminal modes must preserve the browser serial broker client and any writer lease.
|
||||
- Phase 8D.0–8D.3 and M1 are validated by user sign-off; 8D.3 browser login/logout is implemented, host-tested and build-verified, with post-soak evidence and explicit closure recorded. Browser administration remains planned. Follow `docs/phase8d_plan.md`: one numbered chunk per request, target-validated login/logout (M1) before the browser admin shell (M2), then one typed-settings/control domain at a time (M3). Changing terminal modes must preserve the browser serial broker client and any writer lease. The roadmap retains the full end-state requirements.
|
||||
- Security/production hardening, OTA, BLE evaluation, advanced networking, and optional filesystem features remain future roadmap work.
|
||||
- Reserved OTA, coredump, NVS-key, and storage partitions do not imply those runtime features are implemented.
|
||||
|
||||
@@ -27,9 +76,9 @@ Based on checked-in source plus `README.md` and `docs/roadmap.md`:
|
||||
## Clearly incomplete or transitional areas
|
||||
|
||||
- Phase 8C hardware validation passed, including route separation, shared command serialization, history/completion, prompts, output backpressure, revocation during queued work, deferred SSH lifecycle/reboot actions, and full concurrent transport operation. At 460800 baud with SSH and WebSocket clients in parallel, substantial packet drops and slow display controls were observed under load, without memory exhaustion; no baud-rate reduction is planned.
|
||||
- Current HTTPS has no web-based user administration and gives both roles the same status/terminal routes.
|
||||
- Browser authentication still uses HTTP Basic; Phase 8D plans integrated login/logout sessions before exposing administrative browser routes.
|
||||
- NVS encryption, secure boot/flash encryption review, authentication rate limiting, production certificate/provisioning policy, and OTA are not implemented.
|
||||
- Current HTTPS UI retains status/serial for both roles and exposes an admin-only selector in 8D.6. The 8D.5 backend's temporary policy still denies all user mutations and unsupported self-affecting lifecycle commands.
|
||||
- Browser authentication uses cookie login/logout without Basic fallback. M1 and 8D.5 are signed off; 8D.6 selector target checks remain pending. Lifecycle parity remains separately requested 8D.7 work, not completed M2.
|
||||
- NVS encryption, secure boot/flash encryption review, production certificate/provisioning policy, and OTA are not implemented. HTTPS login has a bounded global five-verifications/60-second throttle, not comprehensive cross-transport DoS protection.
|
||||
|
||||
## Known inconsistencies
|
||||
|
||||
@@ -42,9 +91,56 @@ These observations should be checked when touching the relevant area; they are n
|
||||
## Items to verify in future work
|
||||
|
||||
- Confirm task-local Newlib standard-stream behavior if ESP-IDF/Newlib configuration changes; admin SSH command output relies on dispatcher-task stream redirection.
|
||||
- If HTTPD concurrency configuration changes, add locking around the boot-local Basic-authentication cache.
|
||||
- Re-audit the private HTTPD adapter on SDK changes (including same-version patches): parsed-header layout, right-aligned pending data, explicit handshake/frame installation and log suppression. Host tests do not establish real socket behavior.
|
||||
|
||||
## Active Task
|
||||
## Completed Task - Phase 8D.3 Live Authentication Cutover
|
||||
|
||||
- **Inherited implementation:** `web_cookie_auth.{c,h}`, `web_httpd_adapter.{c,h}`, parser optional-cookie validation, server/console/transport integration, browser app/session recovery, and focused suites. No Basic authorization/cache remains. Four one-hour sessions, four 120-second pre-login challenges, five password checks per 60-second window, four non-evicting 30-second tickets/two serial sockets. Fourteen URI slots, unchanged six HTTPS sockets and application task/stack/queue capacities. No generated assets or SDK files changed.
|
||||
- **HTTPD decision:** Other agent chose an isolated **private-IDF adapter**, not the previously proposed SDK patch. Verified first-only header getters, append-only pointer-backed Set-Cookie (six-header successful login), auto-101-before-handler flow and private frame installation against installed 5.5.0. Serial URI uses ordinary GET until authenticated ticket/currentness/broker admission, then explicit handshake. Exact version guard requires re-audit on update; not a source-hash guarantee. HTTPD logs above ERROR compiled out to avoid secrets/ticket queries. Durable boundary recorded in architecture/design decisions and implementation record.
|
||||
- **Fix in this continuation:** Pending HTTPD bytes are right-aligned. Inherited wipe preserved the wrong end, risking pipelined HTTP/early-frame corruption. Fixed consumed-prefix wipe and tested 0–128 pending lengths plus partial reads using extracted installed `httpd_recv_pending`. Kept all other inherited source work intact.
|
||||
- **Validation:** `python3 tests/web_cookie_auth/run.py`, parser 268-case suite, login UI eight Node groups, serial app nine Node groups, and store `--serial` integration mode pass. Cookie suite also executes store tests and extracts installed header getter/setter/pending-reader functions; handshake/network/tasks remain doubled. Final `pio run` passed **17.62 s**, **95,508 B RAM / 1,625,689 B flash**; +248/+23,764 B versus 8D.2, +976/+25,716 B versus recorded 8D.0. Auth symbols 637 B before placement padding (including 576 B challenges); Basic cache/key removal offsets much of it. No runtime reserve or stack margin inferred.
|
||||
- **Handoff (updated at sign-off):** `docs/phase8d3_implementation.md` contains policy, source verification, accounting, user-provided login/mixed-client/post-soak evidence and explicit **M1 closure on 2026-09-06**. No agent device operation or new build was performed to record sign-off. Earlier missing-check notes are evidence limitations/regression coverage, not blockers to this closure. Numeric reserves remain open. Next implementation is **8D.4 only when separately requested**; preserve prior sign-offs and the distinction between connected post-soak data and disconnected cleanup.
|
||||
|
||||
## Previous Task - Phase 8D.3 Inert Login Rendering
|
||||
|
||||
- **Scope:** Second permitted preparatory split after scope review of remaining challenge/throttle/HTTP-route/application work. Added only `src/web_login_ui.{c,h}`, CMake registration, `tests/web_login_ui/` and documentation. Standalone renderer has no live HTTP caller or URI registration; Basic cache/auth, existing app and serial protocol, capacities, task stacks and generated assets are unchanged. No upload, erase, commit or branch change.
|
||||
- **Behavior when integrated:** No fetch on page load; explicit Sign in obtains challenge with `X-Login-Bootstrap: 1`, then POSTs JSON with CSRF. Same-origin credentials/mode, no-store fetch, redirect rejection, 512-byte response bound and UTF-8 field/body limits. Generic safe-text errors, bounded Retry-After display/manual backoff, no automatic credential retry, fixed success navigation to `/`. Inputs disabled while pending; password fields/references cleared best-effort, attempt aborted on every exit, 15-second deadline, pagehide/pageshow generation guards. No localStorage/cookie access or logging; JavaScript/browser memory cannot be securely wiped.
|
||||
- **Policy/resources:** Standalone 7,387-byte HTML document plus terminator in its object; no protected asset dependencies or template interpolation. Five additional headers (no-store, nosniff, no-referrer, frame denial, matching SHA-256 CSP), below existing eight-header limit. Rendering itself allocates no module heap/session/task. Unused renderer leaves final linked RAM/flash **95,260 / 1,601,925 B**, unchanged versus 8D.2 and parser split. Future linked/read-only data, HTTPD send/TLS and runtime stack/heap costs remain cutover measurements; no runtime reserve approval implied.
|
||||
- **Validation:** `python3 tests/web_login_ui/run.py` passes production-C NULL/header/send-failure checks, exact rendered CSP hash and eight Node VM behavior groups. `python3 tests/web_auth_parse/run.py` passes 268 cases; existing store and `--serial` host modes pass. Independent review caught/fixed unread error-body lifetime and re-entered password retention; regression tests cover abort signals/modeled streams and pending-input cleanup. Final `pio run` passed in **8.25 seconds**, unchanged sizes. `git diff --check` passed. No real browser/CSP enforcement/bfcache, HTTPD sockets, TLS or target exercise.
|
||||
- **Next exact work:** Continue **8D.3 atomic live cutover**, not more login rendering or 8D.4. Reuse prepared parser/renderer/store/serial binding. Implement bounded challenge/throttle and complete cookie/session/CSRF/Origin policy, replace Basic for every app/asset/status/ticket/upgrade route together, add `/login`, challenge/login/session/logout routes, and integrate existing application's session validation/logout/401 recovery without changing explicit serial reconnect. Retain no-live-eviction ticket policy cutover, fail-closed initialization and account/session-specific revocation. Duplicate-header detection and two Set-Cookie behavior must be verified against actual IDF HTTPD, not assumed. Keep response header/route/body budgets explicit. Then M1 target/browser pause and resource evidence; no M2 work.
|
||||
- **Remaining gates:** Full M1 checklist in plan/baseline applies only once live: both roles/fresh and former Basic profiles, direct IP/mDNS, challenge/session/capacity/throttle/Origin/CSRF errors, logout/account switch/expiry/reboot/revocation isolation, repeated serial cycles, UART0/USB/user+admin SSH and soak/cleanup memory. Numeric reserves remain pending; 8D.2 user sign-off stands.
|
||||
|
||||
## Previous Task - Phase 8D.3 Inert Request Parsing
|
||||
|
||||
- **Scope:** Private allocation-free helpers for canonical Host/Origin comparison, unique named 64-lowercase-hex cookie extraction, and strict login JSON. CMake registration plus `tests/web_auth_parse/`; no live route, UI, store, transport, task, socket, stack-size or generated-asset change. Split before coding because full challenge/throttle/routes/UI/tests cutover exceeds the work-unit guideline. No commit, upload or erase.
|
||||
- **Contracts:** Exact bounded byte spans; canonical origin max 128 bytes after optional default-port normalization, ASCII DNS/IPv4 authorities only (IPv6 rejected), mandatory matching HTTPS Origin. Cookie header max 1024 bytes, strict unquoted values even for unrelated cookies. Login body max 512 bytes, exactly username/password strings, decoded limits 16/64 bytes; proper UTF-8/JSON escape/surrogate validation. Unknown/duplicate fields and NUL rejected. Database policy remains authoritative. Failures clear complete outputs; successful credentials and original body require caller wiping. No dynamic allocation or persistent state.
|
||||
- **Validation:** Focused production-C host suite, existing store and `--serial` modes passed. Independent review found no memory-safety defect; default-port maximum-length normalization was fixed and regression cases added. Final rerun/build results recorded in the plan. Host tests are not real HTTPD/browser, sanitizer or target validation.
|
||||
- **Resources:** Build reports **95,260 B linked RAM / 1,601,925 B flash**, unchanged from 8D.2. Unused helpers are not live firmware paths; their future linked flash and request-stack cost must be measured at cutover. No new runtime allocation, task, route, queue or socket. Numeric reserve gates remain pending.
|
||||
- **Next exact work:** Continue 8D.3, not 8D.4: wire the complete challenge/session/login/logout/CSRF/Origin policy and browser recovery atomically, or first take the permitted inert login rendering split if the remaining change still exceeds scope. Never expose partly protected cookie routes. Integration must enforce duplicate HTTP header rejection, bounded complete body reads, content type/method/Fetch Metadata checks, and wipe request buffers on every exit. Bootstrap's permitted missing-Origin GET requires separately validated Host handling, not weakening mutation/upgrade Origin checks. Keep account validation tied to existing database capacities. Remove Basic only with the complete security cutover, then stop for mandatory M1 browser/target sign-off.
|
||||
- **Target checks:** No hardware execution for this inert split. On the live cutover run the full M1 checklist in `docs/phase8d_plan.md` and `docs/phase8d_baseline.md`: both roles, usable errors/logout/account switching, expiry/reboot/revocation/isolation, direct IP/mDNS, capacity/throttle/CSRF, serial reconnect, UART0/USB/user+admin SSH and repeat-cycle/soak memory evidence. No browser-authentication success is claimed here.
|
||||
|
||||
## Previous Task - Phase 8D.2 Serial Session Binding
|
||||
|
||||
- **Checkpoint/scope:** Clean `93eef0e67641f2672c56692a7785e50f31cf236d`; only `web_serial_transport.{c,h}`, `web_session_store.{c,h}`, one Basic ticket call in `web_server.c`, focused host tests and documentation changed. No upload/erase/commit or generated assets. Existing `user_console` and legacy-sync callers already reach the extended revocation hook.
|
||||
- **Implementation:** Non-reused 64-bit originating IDs in four tickets/two slots, zero explicitly Basic-only. Matching-ID upgrade and exact principal binding, expiry/currentness at sensitive boundaries and existing periodic owner checks. Session-specific invalidation/cleanup, account-name/all-session invalidation even if serial init failed, reserved-slot revocation and non-wrapping ticket publication epoch. No new task, heap allocation, route, capacity or stack-size change; Basic cache/currentness retained.
|
||||
- **Validation:** `python3 tests/web_session_store/run.py` and `python3 tests/web_session_store/run.py --serial` pass using OpenSSL SHA-256 and deterministic dependency interleavings. Integration exercises production transport private steps with the real store, not real HTTPD sockets/tasks. Expanded checks cover isolation, mismatched identity fields, expiry/lost notifications/DB failure, close-queue retry, slot reuse and mint/admission races. Optional `--serial --sanitize` cannot link because this host lacks ASan/UBSan runtime libraries and static archives; not a pass.
|
||||
- **Build/resources:** Final source `pio run` passed in 7.92 seconds at **95,260 B RAM / 1,601,925 B flash**, **+56 / +1,420 B** versus 8D.1 and **+728 / +1,952 B** versus 8D.0. Target symbol/DWARF: ticket 96 B x 4 (+32 B array), slot 1,664 B x 2 (+16 B array), new epoch 8 B; store remains 664 B. Bound resolver frame 192 B excluding callees; no runtime stack/heap reserve claim.
|
||||
- **Target/handoff:** `pio device list` sees device CDC and USB serial adapter, but this diff was not flashed or exercised on target. Follow `docs/phase8d2_implementation.md` for two-role Basic five-cycle regressions, mutation isolation, five HTTPS restarts, USB/user+admin SSH/UART0 smoke, 15-minute 115200-baud full mix and 60-second cleanup/memory evidence. Numeric floors remain pending. Cookie-route session logout/expiry/origin validation remains dormant until separately requested 8D.3; obtain target sign-off or explicit user decision before stacking it. No later phase work performed.
|
||||
|
||||
## Previous Task - Phase 8D.1 Internal Session Primitives
|
||||
|
||||
- **Objective:** Phase 8D.1 only. User explicitly requested continuation with remaining reserve/validation gates pending, then supplied a second pre-change full-client-mix sample. No browser auth cutover or continuation to 8D.2.
|
||||
- **Context:** The user reports that the earlier experimental implementation was moved to a separate branch and `devel` rolled back after repeated agent interruptions, a roughly +10k/-1k-line diff, broken browser login, and severe memory pressure. These symptoms have not been independently diagnosed. Do not import that branch wholesale.
|
||||
- **Changes completed:** Added `src/web_session_store.{h,c}`, CMake registration and small admitted-HTTPS-start/stop hooks in `web_server.c`. Four static digest-only sessions, copied principals, separate CSRF/origin state, absolute expiry, generation-safe IDs/epochs, invalidation/prune and count-only snapshot APIs. Database/crypto outside portMUX, post-call revalidation. No issuing HTTP callers, ticket/socket/revocation binding, new routes/tasks, UI change or regenerated assets. Added focused `tests/web_session_store/` runner and updated baseline/plan/architecture/code map.
|
||||
- **Baseline:** Clean `devel` at `af89dd1bd96cdd97d8d57eee7a29f68e3874506b`, PlatformIO espressif32 6.12.0 / IDF 5.5.0, N16R8 release. `pio run` passed in 36.08 seconds: 94,532 B linked RAM and 1,599,973 B flash (historical RAM unchanged, flash +208 B, cause not diagnosed). `pio device list` returned no ports. No agent upload or target/browser test was performed. The user subsequently supplied a runtime heap baseline; stack measurements remain pending.
|
||||
- **User-provided runtime memory baseline (2026-09-05):** Administrative SSH transcript with one browser serial writer (broker 8), one public-key user SSH observer (broker 9), and one public-key admin SSH session; web 1/2, SSH 2/2, zero tickets, no USB broker client listed. Heap bytes in free/minimum-free/largest-block order: internal 8-bit **39,668 / 13,876 / 18,432**; internal DMA **31,912 / 6,120 / 18,432**; PSRAM **8,138,380 / 8,107,324 / 7,995,392**. Accepted as the runtime baseline, not reserve floors or full-workload validation. Flashed revision, baud, duration, traffic and stack margins were not supplied. See `docs/phase8d_baseline.md` for provenance and remaining checks.
|
||||
- **Follow-up pre-change full-client-mix sample:** Two web sessions (broker 8 writer, 10 observer), USB broker 11 observer, user SSH broker 9 observer and admin SSH; four broker clients, SSH 2/2, web 2/2, no tickets. Internal 8-bit **41,868 / 13,876 / 18,432 B**; DMA **34,112 / 6,120 / 18,432 B**; PSRAM **8,162,784 / 8,107,324 / 7,995,392 B** (free/minimum/largest). Cumulative web/SSH traffic with no reported transport I/O/protocol/queue failures; one web auth failure and two rejected SSH RX bytes retained without diagnosis. This is not timed-soak or byte-integrity evidence; see baseline record.
|
||||
- **8D.1 validation/accounting:** Started at clean `02fdeee3453654680c11096c9a6224c26233eced`. `python3 tests/web_session_store/run.py` passes with OpenSSL SHA-256, deterministic dependency doubles and injected callback races; no hardware or real multicore scheduling claim. Final `pio run` passed in 8.90 seconds: **95,204 B RAM (+672 B)**, **1,600,505 B flash (+532 B)**. Target object/DWARF: record 152 B × four, state 656 B + lock 8 B = **664 B**, no module heap/new task/socket/route cost, Basic cache retained. Only init/stop currently survive firmware linker GC; full primitive flash/stack costs become relevant when future callers are added. Independent review found/fixed store reinit by rejected concurrent start. Post-change target run pending.
|
||||
- **Planned contract decisions:** Four one-hour absolute sessions without idle/sliding renewal, four 120-second pre-login CSRF challenges, four 30-second serial tickets, no live-record eviction and no Basic compatibility after cutover. Strict same-origin mutation/upgrade checks, session-specific logout versus account-wide revocation, bounded global login throttle. These are functional choices, not measured memory budgets or implemented behavior.
|
||||
- **Next:** Stop at 8D.1. Obtain post-change Basic browser/serial/USB/user+admin SSH/UART0 regression and comparable `memory`/counters/`ssh status` evidence; five serial disconnect/reconnect cycles per role and HTTPS stop/start checks. Timed soak/cleanup and reserve floors remain pending. Next implementation is 8D.2 session-specific ticket/socket binding, only after checkpoint or explicit user decision; M1/M2 pauses remain. Host tests cover dormant primitives, not browser logout/currentness integration. ID/epoch exhaustion, collision rejection and private memory wiping are source-reviewed only.
|
||||
- **Risks / things to remember:** Six HTTPD client slots include WebSockets; HTTPD infrastructure plus HTTPS and SSH can consume twelve of sixteen descriptors before other services/transients. Existing LRU purge is enabled and must not evict retained serial to admit M2 admin sockets. Nine current route slots have no spare capacity; the proposed M1 contract adds five. Cookie sessions, tickets, sockets and request scratch are separate costs. Only SSH exposes stack margin today. Numeric runtime floors and per-chunk/cumulative budgets remain pending, not guessed.
|
||||
|
||||
## Previous task — mDNS (target checks still pending)
|
||||
|
||||
- **Objective:** Announce a configurable `sak-<suffix>.local` hostname through mDNS when Wi-Fi STA has an IPv4 address, without changing the Wi-Fi NVS blob schema.
|
||||
- **Relevant files:** `src/mdns_config.{c,h}`, `src/mdns_service.{c,h}`, `src/mdns_console.{c,h}`, `src/wifi_manager.{c,h}`, `src/main.c`, `src/CMakeLists.txt`, `src/idf_component.yml`, `dependencies.lock`, completion and command documentation.
|
||||
|
||||
@@ -40,6 +40,8 @@ Only constraints supported by implementation or current project documentation be
|
||||
|
||||
**Consequence for future changes:** Preserve transport-slot generations and account-authentication generations as distinct concepts. Validate tokens immediately before side effects and discard late work after disconnect/reuse/revocation.
|
||||
|
||||
Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs in serial tickets/slots. 8D.3 rejects zero IDs; Basic authentication/cache are removed. Session-specific cleanup must not become account-wide cleanup; account-name notification intentionally covers deletion/recreation. Invalidate cookie records before requesting transport cleanup, and retain authoritative session/principal checks when notification fails. The transport epoch cancels in-flight ticket publication without taking store and transport locks together.
|
||||
|
||||
**Relevant files:** `src/session_broker.{h,c}`, `src/ssh_transport.c`, `src/web_serial_transport.c`, `src/admin_ssh_console.c`, `src/user_database.{h,c}`
|
||||
|
||||
## UART0 is the physical recovery authority
|
||||
@@ -80,10 +82,14 @@ Only constraints supported by implementation or current project documentation be
|
||||
|
||||
**Consequence for future changes:** Actions that would invalidate their own SSH transport should integrate with deferred control when acknowledgement preservation matters. Prevent new input while an action is pending, keep the wait bounded, and do not describe it as guaranteed delivery.
|
||||
|
||||
Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutable owner adapter on the existing control task, outside console locks. Tokens include a transport namespace; owners revalidate full identity and marshal to their transport APIs. `SELF_CLOSE` targets the invoking frontend while existing SSH action meanings remain unchanged. Unsupported actions must fail before side effects. The two console slots remain a shared bounded pool, with no hypothetical browser capacity allocated. The 8D.5 prerequisite additionally requires owner currentness on the dispatcher, outside console locks, before commands and during prompts; account currentness alone cannot establish originating browser-session liveness. Recheck token identity after external validation, reject revoked submitted replies, and wipe consumed output. Polling is not a hard cancellation deadline and cannot roll back arbitrary handlers; owners retain admission/input/output/lifecycle responsibilities.
|
||||
|
||||
**Relevant files:** `src/admin_ssh_console.c`, `src/system_console.c`, `src/ssh_console.c`, `src/ssh_transport.c`
|
||||
|
||||
## Authentication uses copied principals and fail-safe currentness checks
|
||||
|
||||
**8D.5 web owner extension:** Browser and runtime SSH admission allocate from the same two console slots; a physical SSH slot is not a console index. WEB supports owner-relative self-close only and rejects unsupported network/lifecycle/account mutations at parsed command policy before execution. One web-admin socket and two tickets do not increase six-socket HTTPD capacity; disable LRU rather than evict retained serial clients. The optional owner uses one PSRAM-only payload and ESP timer scheduling, not a new task. HTTPD alone sends/shuts down its verified current fd. Do not use IDF's queued raw-`sock_db *` close from admin polling: free/reuse before that work executes could close a replacement. Detach must fence queue submissions before HTTPD stop; retire queued markers only after successful stop, retaining ownership across failures. No browser UI or generic HTTP command runner is part of this boundary.
|
||||
|
||||
**Decision:** Network sessions retain secret-free copied principals. Account mutations invalidate generations/IDs; after commit, the command layer requests best-effort targeted transport revocation, while ongoing currentness checks are authoritative.
|
||||
|
||||
**Rationale/evidence:** `user_database` issues principals without secrets; web/SSH check currentness during admission and active sessions. Mutating console paths call transport revocation hooks.
|
||||
@@ -92,6 +98,20 @@ Only constraints supported by implementation or current project documentation be
|
||||
|
||||
**Relevant files:** `src/user_database.{h,c}`, `src/user_console.c`, `src/web_server.c`, `src/web_serial_transport.c`, `src/ssh_transport.c`
|
||||
|
||||
## Browser authentication has a narrow version-pinned HTTPD boundary
|
||||
|
||||
**8D.6 terminal separation:** Browser selection never reconnects serial or requests/releases a writer lease. Hidden connected terminals continue draining with separate bounded scrollback/pending writes and visible browser-drop accounting; only selected keyboard input is sent. Admin admission/reopen is explicit, close is isolated, and logout/expiry/pagehide tears down both routes. Keep the two page-lifetime input subscriptions stable across switches and remove socket callbacks on close. UI role hiding complements, never replaces, backend authorization. Existing unsupported WEB lifecycle/account-command restrictions remain for 8D.7.
|
||||
|
||||
**Decision:** 8D.3 uses `web_cookie_auth` plus digest-only session/challenge stores, mandatory Origin/CSRF mutations and no live session/challenge/ticket eviction. Four one-hour absolute sessions deliberately interrupt long serial connections at expiry. No Basic compatibility path remains.
|
||||
|
||||
**Browser Origin serialization:** Authentication POST fetches use `mode: 'cors'` while retaining fixed same-origin URLs, `credentials: 'same-origin'`, redirect rejection and CSP `connect-src 'self'`. Under `no-referrer`, non-CORS POST mode can serialize Origin as `null` (confirmed in Firefox during M1 testing). Do not fix that by accepting null server-side or weakening CSP/referrer policy; no cross-origin server permission is added.
|
||||
|
||||
**HTTPD boundary:** `web_httpd_adapter` alone includes private ESP-IDF 5.5.0 structures. Public request getters expose only the first field, so the adapter validates bounded parsed headers/rejects duplicates. `/ws/serial` is an ordinary GET until authenticated transport admission explicitly sends 101 and installs the frame handler; automatic HTTPD upgrades happen before URI handlers. Preserve right-aligned unread pending bytes when wiping request memory. Two Set-Cookie calls append pointer-backed fields, whose distinct buffers must survive through send. HTTPD logs above ERROR are compiled out to prevent header/ticket exposure.
|
||||
|
||||
**Consequence:** The version guard is not a source-hash guarantee. Re-audit layout, scratch/pending ownership, logging and handshake/frame dispatch on SDK changes; do not scatter private accesses through application code or assume host doubles prove real socket behavior. No SDK patch is currently applied. See `docs/phase8d3_implementation.md` for verification and target gates.
|
||||
|
||||
**Relevant files:** `src/web_cookie_auth.{c,h}`, `src/web_session_store.{c,h}`, `src/web_httpd_adapter.{c,h}`, `src/web_server.c`, `src/web_serial_transport.c`, `src/CMakeLists.txt`.
|
||||
|
||||
## Security material and configuration use bounded, versioned NVS records
|
||||
|
||||
**Decision:** Application settings, users, and identities use separate fixed/versioned NVS blobs. Serial, Wi-Fi, mDNS-hostname, and local-UI working edits are RAM-only until explicitly saved. User mutations and HTTPS/SSH identity changes commit directly as part of the operation. Invalid ordinary configuration generally selects RAM defaults without erasing storage; malformed security material fails closed and needs explicit reset.
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Phase 8D.2 Implementation Record
|
||||
|
||||
Status (2026-09-05): **Implemented / host-tested / build-verified / target validated by user sign-off.** Post-flash boot and full-client-mix samples are recorded below. Numeric reserve gates remain open. 8D.0 and 8D.1 remain validated by user sign-off. No 8D.3 implementation or browser authentication cutover is claimed.
|
||||
|
||||
## Validation Sign-off
|
||||
|
||||
The user explicitly marked **8D.2 validated** on 2026-09-05 after supplying post-flash boot and full-client-mix evidence. This closes the phase checkpoint. Unrecorded detailed checks and unexplained observations below remain regression coverage and evidence limitations, not blockers to this sign-off or claims that additional tests were executed. Numeric reserve approval and target validation of future cookie-authentication paths are not implied. Do not begin 8D.3 without a separate request.
|
||||
|
||||
## Scope and Behavior
|
||||
|
||||
- Started from clean `93eef0e67641f2672c56692a7785e50f31cf236d`; changes remain uncommitted. No unrelated worktree changes were present. No branch import, upload, erase, NVS change, commit or web-asset regeneration.
|
||||
- Source changes are confined to `web_serial_transport.{c,h}`, `web_session_store.{c,h}` and the existing Basic ticket call in `web_server.c`. Existing `user_console.c` committed-mutation calls and `web_console.c` legacy synchronization calls already reach `web_serial_transport_revoke_user`; no duplicate notifications or command-policy edits were needed.
|
||||
- Four serial ticket records and two socket slots now retain a `web_session_id_t`, distinct from account ID/authentication generation and transport-slot generation. Nonzero IDs come only from the session store's non-reused 64-bit sequence. Zero explicitly identifies the still-shipped Basic path, not a missing-cookie fallback.
|
||||
- Trusted internal mint and upgrade interfaces accept an originating ID. Mint checks both session liveness and exact copied principal ownership; consume requires the same originating ID and consumes before currentness validation. A mismatched-session attempt is rejected without consuming the other session's ticket. Basic upgrade cannot consume a bound ticket. All existing serial framing, writer admission, capacities and Basic behavior remain intact.
|
||||
- Session currentness includes deadline, store readiness, copied-principal binding and authoritative database currentness. Checks run at mint/consume, serial admission boundaries, before input/writer-control dispatch and in the existing 250 ms owner reconciliation. Database calls remain outside both portMUX locks. No CSRF value is exported to the transport.
|
||||
- `web_serial_transport_revoke_web_session(id)` invalidates that store ID first, wipes only matching tickets and marks only matching reserved/active slots for HTTPD-owned close and existing broker cleanup. Repeated stale cleanup cannot match a newly issued session in a reused slot. HTTPD close-queue failure retains the close flag and retries through the existing owner path; input remains denied.
|
||||
- Account-name revocation invalidates all matching cookie records, tickets and reserved/active sockets, including after deletion. It invalidates the store even if serial initialization failed. Global revocation invalidates all records without disabling the store and now marks reserved as well as active slots. Basic cache hits continue authoritative DB checks rather than cross-task cache mutation. Username-scoped notifications intentionally cover old and newly recreated identities of that name; other names are untouched.
|
||||
- A bounded 64-bit transport epoch cancels ticket publication across revocation or detach/re-attach. It never wraps; exhaustion rejects future minting until reboot. As with store issuance, an unrelated concurrent revocation may conservatively reject an in-flight mint, but never removes another session's existing socket. Direct store invalidation/expiry and missed account notifications still fail closed through authoritative checks; no notification is an authorization lease.
|
||||
- Existing secret-free ticket reject/consume/expiry, store invalidation/rejection, close and queue-failure counters cover these paths. No session ID, cookie, CSRF, digest, password or ticket is added to routine snapshots/logs.
|
||||
|
||||
## Resource Accounting
|
||||
|
||||
Same release N16R8 environment, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0. Figures compare the final build against the recorded 8D.1 and 8D.0 builds, not a new runtime measurement.
|
||||
|
||||
| Resource | 8D.2 | Change From 8D.1 | Cumulative From 8D.0 |
|
||||
|---|---:|---:|---:|
|
||||
| Linked static RAM | 95,260 B | +56 B | +728 B |
|
||||
| Program flash | 1,601,925 B | +1,420 B | +1,952 B |
|
||||
| Serial ticket array | 384 B, 96 B x 4 | +32 B | +32 B |
|
||||
| Serial slot array | 3,328 B, 1,664 B x 2 | +16 B | +16 B |
|
||||
| Transport publication epoch | 8 B | +8 B | +8 B |
|
||||
| Cookie store and lock | 664 B; records 152 B x 4 | 0 B | +664 B |
|
||||
|
||||
Target object symbols (`xtensa-esp32s3-elf-nm -S --size-sort`) and ELF DWARF (`gdb` `sizeof`) verify array/record sizes. The six added IDs plus epoch explain the entire +56 B linked RAM delta. Store `resolve` now survives linker GC through transport currentness, along with principal-binding/account-name invalidation dependencies. Issue/lookup and session-specific logout remain without production HTTP callers; future linkage costs are not included as though all M1 code were live.
|
||||
|
||||
- **Heap/PSRAM:** No added dynamic allocation, payload, allocator fallback or per-session heap cost in normal or worst-case execution of these changes. Static internal RAM grows by the reported 56 B. Runtime free/minimum/largest-block deltas are unmeasured, not inferred from static RAM.
|
||||
- **Tasks/stacks:** No new task or stack-size change. Existing static web transport stack remains 6,144 B; dynamic HTTPD stack remains 10,240 B. Bound-session checks use the existing resolver's 152 B transient record copy; target disassembly reports a 192 B resolver frame, excluding callees. Additional ID/epoch locals use existing stacks. This is not a measured peak-stack delta or approved reserve margin; bound paths are dormant on Basic traffic.
|
||||
- **Scratch/queues/capacity:** Existing ticket response 96 B and request scratch/payload limits are unchanged. Four one-hour cookie sessions, four 30-second tickets, two serial sockets, one pending TX work item per slot, nine routes, six HTTPS clients and sixteen lwIP descriptors remain unchanged. No TLS buffers or connection limits change. Full-ticket earliest-expiry eviction is deliberately retained until 8D.3's atomic policy cutover.
|
||||
- Numeric internal-heap/largest-block/stack floors and incremental budgets remain pending. No new floor is invented and no runtime regression is accepted based solely on linked size.
|
||||
|
||||
## Executed Validation
|
||||
|
||||
- `python3 tests/web_session_store/run.py`: **PASS**, production store public APIs with OpenSSL SHA-256 and deterministic dependency doubles.
|
||||
- `python3 tests/web_session_store/run.py --serial`: **PASS**, includes the preceding suite plus production serial private ticket/admission/input/currentness/close/broker-cleanup steps with the production store linked separately. Uses `-Wall -Wextra -Werror` and finite compilation/execution timeouts.
|
||||
- Integration coverage: same-account session-specific versus account-wide cleanup, unaffected account isolation, matching-ID/single-use tickets, all principal fields, stale tickets/principals, expiry, DB failure and direct store invalidation without notification, serial-init failure, close-queue retry, old-session cleanup after slot reuse, stale periodic check after generation change, logout during mint and broker admission, and Basic admission/binary input with the store disabled. The first integration compile caught a misleading-indentation warning in its test double; corrected before passing runs.
|
||||
- `python3 tests/web_session_store/run.py --serial --sanitize`: **BLOCKED at link**, missing `/usr/lib64/libasan.so.8.0.0` and `/usr/lib64/libubsan.so.1.0.0`; static sanitizer archives are also unavailable. No sanitizer execution/pass is claimed. Optional runner mode remains available on a provisioned host.
|
||||
- `pio run`: **PASS**, first build 8.05 seconds, final source build 7.92 seconds, each with a 120-second tool timeout; identical resource totals above.
|
||||
- `git diff --check`: **PASS**. Reviewed source/API diffs for lock ordering, identity mixing, failure isolation, late publication and generation-safe cleanup. No HTTP route or generated-asset change.
|
||||
- `pio device list`: completed; native device CDC and a USB serial adapter are visible. This changed firmware was not uploaded, and no target/browser test was run. Existing flashed firmware cannot validate this diff.
|
||||
|
||||
Host tests are deterministic dependency interleavings, not real multicore scheduling, UART byte-integrity tests or HTTPD network execution. They do not establish the planned one-second expiry-detection target under load, send/close delivery, stack reserve or heap stability. Session-specific logout/expiry via browser cookies remains dormant until 8D.3; no debug route was added to exercise it early.
|
||||
|
||||
## User-Provided Target Samples
|
||||
|
||||
The user reports flashing 8D.2 and collecting a clean-boot sample, followed by the full client mix. Exact flashed revision/hash, settling/load duration, browser/version/origin and fixture were not supplied. These are sequential user observations, not atomic measurements or agent-executed tests; they supersede the implementation-time statement above that no target run had been supplied.
|
||||
|
||||
| Workload | Internal free/min/largest | DMA free/min/largest | PSRAM free/min/largest | SSH stack minimum-free |
|
||||
|---|---|---|---|---|
|
||||
| Post-flash clean boot, UART stopped, no broker clients | 69,500 / 66,448 / 31,744 B | 61,744 / 58,692 / 31,744 B | 8,223,104 / 8,190,440 / 8,126,464 B | 18,472 B |
|
||||
| SSH writer + admin SSH + USB observer + two web observers; UART running at 115200 baud | 39,200 / 18,784 / 29,696 B | 31,444 / 11,028 / 29,696 B | 8,138,284 / 8,109,712 / 8,126,464 B | 16,296 B |
|
||||
|
||||
- **Both samples:** HTTPS/SSH initialized and running, not transitioning, ports **443/22**, `last-error=ESP_OK`. HTTP Basic via the user database, four users/two admins, unchanged endpoints. SSH role-based password/public-key authentication, shell/PTY-only admission; exec/subsystem/forwarding/SCP/SFTP disabled. SSH owner core **1**, configured stack **20,480 B**. mDNS initialized/announced, `sak-1024.local`, suffix `1024`, `ESP_OK`.
|
||||
- **Boot state:** SSH/WebSocket sessions **0/2**, web serial attached, zero tickets, no broker clients. UART stopped, owner idle, configuration v1 **115200 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**, RX available/TX pending **0**. Phase 0 commands reported available, not executed. USB initialized/attached, host-open/DTR/RTS **no**, broker disconnected; host line coding **9600 baud, 8N1**.
|
||||
- **Boot command anomaly:** The transcript contains repeated `ssh sessions` input, one usage response and `0x1 (ERROR)`, followed by successful `ssh sessions` and `ssh status` output. Preserve this as an unexplained console-input/command observation; neither user input error nor a firmware regression is established.
|
||||
- **Boot counters:** SSH starts **1**, all other supplied SSH counters **0**. Web starts **1**, start-failures/stops **0**; requests total/authenticated/status **10**, root/tickets/assets/auth-failures/response-errors **0**. All ticket/WebSocket session/RX/TX/control/failure counters **0**. Boot includes authenticated status activity.
|
||||
- **Loaded sessions:** SSH **2/2**, public-key user session **5**, slot **0**, broker **8 writer**; public-key admin session **6**, slot **1**, admin-console route without a broker. Both active/authenticated, admin-command idle, zero output, no RX/TX pending or closing state. Web **2/2**, same-account password-authenticated normal-user observers: slot **0**, fd **56**, generation **1**, broker **10**; slot **1**, fd **57**, generation **1**, broker **11**. No tickets, TX pending or closing state.
|
||||
- **Loaded broker/USB/UART:** Exactly four clients: SSH **8 writer**, USB **9 observer**, web **10/11 observers**, all with zero pending bytes/events. USB initialized/attached, host-open/DTR/RTS **yes**, broker **9 observer**, host line coding **115200 baud, 8N1**. UART running, owner serial service, unchanged **115200-baud 8N1** configuration, RX available/TX pending **0**, modem **DCD=0, DSR=1, CTS=1, RI=0**, **VLD=1**. Host line coding is diagnostic only, not UART1 configuration authority.
|
||||
- **Loaded SSH counters:** Starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, failures/disconnects **0**, writer requests/grants **1**, denials **0**, **broker revocations 1**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted **19 B**, rejected **0**, TX **24,763 B**, I/O failures/session revocations **0**. The cumulative broker revocation is distinct from session revocation; current SSH writer ownership is confirmed, but the event's cause is not supplied.
|
||||
- **Loaded web counters:** Starts **1**, start-failures/stops **0**. Requests total/authenticated **72**, root **1**, status **68**, tickets **2**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **2**, rejected/expired **0**. WebSocket connects **2**, disconnects/connect failures/service-start failures/broker failures **0**. RX frames/bytes accepted/rejected **0**. TX **223** binary frames / **33,171 B**, **5** control frames / **398 B**. Writer requests **2**, grants **0**, denials **2**, releases/revocations **0**; send/queue/protocol failures and closes **0**. Writer denials match observer admission.
|
||||
- **Comparison:** Versus the 8D.1 full-client-mix sample, free internal/DMA heap is **2,220 B lower**, free PSRAM **24,540 B lower**; minimum-free internal/DMA is **600 B lower**, PSRAM **5,316 B lower**. Internal/DMA largest block is **2,048 B smaller**; PSRAM largest block is unchanged. SSH stack minimum-free is **8 B higher**. These are snapshot differences with differing HTTP/TLS activity and unspecified timing, not a controlled incremental allocation measurement or proof of a leak. Cleanup/repetition evidence is still needed for trends; the static link delta remains **+56 B**.
|
||||
- **Acceptance scope:** Startup, authenticated status and full-client admission/traffic are evidenced, with no reported SSH I/O or web failures. Byte integrity, account-mutation revocation, HTTPS/lifecycle repetition, timed soak/cleanup and full 8D.2 sign-off remain unrecorded. Dormant cookie-bound paths remain host-tested rather than exercised by these Basic-auth samples. Numeric reserves are not approved by these measurements.
|
||||
|
||||
## Target Checklist and Handoff
|
||||
|
||||
1. After a user-controlled upload, record source/build identity, browser/version/origin and UART configuration. Settle for 60 seconds; collect UART0 `memory`, `web status`, `web counters`, `broker clients`, `usb status`, `ssh status` and `ssh counters`. Keep all internal/DMA/PSRAM free/minimum/largest values; do not sum overlapping DMA/internal measurements.
|
||||
2. Log in with each role using current Basic auth. Exercise binary serial data and request/release writer, then five explicit Disconnect/Connect cycles per role. Confirm paused reconnect, broker/writer cleanup, two-browser capacity and unaffected observers. Test mDNS and direct IP separately where available.
|
||||
3. With two browser serial clients for the same disposable account and another account on SSH, mutate password, role and SSH keys, and delete/recreate the disposable account through UART0. Confirm affected existing web sockets/tickets are rejected or closed, unrelated clients survive, and fresh credentials work. Do not change the final administrator or recovery credentials merely to test revocation.
|
||||
4. Stop/start HTTPS from UART0 and reconnect through Basic; confirm native USB UART1 and user/admin SSH remain usable throughout. Capture status/counters after five bounded restart cycles. Injection of store/transport init or close-queue failure is host-covered only unless a separately controlled target fixture exists.
|
||||
5. Run a 15-minute concurrent 115200-baud workload with two web clients, USB, user SSH and admin SSH; exercise UART0 recovery and verify traffic/counter integrity. Record memory and SSH stack minimum-free at full load, then disconnect clients/USB DTR, wait 60 seconds and capture cleanup. Compare with 8D.1's comparable workload, not different-baud historical samples; investigate new drops, queue errors, leaks or declining largest-block trends.
|
||||
6. Record target results and obtain 8D.2 sign-off before stacking 8D.3 runtime changes, unless the user explicitly decides otherwise. Carry browser cookie logout isolation, cross-origin binding, expiry latency and the complete M1 acceptance gate into separately requested **8D.3 only**. No work beyond 8D.2 was performed.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Phase 8D.3 — Live browser authentication cutover
|
||||
|
||||
Status (2026-09-06): **Implemented / host-tested / build-verified / Phase 8D.3 and M1 validated by explicit user sign-off.** The user closed M1 after successful both-role login, mixed-client operation and the post-soak sample below. This supersedes older blocked/pending/in-progress statements in this record. 8D.0–8D.2 sign-offs stand; numeric reserve gates remain open. Wait for a separate 8D.4 implementation request.
|
||||
|
||||
## M1 validation sign-off and post-soak evidence (2026-09-06)
|
||||
|
||||
The user explicitly requested **M1 validation be marked completed**, supplying the following post-soak data after previously confirming both-role HTTPS login. This closes the 8D.3/M1 target checkpoint. Unrecorded detailed acceptance checks remain regression coverage/evidence limitations, **not blockers to this sign-off or claims that those checks were executed**. Numeric reserve approval, later M2 work and target verification of the subsequent admin-SSH empty-line fix are not implied.
|
||||
|
||||
### Post-soak sample — clients still connected
|
||||
|
||||
| Heap | Free | Minimum-free | Largest block |
|
||||
|---|---:|---:|---:|
|
||||
| Internal 8-bit | 38,656 B | 13,756 B | 25,600 B |
|
||||
| Internal DMA | 30,900 B | 6,000 B | 25,600 B |
|
||||
| PSRAM | 8,138,320 B | 8,072,744 B | 7,995,392 B |
|
||||
|
||||
SSH stack minimum-free remains **16,288 B**, configured 20,480 B. Compared with the earlier mixed-load sample, internal/DMA current free increased by **4,788 B** each and PSRAM free by **49,260 B**; all supplied minimum-free and largest-block values are unchanged. This is not a disconnected/60-second-cleanup sample or proof of leak freedom. DMA overlaps internal heap, and lifetime minima are not synchronized snapshots. No numeric safety floor is inferred from the 6,000-byte DMA minimum.
|
||||
|
||||
- UART1 remains running at **115200 8N1/no flow**, owner serial service. RX available 128 B and TX pending zero at its snapshot; modem state DSR/CTS asserted, valid voltage. Four broker clients: user web **8** observer, user SSH **9** sole writer (65 B pending), USB **34** observer and admin web **27** observer. All event queues zero. USB is attached/open with DTR/RTS asserted and host diagnostic coding 115200; changing USB broker ID from earlier 10 to 34 is retained without attributing a specific lifecycle event.
|
||||
- SSH remains **2/2**, user/admin public-key authentication and broker/admin-console route separation intact. Admin command/output idle, no RX/TX pending or closing for either SSH session. Two successful handshakes/auth attempts; all supplied handshake/auth/I/O/session-revocation/broker failure counters zero. Cumulative stream RX **242 B**, all accepted/none rejected; TX **1,550,114 B**. One historical writer denial is unchanged while current ownership remains SSH.
|
||||
- HTTPS remains running/ready with last error ESP_OK. Cookie sessions **2/4**, challenges **0/4**, web serial **2/2**, zero outstanding tickets. Both web sockets show TX pending and not closing, consistent with active traffic rather than a cleanup endpoint. mDNS remains announced as `sak-1024.local`, error ESP_OK.
|
||||
- Web protected requests **510**, authenticated **509**, auth failures **1**; root **4**, status **493**, tickets **4**, assets **8**, response errors **0**. Tickets issued/consumed **4/4**, rejected/expired **0**; serial connects/disconnects **4/2**, no connection/service-start/broker failures.
|
||||
- Web RX **1 frame / 14 B**, accepted with no rejection. TX **14,670 binary frames / 3,146,808 B**, control **21 frames / 1,813 B**. Writer requests/grants/denials/releases **4/1/3/1**, no revocations. Send/queue/protocol/close-failure-related counters remain zero. No broker-drop counters or independent end-to-end byte-integrity result were supplied.
|
||||
- Cookie authentication remains **6 password checks**, **3 invalid-credential results**, **1 logout**, and zero throttle/capacity/CSRF-or-Origin rejections. These cumulative counts are unchanged from the earlier successful mixed-client sample; no new login fault is inferred from the historical invalid-credential/request-auth counts.
|
||||
|
||||
**Evidence boundaries:** User describes this as after the soak test; exact duration, command/traffic pattern and flashed revision/hash are not explicitly supplied with this sample. Do not silently substitute the recommended 15-minute duration or assert a disconnected cleanup happened. The transcript shows actual continued activity and no reported transport error, watchdog or memory exhaustion. The empty-Enter fix's targeted on-device retest remains unrecorded; this does not reopen M1. No agent upload, device exercise or fresh build was performed to record this sign-off.
|
||||
|
||||
**Next:** M1 is complete. Continue only on a separate request for **8D.4 — small transport-neutral console boundary**, preserving the canonical dispatcher, serial ownership and open resource gates. Do not implement later M2 chunks merely because M1 was signed off.
|
||||
|
||||
## Successful post-fix target login and mixed-client sample (2026-09-05)
|
||||
|
||||
The user explicitly reports successful HTTPS login as both `commander1024` (user) and `admin` after the Origin-mode correction. This supersedes the earlier login-blocker statements below. The following are user-provided sequential snapshots, not agent-executed tests or an atomic measurement. Exact flashed revision/hash and settled duration for this new sample were not supplied.
|
||||
|
||||
| Heap | Settled boot free / minimum / largest (B) | Mixed load free / minimum / largest (B) |
|
||||
|---|---|---|
|
||||
| Internal 8-bit | 71,204 / 66,752 / 31,744 | 33,868 / 13,756 / 25,600 |
|
||||
| Internal DMA | 63,448 / 58,996 / 31,744 | 26,112 / 6,000 / 25,600 |
|
||||
| PSRAM | 8,247,744 / 8,245,836 / 8,126,464 | 8,089,060 / 8,072,744 / 7,995,392 |
|
||||
| SSH stack minimum-free | 18,464 B (20,480 B configured) | 16,288 B |
|
||||
|
||||
**Settled boot:** UART service stopped/owner idle at 115200 8N1/no flow; no broker clients or SSH sessions. USB initialized/attached but host closed/DTR false. HTTPS ready, sessions/challenges/tickets zero, all supplied web and SSH request/traffic/failure counters zero. mDNS initialized/announced as `sak-1024.local`, last error ESP_OK. The USB host's diagnostic 9600 coding does not configure UART1.
|
||||
|
||||
**Mixed-client load:** UART running at 115200 8N1/no flow. Four broker clients: web user 8 observer, user SSH 9 **sole writer**, USB 10 observer, web admin 27 observer. Public-key user/admin SSH both active (2/2); admin SSH has no broker client. Two password-authenticated web serial sessions and two cookie sessions, no outstanding challenges/tickets. USB host open/DTR/RTS asserted; diagnostic line coding 115200. RX available/TX pending zero at the serial snapshot; all four displayed broker pending/event queues zero. mDNS remains announced without errors.
|
||||
|
||||
- Two consecutive loaded `memory` samples are identical. This is short-term observation, **not** leak/soak/cleanup or reserve-floor validation. Lifetime minima include handshake/earlier activity; DMA overlaps internal heap. The **6,000 B DMA minimum** leaves runtime reserve analysis important even though current DMA free is 26,112 B. No stack fault/watchdog or memory exhaustion is reported.
|
||||
- SSH: two successful handshakes/auth attempts, no handshake/auth/I/O/session-revocation failures. Stream RX 73 accepted, zero rejected, TX 31,941 B. Broker initial writer request was denied once; later writer snapshot shows SSH owns the lease (no inconsistency inferred from cumulative counters). Admin command-running/output-pending fields were sampled while executing status commands, not proof of a stuck dispatcher.
|
||||
- Web: 97 protected requests, 96 authenticated and one auth failure; four roots, 80 status, four tickets, eight assets; zero response errors. Four tickets issued/consumed, zero rejected/expired. Four serial connects/two disconnects; two currently active. RX one accepted frame/14 B with no rejection; TX 495 binary frames/43,738 B and 15 control frames/1,255 B. Writer requests four, grants one, denials three, releases one; no revocations. No reported send/queue/protocol/service-start/broker/connection failures.
|
||||
- Cookie auth: six password checks, three invalid-credential results, zero throttle/capacity/CSRF-or-Origin rejections, one logout, two active sessions. Successful both-role login is explicit user confirmation; the counters also show logout/reconnection activity but do not establish five cycles, account-isolation coverage or logout acknowledgement delivery. The three invalid-credential results and one protected-request auth failure are retained without attributing a cause.
|
||||
|
||||
**Reported admin-SSH empty-line issue:** pressing Enter without text prints “Command is restricted to physical UART0.” Source trace identifies `remote_command_allowed()` classifying zero parsed arguments as a policy denial. Corrected that helper to allow empty input to reach IDF's normal quiet `ESP_ERR_INVALID_ARG` handling; existing current-admin/generation checks still run, and `user bootstrap`/`user recover` remain denied. No new dispatcher, route or 8D.4 refactor. `python3 tests/admin_ssh_policy/run.py` passes 15 policy cases with the production helper and installed IDF argument parser, including quoted restricted commands. `pio run` passes in **21.04 seconds**, **95,508 B RAM / 1,625,725 B flash** (+20 B flash versus Origin fix). This SSH fix is **not yet target-tested** and was not present in the user's sample. No upload/erase/commit performed.
|
||||
|
||||
Remaining: explicit M1 sign-off, repeated lifecycle/expiry/revocation/isolation and raw-client security checks, timed full-load soak and settled cleanup, numeric reserves and non-SSH owner stack margins. Do not reopen prior phase sign-offs or invent missing execution evidence.
|
||||
|
||||
## First target sample and login blocker (historical, user-provided, 2026-09-05)
|
||||
|
||||
After clean boot and 60 seconds settled, the user reports:
|
||||
|
||||
| Heap | Free | Minimum-free | Largest block |
|
||||
|---|---:|---:|---:|
|
||||
| Internal 8-bit | 69,004 B | 66,752 B | 31,744 B |
|
||||
| Internal DMA | 61,248 B | 58,996 B | 31,744 B |
|
||||
| PSRAM | 8,223,116 B | 8,218,204 B | 8,126,464 B |
|
||||
|
||||
SSH has 0/2 sessions, all supplied error/traffic counters zero, configured stack 20,480 B and minimum-free 18,464 B. HTTPS is running/ready with no lifecycle/response errors, cookie sessions 0/4 and challenges 0/4; eight protected requests were unauthenticated, with zero password-verification attempts, CSRF/origin rejections, logouts, tickets or serial sockets at this sample point. mDNS announces `sak-1024.local`. UART1 is stopped/owner idle, configured 115200 8N1/no flow; no broker clients. USB is initialized/attached but host-open/DTR false, broker disconnected. Its reported 9600 host coding does not configure UART1. These snapshots precede the reported login attempts; they do not establish post-attempt counters. Exact flashed revision/hash was not supplied.
|
||||
|
||||
**M1 is blocked:** subsequent login attempts for a normal user and administrator both show “The sign-in challenge expired or the request was rejected. Please try again.” Browser console reports blocked inline scripts with two hashes different from the application's login script hash, denied favicon by default-src, and a denied file URL. No browser login, loaded-memory or full M1 acceptance is claimed. These errors do not establish memory exhaustion or invalid passwords.
|
||||
|
||||
The production-renderer suite was rerun and the exact shipped inline-script hash still matches its CSP (`x70ID2kbifGBVYfh/pePTt5v/AVHkT7JVAV0LjT1wCo=`). The displayed login message maps to HTTP 403 in the running script; the console's other hashes may be injected-script warnings, not a reason to broaden CSP. Request-stage/status and the bounded error code plus nonsecret Origin/Fetch Metadata are needed to isolate the rejection. No corrective firmware change has yet been made for this target report.
|
||||
|
||||
## Confirmed Origin-null diagnosis and correction (2026-09-05)
|
||||
|
||||
Follow-up user evidence: `/api/login-challenge` returns 200, `/api/login` returns 403 with request `Origin: null`, `Sec-Fetch-Site: same-origin`, and the pre-login cookie present. `web status` reports ready, zero sessions/challenges/tickets, **zero password-verification attempts** and **seven CSRF/origin rejections**. This confirms the rejection occurs before password authentication; it is not evidence of wrong credentials. No secret values were requested or retained.
|
||||
|
||||
Cause: the authored fetch requests used non-CORS `mode: 'same-origin'` under `Referrer-Policy: no-referrer`; browser Origin-header serialization for these POSTs yields `null`. Corrected login fetch options to `mode: 'cors'` and the existing app API helper to use `cors` for POST (ticket/logout), retaining same-origin mode for app GETs. Fetch CORS mode is not permission for cross-origin service access: paths remain fixed same-origin endpoints, credentials remain `same-origin`, redirects remain rejected, CSP `connect-src 'self'` remains intact, and the server's strict Origin/CSRF checks/no-CORS-response policy are unchanged. The login script hash was updated atomically to `eZO4pMDQx6SIaa5AFlMnuf0CD5JdGSWyi8lNVmCNPBQ=`; existing app loader hash is unchanged because only its external app script changed.
|
||||
|
||||
Validation: login renderer/CSP eight Node groups, app nine Node groups, and cookie-policy suite all pass. Node guards assert CORS mode for every mutation (including logout), fixed endpoint destinations and no manually supplied Origin. They do not synthesize real browser Origin headers; Firefox/target retest is still required. `pio run` passed in **14.30 seconds**, **95,508 B RAM / 1,625,705 B flash** (RAM unchanged, flash +16 B versus the preceding live build). No upload/erase. Retest both roles, serial Connect/Disconnect/reconnect and Sign out; expect login POST Origin `https://sak-1024.local` (or the actual direct-IP origin), not null. M1 remains blocked until confirmed on target; other CSP warnings were not loosened or assumed resolved.
|
||||
|
||||
## Delivered behavior
|
||||
|
||||
- `web_cookie_auth.{c,h}` replaces Basic authentication/cache completely. Both roles use `/login` and the same serial/status application. No admin shell/settings routes were added. Previously cached Basic headers do not authorize a request.
|
||||
- Four digest-only authenticated sessions retain the existing store's copied principal, canonical-origin binding, CSRF state, non-reused ID and one-hour absolute lifetime. Traffic/polling does not renew expiry. Failure to initialize authentication prevents HTTPS start; UART0/USB/SSH implementations remain unchanged.
|
||||
- Four 120-second pre-login challenges contain only token/origin digests, CSRF state and expiry. Explicit login bootstrap requires `X-Login-Bootstrap: 1`; a matching live challenge is reused without extending its lifetime or resetting its cookie. Credential submissions consume the challenge, including wrong passwords. A global fixed window permits five password verifications per 60 seconds, including successes. Further attempts return 429 with Retry-After; no HTTPD sleep or per-IP/account table.
|
||||
- Session and pre-login cookies use `__Host-sak-session` / `__Host-sak-prelogin`, `Secure; HttpOnly; SameSite=Strict; Path=/`, explicit Max-Age 3600/120 and no Domain. A consumed challenge expires its cookie; successful login additionally sets a fresh session cookie. Login with a current authenticated cookie returns 409; account switching requires logout.
|
||||
- Mutations require canonical same-origin HTTPS Origin and CSRF; upgrade requires Origin and matching cookie/session/ticket. Host case and optional default port 443 normalize; non-443 ports, malformed authorities and IPv6 literals are rejected. Direct-IP and mDNS names remain distinct cookie origins. Cross-site/same-site Fetch Metadata requests are rejected (same-origin/none accepted); absent Origin is permitted only on read/bootstrap requests after Host validation.
|
||||
- Exactly username/password string fields, maximum 512-byte login JSON, decoded 16/64-byte limits. Unknown/duplicate fields, NUL and malformed Unicode fail. Header/body/request scratch is wiped; rejected unread bodies close instead of invoking HTTPD's automatic body drain. Login reads have a three-second application deadline plus existing socket wait bounds. API authentication responses are at most 512 bytes; safe username JSON encoding is explicit.
|
||||
- Full live session/challenge/ticket tables reject with 503 and Retry-After 5; serial-ticket earliest-expiry eviction is removed. Expired/stale tickets are reclaimed without database calls under the transport lock. Existing two serial sockets, one-writer broker model and binary protocol are unchanged.
|
||||
- Logout invalidates its originating session before acknowledgement and requests only its ticket/socket cleanup. Account mutation/revocation continues to invalidate all affected account sessions, not unrelated accounts. Mint/consume/admission/input and existing periodic owner checks remain authoritative if notification fails. Zero session ID no longer falls back to Basic.
|
||||
- Browser validates `/api/session` before initial connect/reconnect/restore; stores CSRF only in memory; adds Sign out and visible absolute expiry. 401 cancels work/closes local serial/navigates once to `/login`; explicit Disconnect still pauses reconnect without ending login. 403 mutation failures require explicit retry; capacity/backoff and network errors are not bad credentials. Lost logout response is reconciled with session status rather than claiming success. Pending fetch/socket callbacks are generation-guarded. Both authored inline scripts have exact CSP hashes; generated assets were not regenerated.
|
||||
|
||||
### Route boundary
|
||||
|
||||
| Route | Policy |
|
||||
|---|---|
|
||||
| GET `/login` | Public standalone no-store login page, no protected assets |
|
||||
| GET `/api/login-challenge` | Validated Host, bootstrap header, Fetch Metadata and any supplied Origin |
|
||||
| POST `/api/login` | Strict Origin, pre-login cookie/CSRF, bounded JSON and throttle |
|
||||
| GET `/api/session` | Current cookie session; username/role/CSRF/remaining seconds only |
|
||||
| POST `/api/logout` | Current session, strict Origin/CSRF, empty body |
|
||||
| GET `/` | Current session; unauthenticated navigation gets 303 `/login` |
|
||||
| GET five `/assets/` routes; GET `/api/status` | Current session; unauthenticated gets 401, not login HTML |
|
||||
| POST `/api/ws-ticket` | Current session, strict Origin/CSRF, empty body |
|
||||
| GET `/ws/serial?ticket=...` | Cookie/Origin authorization and ticket/principal/broker admission before explicit 101 |
|
||||
|
||||
No CORS/preflight compatibility or Basic fallback. Query strings outside the exact serial-ticket route and wrong methods are rejected. Error routes have bounded no-store responses. The login document itself also rejects malformed/duplicate cookies; manually corrupted cookies may require clearing those site cookies, unlike ordinary expired well-formed cookies.
|
||||
|
||||
## Verified HTTPD boundary and maintenance risk
|
||||
|
||||
The delivered solution is **not the previously proposed SDK patch**. `web_httpd_adapter.{c,h}` alone includes installed HTTPD private headers. `src/CMakeLists.txt` supplies private include paths; the adapter fails compilation unless `ESP_IDF_VERSION == 5.5.0`. No installed SDK source was changed and no full component was vendored.
|
||||
|
||||
Verified under `/home/mscholz/.platformio/packages/framework-espidf/components/esp_http_server/`:
|
||||
|
||||
- `src/httpd_parse.c`, `httpd_req_get_hdr_value_len/str`: return the **first** matching header only. Parsed fields occupy NUL-separated scratch, not a raw CRLF block. Adapter walks that bounded storage and rejects **all duplicate field names**, case-insensitively, plus malformed fields, control characters, Transfer-Encoding and Expect. This is stricter than general HTTP acceptance, deliberately fail-closed. Public getters are called only after validation and with terminator capacity.
|
||||
- `src/httpd_txrx.c`, `httpd_resp_set_hdr`: appends pointers, does not replace an earlier same-name field. Sending emits each entry; login retains its two cookie values until send returns. Success uses exactly **six of eight additional-header slots**. Tests extract the installed getters and append function rather than inventing their behavior.
|
||||
- `src/httpd_uri.c`: routes marked `is_websocket=true` send 101 before their handler. The application's serial URI is deliberately registered as an ordinary GET. After cookie/Origin checks, transport consumes the matching ticket and completes currentness/broker admission, then adapter calls `httpd_ws_respond_server_handshake()` and installs the existing transport frame handler. Failed pre-admission never sends 101; handshake/admission failure uses existing reserved-slot/broker cleanup. Tests stub the handshake send: real on-wire integration remains a target gate.
|
||||
- `src/httpd_txrx.c`, `httpd_unrecv/httpd_recv_pending`: pending bytes are **right-aligned**. The inherited adapter incorrectly wiped the unread suffix. This continuation fixes cleanup to wipe the consumed prefix while preserving unread bytes at the end, or wipe everything when closing. Regression exercises all 0–128 pending lengths and partial reads through the installed reader function. This prevents corruption of pipelined requests/early serial frames; it is not a claim of real socket execution.
|
||||
- HTTPD DEBUG logs include headers, and URI warnings can include ticket queries. HTTPD is compiled with `LOG_LOCAL_LEVEL=ESP_LOG_ERROR`; ERROR sites were inspected for secret-bearing content. This deliberately removes HTTPD warning/debug diagnostics regardless of runtime log-level changes. Application count-only authentication telemetry remains available via `web status`/`web counters`.
|
||||
|
||||
Private layout, frame dispatch and scratch ownership must be re-audited for an SDK update, including same-version local source patches (the guard checks the version, not source hashes). Do not distribute private-structure access into other application modules. Wiping reduces request lifetime, not all TLS/allocator/browser copies; do not claim resistance to RAM extraction.
|
||||
|
||||
## Resource accounting
|
||||
|
||||
Final `pio run` passed in **17.62 seconds** after the cleanup fix:
|
||||
|
||||
| Metric | 8D.2 / preparatory baseline | Live 8D.3 | Increment |
|
||||
|---|---:|---:|---:|
|
||||
| Linked static RAM | 95,260 B | 95,508 B | +248 B |
|
||||
| Reported program flash | 1,601,925 B | 1,625,689 B | +23,764 B |
|
||||
|
||||
Cumulative versus recorded 8D.0 build (94,532 / 1,599,973 B): **+976 B RAM / +25,716 B flash**. These are linked sizes, not runtime headroom.
|
||||
|
||||
- Target object symbol accounting: challenges **576 B (144 × 4)**, counters 32 B, lock 8 B, epoch 8 B, window 8 B, attempts 4 B, ready 1 B: **637 B before placement padding**. Removed Basic cache/key/readiness offset most of this; final link delta includes alignment/other changes. Existing session store remains present.
|
||||
- No new application task, task-stack size change, module heap allocation, queue, TLS buffer, accepted socket or lwIP descriptor limit. HTTPD URI capacity rises **9 → 14**, with five additional dynamically allocated handler records; HTTPD error handlers use its existing table. Six HTTPS clients and two web serial slots remain unchanged. LRU purge remains enabled; retained-serial admission protection is still an M2 concern.
|
||||
- Auth request locals include 513 B body/response scratch, 180 B cookie header, token/CSRF/canonical buffers, copied session/principal/challenge/credentials; cookie parsing has nested 1025 B header scratch. No task-stack reserve is inferred from source locals or static link size. Existing HTTPD stack is 10,240 B; real worst-case stack/TLS/PBKDF2/fragmentation measurements remain pending.
|
||||
- Existing xterm/logo data unchanged. Login page and enlarged authored app are now actually linked; their dormant-preparation flash numbers were not their live cost. Header slots remain eight; login success six, login renderer five.
|
||||
|
||||
## Executed validation
|
||||
|
||||
All ran successfully in this continuation:
|
||||
|
||||
```sh
|
||||
python3 tests/web_cookie_auth/run.py
|
||||
python3 tests/web_auth_parse/run.py
|
||||
python3 tests/web_login_ui/run.py
|
||||
python3 tests/web_ui_session/run.py
|
||||
python3 tests/web_session_store/run.py --serial
|
||||
pio run
|
||||
```
|
||||
|
||||
- Cookie policy suite compiles production store/parser/policy/adapter with OpenSSL SHA-256 and deterministic database/HTTPD doubles. Covers fragmented reads, challenge reuse/consumption/expiry/capacity, session-specific logout, throttle, duplicate headers/cookies, methods/Origin/CSRF/Fetch Metadata, Basic denial, currentness, failures/stop race, cookie header budget and explicit upgrade state. Installed IDF getter/setter/pending-reader functions are extracted verbatim. It does **not** execute the full IDF parser, TLS, URI dispatcher, network handshake or real tasks.
|
||||
- Parser suite: **268 cases**. Login renderer: production C failure/header checks and **eight Node groups**. Serial app: production C resource/header/CSP checks and **nine Node groups**. Node VM DOM/fetch doubles are not a real browser/CSP/bfcache test.
|
||||
- Serial integration mode includes store public-API tests plus transport binding/isolation/races and no Basic/no live-ticket eviction. No sanitizer pass is claimed.
|
||||
|
||||
No upload, erase, commit, branch change or target/browser exercise was performed. The preceding agent's changes were preserved except the focused pending-buffer fix/tests; its unrecorded executions are not evidence here.
|
||||
|
||||
## M1 target acceptance handoff — stop before 8D.4
|
||||
|
||||
Use the complete [M1 contract/checklist](phase8d_baseline.md#minimal-m1-browser-contract-planned) and [user acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration). At minimum:
|
||||
|
||||
1. Keep UART0 attached. Record flashed revision/configuration and settled-boot `memory`, `web status`, `web counters`, `broker clients`, `ssh status`. Confirm native USB and both SSH roles survive HTTPS stop/start and authentication failures.
|
||||
2. Test both roles, fresh and previously Basic-authenticated profiles, direct IP and mDNS. Wrong credentials, refresh/back, expiry/reboot, sign out/account switch and lost logout response must remain usable. Verify actual secure cookie attributes and CSP; never include raw cookies/CSRF/tickets/passwords in shared evidence.
|
||||
3. Five login/serial-disconnect/reconnect/logout cycles per role; five HTTPS stop/start cycles. Check session-specific logout across two sessions of the same account, and account password/role/key changes/deletion/recreation via UART0 while unrelated sessions survive.
|
||||
4. Challenge/session/ticket capacity without eviction; bounded throttle and retry. Raw-client missing/malformed/duplicate Origin/Host/Cookie/CSRF/content-type/framing tests. Verify an unauthorized or mismatched-ticket upgrade gets **no 101**; validate actual frame routing, early/pending bytes and close cleanup after admitted upgrades. These are especially important for the private adapter.
|
||||
5. Fifteen-minute full-client mix at 115200 baud (USB, two web serial clients, user SSH and admin SSH), then 60-second cleanup. Record internal/DMA/PSRAM free/minimum/largest block plus SSH stack margins at boot/login/serial/load/cleanup. Check binary integrity, writer isolation, drops and watchdogs. Measure the planned ≤1-second expiry/revocation detection target under contention separately from socket-close delivery.
|
||||
6. Numeric reserve floors and non-SSH owner-stack instrumentation remain pending. Obtain explicit M1 sign-off before adding the browser admin shell. Do not equate host tests/build success with target acceptance.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Phase 8D.4 - Small Console Boundary
|
||||
|
||||
Status (2026-09-06): **Implemented / host-tested / build-verified / Phase 8D.4 validated by explicit user sign-off.** The user confirmed successful empty Enter and soak testing after the boot/full-client-mix evidence. M1 and previous phase sign-offs stand. Numeric reserve gates remain open. No 8D.5 implementation or browser admin route is included.
|
||||
|
||||
## Validation Sign-Off (2026-09-06)
|
||||
|
||||
The user explicitly requested: "Mark Phase 8D.4 as validated - conforming empty enter and the soak test successful". This closes 8D.4 and the previously pending admin-SSH empty-Enter target retest, superseding older pending/in-progress statements in project records.
|
||||
|
||||
Soak duration, exact flashed revision, post-soak/cleanup telemetry and individual results for other detailed checklist items were not supplied. These remain evidence limitations, not blockers to this user-approved closure or claims that unreported tests were executed. No new agent build or device operation was performed for sign-off. Numeric reserves remain open. Next is **8D.5 only on a separate implementation request**; M2 as a whole is not yet complete.
|
||||
|
||||
## Scope And Contract
|
||||
|
||||
Starting revision: `f9ee6eec9cbe06fe5120ee718507eaffd69787a2`, initially clean worktree. Changed production files are `src/admin_ssh_console.{c,h}` and `src/ssh_transport.c`; focused tests are in `tests/admin_console_boundary/`. No console-input changes were necessary because its prompt routing already uses the shared dispatcher.
|
||||
|
||||
- `admin_ssh_console_open_owned()` accepts a copied transport-qualified token/principal and an immutable firmware-lifetime owner adapter. Existing SSH admission remains available through `admin_ssh_console_open()`, now implemented beside its owner in `ssh_transport.c`.
|
||||
- The adapter supplies nonblocking application-output drain checks and deferred lifecycle handling, called by the existing control task outside console locks. SSH validates slot/session/generation and uses existing transport control APIs, never runtime wolfSSH calls from the control task.
|
||||
- Two console slots remain shared, not two per transport. Occupied and still-executing slots reject replacement. Future admission must coordinate this pool; this phase does not promise simultaneous browser and full SSH admin capacity.
|
||||
- The token includes transport, slot, session ID and generation. Owners serialize input per session, exclusively consume output, maintain transport authentication/expiry, and close on disconnect/revocation. The core retains admission/dispatch principal checks and queued-work identity checks.
|
||||
- Completion scratch has a nonblocking claim so different owners cannot race the shared buffer outside the lock. A competing TAB remains unconsumed for retry; stale completion cannot publish into a reused slot.
|
||||
- Shell exit uses an owner-relative `SELF_CLOSE` action; existing STOP/DISCONNECT/key actions still mean SSH. Unsupported actions fail before side effects. Deferred input is rejected at both admission and feed, and identity is checked again after the final delay.
|
||||
- The same dispatcher, canonical registry, queue, editor/history/prompts, UART0 policy and output ring remain. Five-second output backpressure and ten-second deferred drain plus 200 ms remain bounded best-effort heuristics, not delivery confirmation.
|
||||
|
||||
No new tasks, browser endpoints, UI changes, serial/broker changes, generated assets, persistence changes, uploads, erases or commits.
|
||||
|
||||
## Verification And Resources
|
||||
|
||||
- `python3 tests/admin_console_boundary/run.py`: PASS for production console and extracted production SSH adapter with deterministic host fakes. Covers readiness/admission, cross-transport and stale identities, completion contention and close/reopen during completion, history, queued disconnect/revocation, UART dispatch, executing cleanup, hidden/visible/cancelled/disconnected prompts, exit-to-SELF_CLOSE routing, unsupported actions, queue rejection, drain timeout/delay, SSH action routing and slow output.
|
||||
- `python3 tests/admin_ssh_policy/run.py`: PASS, including quiet empty input and physical-only command restrictions.
|
||||
- Independent code review found no defects. `git diff --check`: PASS.
|
||||
- `pio run`: PASS in **43.61 seconds**, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0, N16R8 release. Linked RAM **95,084 B**, flash **1,627,173 B**. Against the recorded latest 8D.3 build (95,508 / 1,625,725 B): **-424 B RAM / +1,448 B flash**. Against recorded 8D.0 (94,532 / 1,599,973 B): **+552 B RAM / +27,200 B flash**. These comparisons use recorded builds, not a fresh baseline rebuild.
|
||||
|
||||
Static savings come from removing the full SSH snapshot scratch in the console and reading only the matching published slot under the SSH lock. Added costs are transport tags, owner pointers in sessions/control requests, the immutable adapter, and one completion-claim flag. No payload buffer or capacity increase. Two 4 KiB output rings, four-entry command queue, two-entry control queue, four-line per-session history and task stack sizes (dispatcher 12 KiB, UART frontend 6 KiB, control 4 KiB) are unchanged. No new module heap allocation, socket, TLS connection, HTTP handler, web session or ticket capacity. Runtime heap, PSRAM and stack margins were not measured; no reserve approval is inferred from linked RAM.
|
||||
|
||||
Host fakes do not establish actual FreeRTOS scheduling, task-local stdio redirection, real queue capacities, socket behavior or hardware regression. The command runner and lifecycle operations are doubled; direct production helpers test routing and output separately. Sanitizer execution was unavailable because the host lacks the required runtime library, not a sanitizer pass.
|
||||
|
||||
## Target Handoff
|
||||
|
||||
### User-Provided Boot And Full-Client-Mix Evidence (2026-09-06)
|
||||
|
||||
The user supplied UART0 transcripts labeled clean boot and full client mix after the 8D.4 implementation. Exact flashed revision, settling interval, traffic duration and byte-integrity comparison were not supplied. These are user target observations, not agent device execution or phase sign-off.
|
||||
|
||||
| Resource (bytes) | Boot free | Boot minimum | Boot largest | Loaded free | Loaded minimum | Loaded largest |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| Internal 8-bit | 71,512 | 60,344 | 31,744 | 34,632 | 20,648 | 25,600 |
|
||||
| Internal DMA | 63,756 | 52,588 | 31,744 | 26,876 | 12,892 | 25,600 |
|
||||
| External PSRAM | 8,247,940 | 8,241,988 | 8,126,464 | 8,089,284 | 8,077,516 | 7,995,392 |
|
||||
|
||||
SSH owner stack: 20,480 B configured; minimum-free **18,472 B boot / 16,280 B loaded**. Heap minimum-free values are conservative sums of matching regions' lifetime minima; internal 8-bit and DMA are overlapping capabilities, not additive pools.
|
||||
|
||||
- Boot: HTTPS/SSH initialized and running with ESP_OK, zero active SSH/cookie/WebSocket sessions and no broker clients. Startup failure and traffic counters zero. mDNS initialized/announced. UART service stopped with 115200 8N1/no flow configured; USB attached but host closed, DTR/RTS false and no broker client. Diagnostic USB host coding 9600 did not change UART1 configuration.
|
||||
- Loaded: UART service running at **115200 8N1/no flow** with no pending RX/TX in the sample. Four broker clients: SSH **8 sole writer**, USB **9 observer**, web **10/11 observers**. Two public-key SSH sessions active, one user/broker route and one admin/console route. Admin admission successful, idle, zero output and no RX/TX pending. USB host open with DTR/RTS true.
|
||||
- Both browser roles logged in successfully: two cookie sessions and two serial WebSockets, no outstanding tickets/challenges. Two login attempts, zero invalid credentials, throttle/capacity/CSRF/Origin rejections or logouts. HTTPS/SSH/mDNS still running/announced with ESP_OK.
|
||||
- SSH: two successful handshakes, zero handshake/auth/admission/I/O failures and zero admin input backpressure. Stream RX/accepted **13/13 B**, rejected **0**, TX **8,235 B**. Cumulative broker revocations **1** is retained without diagnosis; the current snapshot still shows SSH as sole writer and no session revocations.
|
||||
- Web: two tickets issued/consumed and two successful WebSocket connects; zero response, service-start, broker, send, queue, protocol or close failures. TX **105 binary frames / 15,760 B**, **5 control frames / 398 B**; no serial RX frames. Both writer requests denied while SSH owns the lease, consistent with observer admission rather than a transport failure.
|
||||
|
||||
Compared with the earlier 8D.3 mixed-load sample (not its different post-soak sample), current free internal/DMA memory is **764 B higher** and PSRAM **224 B higher**. Internal/DMA minima are **6,892 B higher** and PSRAM minimum **4,772 B higher**; largest blocks are unchanged. SSH stack minimum-free is **8 B lower**. Different transient histories prevent attributing these runtime deltas solely to the refactor or using them as reserve/leak proof.
|
||||
|
||||
These snapshots alone support startup, both-role login and concurrent transport admission/data activity, not detailed console regressions or soak/cleanup. The subsequent explicit sign-off above confirms successful empty Enter and soak testing and closes 8D.4; unreported checklist details remain evidence limitations.
|
||||
|
||||
The original target checklist below is retained for regression coverage, not as an outstanding gate to the signed-off phase:
|
||||
|
||||
1. Boot and capture UART0 `memory`, status and `ssh status`. Verify UART0 recovery and empty Enter/normal commands through admin SSH (empty Enter is now confirmed by user sign-off).
|
||||
2. Exercise UART0/admin-SSH serialization, completion/history, visible/hidden/cancelled prompts, and disconnect/revocation while work is queued or a prompt is active. Check reconnect/slot reuse does not receive old output.
|
||||
3. Exercise slow-reader backpressure and recovery, `exit`, empty-line Ctrl+D, and the existing deferred SSH stop/disconnect/key/reboot paths as appropriate. Confirm acknowledgement remains best effort and stale sessions cannot act on reused identities.
|
||||
4. Run the always-on browser login/serial explicit disconnect/reconnect, USB UART1 and user/admin SSH smoke. Repeat five serial lifecycle cycles per role and collect comparable settled/full-client-mix/cleanup `memory` and SSH stack telemetry at the supported 115200-baud workload.
|
||||
|
||||
No target checks above were executed by the agent. Exact next chunk is **8D.5: bounded admin WebSocket backend**, only when separately requested after the applicable validation decision.
|
||||
@@ -0,0 +1,146 @@
|
||||
# Phase 8D.5 — Admin WebSocket backend
|
||||
|
||||
Status (2026-09-06): **8D.5 backend implemented / host-tested / build-verified / validated by explicit user sign-off.** The user supplied settled cold-boot telemetry and reported a successful 15-minute full-client-mix active-use soak at 230400 baud, with a few broker drops under heavy output, and explicitly closed 8D.5. Final build: **23.55 s**, **95,580 B RAM / 1,637,273 B flash**. No 8D.6 UI work or M2 acceptance. Prior 8D.4/M1 sign-offs stand; numeric resource reserves remain open.
|
||||
|
||||
## Target Sign-Off (2026-09-06)
|
||||
|
||||
User reports: "With full client mix, running and active use for 15 mins, soaked, only a few dropped broker packets at 230400 baud with extremely fast and dmesg output. Mark 8D.5 as validated."
|
||||
|
||||
This is explicit phase acceptance and supersedes older pending/incomplete validation statements below and in project memory. The reported broker drops are preserved, not treated as zero-drop or byte-integrity evidence; no cause, exact count or affected client was supplied. This 230400-baud workload is distinct from earlier 115200-baud samples. No baud-rate or capacity reduction is made.
|
||||
|
||||
Settled cold-boot UART0 measurements supplied with sign-off:
|
||||
|
||||
| Heap (bytes) | Free | Minimum-free | Largest block |
|
||||
|---|---:|---:|---:|
|
||||
| Internal 8-bit | 70,876 | 59,560 | 31,744 |
|
||||
| Internal DMA | 63,120 | 51,804 | 31,744 |
|
||||
| External PSRAM | 8,246,360 | 8,242,140 | 8,126,464 |
|
||||
|
||||
SSH owner stack: **20,480 B configured / 18,472 B minimum-free**. Internal/DMA capabilities overlap; their free bytes are not additive. Minimum-free is the firmware's conservative sum of matching heap regions' lifetime minima.
|
||||
|
||||
- HTTPS and SSH initialized/running with ESP_OK, each with one successful start and zero startup failures. mDNS initialized/announced with ESP_OK.
|
||||
- Admin backend initialized/attached with ESP_OK, no active admin socket or tickets, and all admin counters zero. Reported transport static/ticket/PSRAM payload storage **167 / 240 / 1,552 B**, matching implementation accounting.
|
||||
- No SSH, cookie or serial WebSocket sessions, challenges, tickets or broker clients. Web/SSH traffic/authentication/failure counters zero at boot; this does not describe post-soak counters.
|
||||
- UART service stopped, RS-232 owner idle, configuration **230400 8N1/no flow**, RX/TX pending zero. USB initialized/attached but host closed with DTR/RTS false and no broker client. Diagnostic 9600 host line coding does not reconfigure UART1.
|
||||
|
||||
Exact flashed revision, settling duration, loaded/post-soak/cleanup memory and counters, individual client identities and detailed checklist results were not supplied. The full client mix and 15-minute successful soak are user-reported, not reconstructed from the idle boot sample. Missing details remain evidence limitations, not blockers to user-approved phase closure or claims of unreported test execution. Runtime per-admin-socket cost and numeric reserve approval remain open. No agent build, device operation or source change was performed to record sign-off. **Next is 8D.6 only on a separate request; M2 remains incomplete.**
|
||||
|
||||
## Combined backend completion
|
||||
|
||||
The user explicitly authorized finishing the entire interrupted 8D.5 implementation, superseding the prerequisite pause below. Extensive uncommitted source/tests were preserved: ticket store, transport, protected server routes, shared-console allocator and SSH mapping, command restrictions, revocation integration, diagnostics, transport regressions, lifecycle harness and local smoke client. This continuation reviewed them, added real authenticated endpoint integration tests and fixed the final admin socket close/reuse race. No upload, erase, commit, generated asset or normal UI change.
|
||||
|
||||
### Admission and protocol
|
||||
|
||||
- `POST /api/admin/ws-ticket` runs the existing strict cookie/Origin/CSRF mutation policy, then admin-role validation. Two digest-only, non-evicting, single-use tickets expire after 30 seconds and bind the originating session ID and full current password principal. Crypto/database checks are outside critical sections; epoch/generation checks reject stale publication, consumption and prune work.
|
||||
- `GET /ws/admin?ticket=<64 hex>` is an ordinary HTTP route, not an automatic WebSocket route. Cookie, strict Origin, current admin role, exact ticket shape/consumption, one transport-slot reservation and a free shared console slot precede explicit 101. Upgrade has no CSRF header requirement: the CSRF-protected ticket plus cookie/Origin authorizes it, including browser clients that cannot add custom WebSocket headers. Rejections never execute console commands.
|
||||
- Exactly one admin socket, two admin tickets and the existing two shared console slots. SSH now retains its allocated console index separately from the physical SSH slot and resolves published owner state by full identity. Busy/executing console slots cannot be replaced. Admin does not join the serial broker or obtain a writer lease.
|
||||
- Final unfragmented binary frames carry console input (maximum 512 bytes); binary output chunks are at most 1024 bytes. Text, fragmented, oversized and overlapping pending input fail closed. Partially consumed input has a five-second deadline, checked before retry. Consumed input/output and retired payload/console state are wiped. Empty binary frames must not invoke IDF's zero-length header probe twice.
|
||||
- Saturating lifetime admin counters and allocation sizes are available through `web status`/`web counters`, without token, CSRF, verifier or private-key disclosure. `web clear-counters` does not reset admin counters; diagnostics say so.
|
||||
|
||||
### Ownership and failure isolation
|
||||
|
||||
- One permanent 20 ms ESP timer schedules at most one HTTPD poll. It does no database, console, payload or socket work. No new application task/stack/dispatcher is created. Blocking HTTPD queue-work configuration makes the optional admin initializer fail closed.
|
||||
- HTTPD exclusively owns admission, frame input, payload mutation, output sends and session-context cleanup. Revocation/control callers only flag closure and close the generation-qualified console token. Authoritative session/principal checks guard admission, dispatcher execution/prompts, input, output and idle polls. These checks cannot roll back arbitrary already-running commands.
|
||||
- Detach disables admission/tickets and console access before fencing timer submissions for up to two seconds. Timeout retains the live HTTPD handle and requires a stop retry. Failed SSL stop retains admin ownership; only successful HTTPD stop permits clearing queued-work state and reattachment. Queued polls after detach do no IO; successfully stopped HTTPD cannot execute discarded work.
|
||||
- **Final lifecycle fix:** installed IDF 5.5.0 `httpd_sess_trigger_close()` queues a raw reusable `sock_db *`. A poll could queue closure, then a frame error free that slot and acceptance reuse it before the queued close executes, potentially closing an unrelated serial client. Admin polling now calls `shutdown(fd, SHUT_RDWR)` directly on HTTPD after checking its session context. HTTPD's next read owns deletion; there is no late queued close pointer. Failed shutdown retries on later polls, with send-failure accounting. This deliberately does not promise a graceful WebSocket close frame or peer delivery. The existing serial transport's use of IDF queued close was not changed; the new admin path cannot introduce this eviction route.
|
||||
- HTTPS retains six client sockets, now with LRU purge disabled, and grows from 14 to 16 URI handlers. Full socket capacity can delay/refuse new HTTP/TLS connections rather than evict a retained serial writer. Optional admin allocation/registration failure preserves M1 routes and serial attachment; failed optional-ticket unregister leaves an authenticated but unattached/unavailable ticket handler, not a bypass.
|
||||
- Logout invalidates its cookie session before serial/admin ticket/socket cleanup; account/global revocation follows the same order through the existing `web_serial_transport_revoke_*` integration hooks. Lost notifications still fail session/principal currentness. Unrelated session notifications do not close the admin socket.
|
||||
|
||||
### Temporary command restrictions
|
||||
|
||||
The parsed canonical command policy rejects unsupported actions before `esp_console_run()`, not after a handler has mutated configuration. From web: only `web status`, `wifi status`, `mdns status`; only bare `user`, `user status`, `user list`, `user show <name>` in the user group; no `reboot`, SSH stop/disconnect/reset or SSH host-key action except `ssh host-key info`. Thus web/network identity changes, all account mutations and one-time generated credentials remain unavailable here until the later lifecycle phase. Ordinary permitted commands, empty Enter and `exit`/empty-line Ctrl+D use the existing dispatcher/editor. Only owner-relative deferred self-close is supported by WEB. UART0 bootstrap/recovery remains physical-only; SSH policy otherwise remains unchanged. See the policy suite for quoted forms.
|
||||
|
||||
## Final local validation
|
||||
|
||||
All commands below were executed in this continuation and passed. Host compiler warnings are errors; tests are deterministic dependency interleavings, not real multicore execution.
|
||||
|
||||
| Command | Actual result |
|
||||
| --- | --- |
|
||||
| `python3 tests/web_admin_transport/run.py --tickets` | 19 transport groups plus 12 ticket groups; rerun after shutdown fix |
|
||||
| `python3 tests/web_admin_transport/server_lifecycle.py` | 11 groups, including all 16 required registration failure positions, two optional positions, failed unregister, failed stop/retry and six-socket/no-LRU configuration |
|
||||
| `python3 tests/web_cookie_auth/run.py --admin` | Real cookie policy, parser, store, tickets, transport and private adapter linked together; endpoint rejection before 101, cross-session replay burn, admission, isolated logout, missed account revocation, expiry and restart; rerun after fix |
|
||||
| `python3 tests/web_cookie_auth/run.py` | Existing cookie/HTTPD policy and embedded store regressions pass |
|
||||
| `python3 tests/admin_console_boundary/run.py` | Shared two-owner allocation, production SSH publication/mapping, queued currentness, prompts, deferred actions, history/completion and wiping pass |
|
||||
| `python3 tests/admin_ssh_policy/run.py` | SSH policy and browser restrictions using installed IDF parser pass |
|
||||
| `python3 tests/web_session_store/run.py` | Store API/failure/race suite passes with OpenSSL SHA-256 |
|
||||
| `python3 tests/web_session_store/run.py --serial` | Serial/session binding, revocation, races and non-eviction regressions pass |
|
||||
| `python3 tests/web_auth_parse/run.py` | 268 cases, zero failures |
|
||||
| `python3 tests/web_login_ui/run.py` | C/header/CSP checks and eight browser-behavior groups pass |
|
||||
| `python3 tests/web_ui_session/run.py` | C/header/CSP checks and nine browser-behavior groups pass |
|
||||
| `python3 tests/web_admin_transport/client.py --help` | Local import/CLI smoke only; no network/device operation |
|
||||
| `git diff --check` | Pass |
|
||||
|
||||
The combined endpoint harness doubles console execution/IO and the logout revocation hook (matching reviewed production ordering); the console harness separately runs real shared-console code. Installed HTTPD getter/setter/pending-reader functions are extracted, but TLS, handshake writes, actual HTTPD event processing and FreeRTOS are doubled. The server lifecycle harness extracts production lifecycle/table code, not live HTTPD. No sanitizer run/pass is claimed in this continuation; inherited harness notes record missing ASan/UBSan libraries. Manual client offline evidence in its README is inherited, not rerun here beyond `--help`.
|
||||
|
||||
### Firmware and resources
|
||||
|
||||
**Final build, parent-reported:** the necessary sequential `pio run` after the shutdown fix **passed in 23.55 seconds**, reporting **95,580 B linked RAM / 1,637,273 B flash** on the existing PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0, N16R8 release configuration. This verifies the final source, including the shutdown fix. Parent also reports the final independent security integration review found **no actionable findings**. This documentation-only follow-up ran no build or tests.
|
||||
|
||||
Historical build: the continuation's one 120-second-bounded `pio run` passed in **22.67 seconds**, at **95,580 B RAM / 1,637,277 B flash**, before the shutdown fix. Both affected production-C host suites passed after the fix; the parent's subsequent final build supersedes that earlier image for final-source verification and saves 4 B flash with unchanged linked RAM.
|
||||
|
||||
Final-image deltas (baselines not rebuilt):
|
||||
|
||||
| Baseline | RAM delta | Flash delta |
|
||||
| --- | ---: | ---: |
|
||||
| 8D.5 prerequisite: 95,164 / 1,628,049 B | +416 B | +9,224 B |
|
||||
| 8D.4: 95,084 / 1,627,173 B | +496 B | +10,100 B |
|
||||
| 8D.0: 94,532 / 1,599,973 B | +1,048 B | +37,300 B |
|
||||
|
||||
Target ELF/DWARF/map inspection, with no device access: admin payload **1,552 B PSRAM-only** (512 RX + 1024 TX + 16 metadata), slot **80 B**, ticket **96 B** x two, ticket state **232 B** + lock **8 B** = **240 B**. Transport static symbols total **167 B** before placement padding (168 B occupied); retained timer handle is included there. IDF `struct esp_timer` is **32 B**, allocated with internal/8-bit capabilities, excluding heap metadata. Payload/timer persist across HTTPS restarts; PSRAM allocation has no internal fallback. SSH adds two console-index bytes in published state and retains the prerequisite's 80 B principal copies. No added console rings, queue capacities or task stacks. Dynamic TLS/socket/request allocations, heap fragmentation, HTTPD/dispatcher/timer stack margins and internal/DMA reserves remain unmeasured; these static figures are not per-socket runtime cost or reserve approval.
|
||||
|
||||
## Target Regression Procedure
|
||||
|
||||
No target/network exercise was performed by the agent. The user's target sign-off is recorded above; this original checklist is retained as regression coverage, not as outstanding gates to that closure. Use the already present bounded, stdlib-only `tests/web_admin_transport/client.py`; full usage/security caveats are in its README. It prompts for credentials without echo, keeps cookies only in memory, never logs tickets/CSRF/credential metadata, and attempts logout in `finally`. Prefer trusted certificate/hostname validation; `--insecure` is explicit test-only exposure to active interception, not a local-routing guarantee. Its console output is intentionally raw terminal output: use a trusted target and do not record secret-bearing command output.
|
||||
|
||||
```sh
|
||||
python3 tests/web_admin_transport/client.py --url https://device.local --cafile device-cert.pem --smoke --max-runtime 60
|
||||
```
|
||||
|
||||
1. Have the operator flash the final build-verified image through the usual approved procedure. Record exact revision/diff, clean-boot and 60-second settled `memory`, `web status`, `ssh status` and broker/serial counters. Build verification does not establish target acceptance.
|
||||
2. Run the smoke client separately with disposable role-user and role-admin accounts. Require user ticket 403; admin cookie/ticket/101, same-ticket replay 403, `help`, empty frame, empty Enter and `exit`, then logout and session 401. Repeat five times per role. No automatic credential retry; respect the five/60-second throttle.
|
||||
3. Keep two browser serial sockets, USB and role-user SSH at 115200 baud, with a known sole writer; concurrently admit one admin SSH plus web admin. Verify the same broker client IDs/writer before and after web admin open/exit/failure. Attempt a second admin socket and fill both console slots with SSH before web admission: reject, never replace. Fill remaining HTTPS sockets; no serial eviction. Capture live TLS/heap cost rather than infer it from six configured sockets.
|
||||
4. With a temporary authenticated development client (not a firmware endpoint), test missing/foreign/null/duplicate Origin, missing/duplicate cookie, absent/wrong CSRF on ticket POST, expired/wrong-session/replayed tickets and direct user-role upgrade. Require rejection before any 101. Check raw responses without publishing auth headers or ticket URLs. The supplied smoke client only automates the documented subset, not this full negative matrix.
|
||||
5. Exercise shared command serialization with UART0 and admin SSH, completion/history, visible/hidden/cancelled prompts, disconnect/revoke/expiry while queued or prompting, and slow input/output. Use disposable secrets and approved existing non-restricted commands; do not type secrets into a retained browser developer-console history. Unsupported web lifecycle/account mutations must report rejection before any state change. The supplied smoke client is not interactive and does not claim prompt/completion coverage.
|
||||
6. Logout one session with serial+admin; only that session's sockets/tickets close. Change its disposable account from UART0/admin SSH, test deletion/recreation and let a session reach its one-hour absolute expiry. Verify unrelated sessions, queued-command rejection, no stale prompt/output after slot reuse, and no lingering reserved console slot after an executing handler returns.
|
||||
7. From UART0, repeat five HTTPS stop/start cycles with active admin and pending output; inject detach/queue/SSL-stop failures where feasible, retry stop and ensure no handle reuse until successful stop. Stress simultaneous peer disconnect and new serial admission during admin closure, specifically validating the shutdown/reuse fix. USB and UART0 must remain usable if HTTPS is unavailable.
|
||||
8. Run at least a 15-minute full-client-mix/slow-reader soak, collect free/minimum/largest internal/DMA/PSRAM and available stack telemetry, disconnect all optional clients, wait 60 seconds and compare cleanup figures. Numeric reserve floors and real per-admin-socket cost still need approval/evidence. Stop before 8D.6; M2 also requires separately requested 8D.6/8D.7 work.
|
||||
|
||||
## Historical prerequisite record
|
||||
|
||||
The sections below record the earlier prerequisite-only checkpoint. Their no-backend statements and request to pause were superseded by the combined backend authorization/results above; their old build measurements are retained as provenance.
|
||||
|
||||
## Scope and provenance
|
||||
|
||||
Resumed at revision `e5dce12ed43154dacd086437de0f2d156014d58c` with existing uncommitted prerequisite changes in `src/admin_ssh_console.{c,h}`, `src/ssh_transport.c`, and `tests/admin_console_boundary/`. Preserved and reviewed that work, extended the harness to exercise production SSH snapshot/principal publication and wiping, ran both console suites and the firmware build, and recorded the handoff.
|
||||
|
||||
The plan's work-unit review splits the full ticket store, socket owner, HTTP policy/routes and integration tests from this runtime-changing prerequisite. No browser route, ticket store, new task, UI entry, broker client, generated asset, persistence change or device operation is included. No commit or branch change was made.
|
||||
|
||||
## Implemented contract
|
||||
|
||||
- The immutable console owner adapter now requires `is_current(token, principal)`. It runs on the dispatcher outside console locks, validates full transport identity and originating-session/principal binding, and must not call socket libraries or console handlers. Admission and transport input/output liveness remain owner responsibilities; admission need not already be published to this callback.
|
||||
- Core checks account and owner currentness for queued work, again immediately before the canonical command runner, and after dispatch. Identity/owner are rechecked after external calls so late validation cannot close a replacement slot. UART0 remains independent.
|
||||
- Visible and hidden prompts check currentness before publishing and after each wait. Waits poll at 250 ms plus validation/scheduling latency, not a hard real-time deadline; stale semaphore wakes cannot submit a still-waiting prompt. Revoked submitted replies are not returned to handlers. Close wipes prompt input immediately, including submitted input; executing session storage remains reserved until handler cleanup.
|
||||
- SSH publishes two copied principals under the same lock as its snapshots. Its dispatcher adapter validates active authenticated admin route, transport/session/generation, close intent and full principal binding, without reading owner-task slots or calling wolfSSH. Consuming an external close preserves published close intent until cleanup.
|
||||
- Reading console output wipes consumed ring segments, including wraparound, while retaining unread output.
|
||||
|
||||
These checks do not cancel or roll back arbitrary executing handlers, nor make authorization atomic with subsequent side effects. Database currentness can wait on its mutex. A future browser owner must still enforce cookie-session expiry/logout/revocation at admission, input, output and periodic cleanup; console polling is not a replacement for transport cleanup.
|
||||
|
||||
## Executed validation and resources
|
||||
|
||||
- `python3 tests/admin_console_boundary/run.py`: PASS. Production console plus extracted production SSH token/publication/adapter code; covers stale owner with current account, unrelated-session isolation, close/reuse during validation, invalidation immediately before execution, revoked/disconnected submitted prompts, unanswered prompt expiry without notification, stale wakes, UART0 recovery, output wiping, published principal cleanup, route/auth/principal/identity rejection and external-close handoff. Existing completion/history, prompts, deferred control and backpressure regressions also pass.
|
||||
- `python3 tests/admin_ssh_policy/run.py`: PASS, including empty input, ordinary commands and quoted physical-only restrictions.
|
||||
- Independent static review of the production prerequisite found no actionable defects. Final `git diff --check`: PASS.
|
||||
- `pio run`: PASS in **38.46 s**, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0, N16R8 release. **95,164 B linked RAM / 1,628,049 B flash**. Versus recorded 8D.4 (95,084 / 1,627,173): **+80 / +876 B**. Versus recorded 8D.0 (94,532 / 1,599,973): **+632 / +28,076 B**. Baselines were not rebuilt.
|
||||
- Link map attributes 80 B (`0x50`) to `s_console_principals`. The owner callback adds code/read-only adapter storage, no per-session payload. Two shared console slots, two 4 KiB output rings, four-entry request queue, two-entry control queue, four-line history and task stacks (dispatcher 12 KiB, UART0 6 KiB, control 4 KiB) are unchanged. No new module heap/PSRAM allocation or increased socket/TLS/HTTP handler/session/ticket capacity; actual web-admin socket/slot cost is not yet available.
|
||||
|
||||
Host fakes are deterministic, not concurrent: locks are counters, waits use hooks, console execution/lifecycle operations are doubled. Production publication is now exercised, but the complete SSH owner loop, real FreeRTOS scheduling, task-local stdio, socket behavior and runtime memory/stack margins are not proven. No sanitizer or target pass is claimed.
|
||||
|
||||
## Target checkpoint and exact next increment
|
||||
|
||||
Before stacking the live backend on this runtime-changing prerequisite, obtain target regression or an explicit user decision to proceed under the plan:
|
||||
|
||||
1. Boot and capture UART0 status/`memory` and SSH stack telemetry. Confirm admin SSH empty Enter, commands, history/completion, visible/hidden/cancelled prompts and `exit`/Ctrl+D.
|
||||
2. Disconnect or revoke an admin SSH account with work queued and while a prompt waits; confirm prompt cancellation, dispatcher/UART0 recovery, no reply/output crossover after reconnect, and isolation of unrelated sessions. Only use disposable test accounts and approved mutations; do not publish secrets.
|
||||
3. Run browser login/serial disconnect/reconnect, native USB UART1 and user/admin SSH smoke at the supported 115200-baud workload. Repeat five serial lifecycle cycles per role and compare settled/full-client-mix/cleanup heap and SSH stack telemetry. Check slow readers do not compromise UART0 recovery.
|
||||
|
||||
Next implementation remains **8D.5**, not 8D.6: bounded admin-only digest tickets bound to current cookie session/principal; coordinated admission to the existing two shared console slots; a separate bounded admin transport using HTTPD-owned socket work; full pre-101 Origin/cookie/ticket admission; session/account/global invalidation and authoritative liveness checks; fail-before-side-effect restrictions for unsupported self-affecting commands; counters and focused authenticated integration checks. Review scope and socket/allocation budgets before coding and split further if needed. No normal UI entry or generic HTTP command runner. Admission must never evict a serial client/writer. Test real two-WebSocket coexistence before claiming M2 capacity or acceptance.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Phase 8D.6 Implementation
|
||||
|
||||
## Status and Scope
|
||||
|
||||
Implemented, host-tested, build-verified and **validated by explicit user sign-off on 2026-09-06**. Prior 8D.5 sign-off stands. Independent final re-review confirmed both session-isolation and fit-readiness findings resolved. The sign-off below supersedes historical target-pending notes in this record. Numeric reserves remain open; M2 is not yet complete.
|
||||
|
||||
The starting worktree was clean. Production changes are confined to authored `src/web_ui.c`; focused tests extend `tests/web_ui_session/`. No server/protocol/settings/8D.7 policy change, generated asset regeneration, commit, upload or erase. The change fits the work-unit guideline without a preparatory split.
|
||||
|
||||
## Target Sign-off and Evidence (2026-09-06)
|
||||
|
||||
After providing 60-second fresh-boot, full-client-mix and partial-cleanup telemetry, the user confirmed the remaining validation checks: **"Yepp, that checks all out. Let's wrap up for the next phase."** This closes 8D.6, including the Open admin fix and toolbar-order follow-up. The confirmation covers the discussed switching/lease preservation, console/connection isolation, browser/session recovery, layout and soak/recovery checks. Exact repetition counts, soak duration, browser versions/origin and flashed revision were not separately supplied; do not manufacture those details or treat missing per-case transcripts as blockers to this explicit closure. No target checks were executed by the agent.
|
||||
|
||||
| Resource (bytes) | Boot free / minimum / largest | Full mix free / minimum / largest | Partial cleanup free / minimum / largest |
|
||||
|---|---|---|---|
|
||||
| Internal 8-bit | 70,988 / 66,536 / 31,744 | 33,900 / 6,516 / 24,576 | 61,296 / 6,516 / 31,744 |
|
||||
| Internal DMA | 63,232 / 58,780 / 31,744 | 26,144 / 1,580 / 24,576 | 53,540 / 1,580 / 31,744 |
|
||||
| External PSRAM | 8,246,364 / 8,242,552 / 8,126,464 | 8,087,468 / 8,063,428 / 7,995,392 | 8,186,424 / 8,063,428 / 7,995,392 |
|
||||
|
||||
- Boot: HTTPS/SSH/mDNS and both web transports ready without reported startup errors; no sessions/broker clients, UART stopped, USB host closed. Configuration **230400 8N1/no flow**. SSH owner minimum-free stack **18,472 B**, configured 20,480 B.
|
||||
- Full mix: UART running at **230400 baud**; web broker **24 sole writer**, SSH **9**, second web **10**, USB **11** observers. Two cookie sessions, two serial sockets, browser admin and admin SSH active concurrently. SSH stack minimum-free **16,280 B**.
|
||||
- Browser admin: five connections/four disconnections, six tickets issued/five consumed, 14 RX/1,096 TX bytes; zero reported capacity, authorization, protocol, input-backpressure, send or queue failures. Serial web: four connections/two disconnections, 47 RX/869,682 binary TX bytes, no transport failures; one expired ticket. HTTPS totals include two starts/one stop, two authentication failures and no response errors. These are cumulative observations, not attribution to particular validation actions.
|
||||
- SSH: two successful handshakes and **one unexplained handshake failure**; 12 RX bytes, 11 accepted, **one rejected**, 47,392 TX bytes, no I/O failures. Observer input rejection is a possible explanation, not an established diagnosis.
|
||||
- Cleanup is described as disconnecting all clients/admins **except serial**; only heap data was supplied. It is not a verified zero-client/UART-stopped baseline or a precisely timed cleanup sample. Internal/DMA largest blocks recovered to boot size. A single cleanup sample does not establish leak freedom or exact per-socket cost.
|
||||
|
||||
The **6,516 B internal / 1,580 B DMA lifetime minima** remain a resource follow-up for 8D.7/M2. These capabilities overlap and minima are conservative sums across regions, not necessarily simultaneous free-space readings. Do not infer exhaustion, approve numeric reserves or claim an unexplained reserve-floor violation from these values alone. Preserve this evidence for comparable full-load/cleanup measurement in the next phase; it does not reopen user-approved 8D.6.
|
||||
|
||||
Toolbar follow-up moved Open/Close admin before the Serial/Admin group, keeping the selector rightmost. Production-renderer/CSP checks, all **17 UI groups**, and `git diff --check` passed; bounded `pio run` passed in **7.60 s**, unchanged **95,580 B RAM / 1,646,489 B flash**. No JavaScript behavior or generated assets changed. This sign-off/handoff update itself is documentation only; no new build, upload or commit.
|
||||
|
||||
**Next:** separately requested **8D.7 — Web-shell lifecycle parity and M2 acceptance**. Review and close the explicit unsupported self-affecting command restrictions through bounded deferred owner actions; preserve UART0 recovery, single dispatch, serial isolation and safe credential handling. Split if needed. Do not implement settings or declare M2 complete; no 8D.7 implementation is authorized by this wrap-up.
|
||||
|
||||
## Open Admin Bug Follow-up (2026-09-06)
|
||||
|
||||
User reports working serial and mode selection, but Open admin displays `Admin connection failed. Open admin to retry.` Source diagnosis: `web_ui.c:openAdmin()` incorrectly validated a 32-character URL-safe serial ticket; `web_admin_tickets.c:web_admin_tickets_issue()` emits 32 random bytes as **64 lowercase hex characters**. A successful admin-ticket POST was rejected locally before `/ws/admin` construction/admission. No Origin, role, shared-console, fit, subprotocol or private-adapter change is needed for this demonstrated failure. Repeated attempts can leave the two unconsumed tickets occupied until their 30-second expiry.
|
||||
|
||||
Changed only the admin validator to the backend's emitted format. The UI harness had incorrectly shared the serial fixture with admin; separate 64-hex admin responses now exercise the exact query URL, plus malformed/serial-format rejection and serial isolation. The corrected fixture failed before the source fix (only the serial socket existed), then all **17 UI groups** passed. Production C renderer verifies the unchanged inline loader against the exact reported `sha256-o6St1XqFiWgZZKDDKYP8Y1ROJxvOnf96z55w4i/dC20=` CSP. The other reported UUID/index.js hashes are not that loader; their source is not established, and CSP was not relaxed.
|
||||
|
||||
Final focused reruns: `python3 tests/web_ui_session/run.py`, `python3 tests/web_cookie_auth/run.py --admin` (real store/ticket/endpoint-to-101 admission), `python3 tests/web_admin_transport/run.py --tickets` (19/12 groups), and `python3 tests/web_admin_transport/server_lifecycle.py` (11 groups) all pass. Frontend request behavior and backend admission were tested in separate harnesses, not an end-to-end browser/network session. Final bounded `pio run` passed in **7.96 s**, **95,580 B RAM / 1,646,489 B flash**, unchanged from the previous build. `git diff --check` passes. No assets, uploads, commits or target execution. Parent review and user target retest remain pending; this bug fix is not a new phase or sign-off. On next user-controlled deployment, reload the app and verify explicit Open admin reaches a prompt while serial remains connected; no ticket/cookie/CSRF values are needed in any report.
|
||||
|
||||
## Browser Contract
|
||||
|
||||
- Validated `/api/session` role reveals Serial/Admin selection only for admins. Ordinary users retain serial-only navigation; existing server authorization remains authoritative.
|
||||
- Selecting Admin lazily creates one additional xterm instance. Open admin explicitly POSTs `/api/admin/ws-ticket` with the existing CSRF/cookie policy, then connects `/ws/admin`. Selection alone does not open a socket or run a command. Failures and shell termination require explicit reopen, not automatic command/session replay.
|
||||
- Mode switches only change visibility, focus, selected-terminal sizing and input gating. Both connected sockets continue receiving output. The same serial socket, broker identity and writer lease remain; broker status and Request control/Release control remain visible in both modes.
|
||||
- Serial and admin have separate 5,000-line scrollbacks and separate 65,536-byte pending xterm-write limits, released through write callbacks. Excess frames are discarded while the socket continues draining, with cumulative per-terminal browser-drop byte counts visible in both modes. The UI explicitly states oldest scrollback lines expire. These counts are not firmware broker-drop telemetry or a byte-integrity guarantee.
|
||||
- Keyboard/paste goes only to the selected terminal. Serial retains its writer check and 1,024-byte framing; admin sends binary frames of at most 512 bytes with a 4,096-byte input event/socket-buffer admission limit. Excess admin input closes only the shell rather than silently truncating a command. Earlier accepted input cannot be rolled back.
|
||||
- Close admin, shell `exit` and admin failures do not disconnect serial. Explicit serial Disconnect still closes only serial and pauses its reconnect. Admin open has a 15-second handshake deadline in addition to the existing 15-second API deadline. Capacity/security errors remain safe-text/manual retry; server admission errors during upgrade appear as generic connection failure.
|
||||
- Logout, session expiry/401 and pagehide cancel pending work and close both routes. Generation guards reject late responses/callbacks; socket handler properties are cleared before close. Resize listeners/observer are removed on exit and restored once for bfcache. Restored pages revalidate the session and preserve serial pause policy; admin requires explicit reopen. At most two terminals and one input subscription each persist for the page lifetime, not one per switch/reconnect.
|
||||
- Scrollback belongs to the document's first validated username/role/session-stable CSRF tuple, retained separately from the active request CSRF value. Before adopting any later session view, a mismatch hides both terminals, closes both routes, fences pending work and replaces the document at `/`. This also prevents logout from mutating a replacement session. Pagehide hides both terminal hosts and blocks selection until successful same-session validation; same-session restore and mode switches retain scrollback. Failed or delayed restore validation never reveals the retained buffers.
|
||||
- Terminal bounds are cached only after valid dimensions and successful resize (or an already matching size). An unavailable/invalid measurement gets at most three animation-frame retries per external fit request. Work cancellation removes the pending frame, resets the retry budget and generation-fences stale callbacks; teardown/logout cannot restart readiness retries. A later resize/selection can explicitly start a fresh bounded attempt.
|
||||
- Existing dark panel/button visual language is retained, selected buttons expose `aria-pressed`, and narrow layouts can scroll rather than clipping the terminal below the dashboard. Real desktop/mobile rendering remains to be checked. The inline loader and its exact CSP hash are unchanged and tested; authored app/document remain no-store.
|
||||
|
||||
## Executed Validation
|
||||
|
||||
All commands ran successfully in this workspace:
|
||||
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `python3 tests/web_ui_session/run.py` | Production C renderer/header/failure/CSP checks and 16 Node groups pass |
|
||||
| `python3 tests/web_login_ui/run.py` | C/HTML checks and eight Node groups pass |
|
||||
| `python3 tests/web_cookie_auth/run.py --admin` | Cookie/session policy plus combined real store/ticket/admin endpoint tests pass |
|
||||
| `python3 tests/web_auth_parse/run.py` | 268 cases, zero failures |
|
||||
| `python3 tests/web_session_store/run.py --serial` | Store and serial binding/isolation/races pass |
|
||||
| `python3 tests/web_admin_transport/run.py --tickets` | 19 transport groups and 12 ticket groups pass |
|
||||
| `python3 tests/web_admin_transport/server_lifecycle.py` | 11 lifecycle groups pass |
|
||||
| `python3 tests/admin_console_boundary/run.py` | Shared-owner/currentness/prompt/completion/SSH boundary suites pass |
|
||||
| `pio run` | Final source build after review fixes succeeds in 21.35 seconds, 120-second finite timeout |
|
||||
| `git diff --check` | Pass |
|
||||
|
||||
New UI checks cover 20 switch cycles with unchanged serial socket/client/writer IDs, hidden binary output and independent terminals, selected input framing, user-only navigation, visible overflow and resumed draining, close/reopen/remote-close isolation, 401/logout/pagehide/expiry, cancelled admin ticket late 401, stale socket callbacks, selected resize, three bfcache listener cleanup cycles and handshake timeout. The harness executes production C-rendered JavaScript with DOM/xterm/WebSocket/timer doubles. It does not execute real terminal escape parsing, hidden prompts, CSS layout, browser CSP enforcement, TLS/HTTPD scheduling, target UART traffic or browser heap profiling.
|
||||
|
||||
### Independent Review Fixes (2026-09-06)
|
||||
|
||||
Both reported P2 findings are fixed, with parent re-review pending:
|
||||
|
||||
- **Cross-session retained scrollback/live admin:** `loadSession` now compares identity before adopting CSRF, role, expiry or view. It requires a clean document on mismatch rather than reusing A's buffers or live admin socket under B's cookie. Regression coverage checks admin-to-admin, admin-to-user, same-account/new-CSRF, username-only and role-only changes, each during active restore, paused restore, live serial reconnect with admin still open, and logout. Tests assert clean `/` replacement, hidden old terminals, both sockets closed, no new ticket/logout request, unchanged old session view and fenced callbacks. Same-identity restore retains both scrollbacks and live same-session reconnect retains admin.
|
||||
- **Failed initial fit cached forever:** Valid measurements alone populate the bounds cache; three bounded readiness retries recover from an initially undefined measurement at identical host bounds. Tests also exhaust retries, verify later external retry, and invoke stale callbacks after pagehide/restore, expiry and logout. The first test run exposed a fresh fit scheduled by logout's session check; suppressing fits while logging out fixed that case before the final passing rerun/build.
|
||||
|
||||
After the fixes, reran `tests/web_ui_session/run.py` (16 groups), `tests/web_login_ui/run.py` (8 groups), `tests/web_cookie_auth/run.py --admin`, `tests/web_session_store/run.py --serial` and `tests/web_auth_parse/run.py` (268 cases), all with `python3`, all passing. The other unchanged-backend suites in the table passed during initial implementation and were not rerun for these UI-only review fixes. Final `pio run` and `git diff --check` pass. No target/browser result, phase sign-off or M2 completion is claimed.
|
||||
|
||||
## Resource Accounting
|
||||
|
||||
Final PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0 N16R8 release build:
|
||||
|
||||
| Metric | Final | Versus signed-off 8D.5 build | Versus recorded 8D.0 |
|
||||
|---|---:|---:|---:|
|
||||
| Linked RAM | 95,580 B | 0 B | +1,048 B |
|
||||
| Flash | 1,646,489 B | +9,216 B | +46,516 B |
|
||||
|
||||
The delta is authored HTML/CSS/JavaScript read-only content. Review fixes add **0 B RAM / 1,376 B flash** versus the initial 8D.6 build (22.22 seconds, 95,580 / 1,645,113 B). An earlier pre-final-CSS build passed in 25.30 seconds at 95,580 / 1,644,905 B. The table is the final post-review-fix source result. Browser-only additions are one retained session identity tuple and a retry counter using the existing single animation-frame slot, not new firmware storage or a polling task.
|
||||
|
||||
No firmware runtime allocations, tasks/stacks, queues, request scratch, route count or capacities are added. Existing six HTTPS socket slots, two serial sockets, one optional admin socket, two admin tickets and two shared console slots are unchanged. The UI now exercises the already-allocated backend with simultaneous serial/admin WebSockets plus periodic HTTPS requests; actual loaded TLS/socket/console heap headroom still requires target measurement. There is no LRU eviction/capacity increase to make the selector work.
|
||||
|
||||
Browser cost adds a lazy terminal/fit addon and 5,000 lines of scrollback, plus at most 64 KiB pending output payload per terminal (128 KiB together) and callback/object overhead. This is not a total browser heap bound: xterm cell storage depends on dimensions, browser networking queues are implementation-owned, and JavaScript cannot securely wipe engine-managed strings. Numeric internal/DMA/largest-block/stack reserve gates remain open.
|
||||
|
||||
## Target Checklist (Retained for Regression)
|
||||
|
||||
The user sign-off above closes 8D.6. This original checklist is retained for future regression coverage; individual unrecorded details are evidence limitations, not outstanding phase gates.
|
||||
|
||||
1. Record exact flashed revision, browser versions and direct-IP/mDNS origin. Verify user serial-only login and admin selector on desktop and narrow/mobile screens, selected styling/focus, resize/orientation and usable terminal height. Exercise initially unavailable font/cell measurements: sizing must recover at unchanged bounds or after a later explicit resize, without unbounded retries.
|
||||
2. With serial writer active and sustained UART output, perform 20 Serial/Admin switches. Confirm unchanged broker client/writer IDs, Request/Release from both modes, continued hidden output and no admin keystrokes on UART1. Repeat as observer. Distinguish browser-drop counts, firmware broker drops and expected scrollback rollover.
|
||||
3. Exercise admin empty Enter, normal commands, history, Tab completion, Ctrl+C and hidden prompts while switching views. Confirm no input/output/prompt crossover with UART0/admin SSH. Close/reopen five times and use `exit`/empty Ctrl+D; serial must remain intact. Explicit serial Disconnect/reconnect must not close admin.
|
||||
4. Test admin capacity rejection with existing console/socket occupancy, network failure, stale ticket and repeated manual reopen; retained serial must not be evicted. Confirm existing 8D.5 unsupported self-affecting command restrictions still reject without side effects; no 8D.7 parity is claimed.
|
||||
5. Test logout, account revocation, absolute expiry, pagehide/bfcache restore, ordinary reload and interrupted logout with both routes open and with a pending ticket. No stale admin command/session replay; expired auth returns to usable login. Recheck unrelated-session isolation. Change the same-origin cookie via another tab/account login (admin-to-admin and admin-to-user), then restore/reconnect: no old scrollback may appear in the new document, and old admin must close. Same-session restore must retain both scrollbacks; delayed/failed revalidation must leave them hidden.
|
||||
6. Run UART0 recovery, USB UART1, user/admin SSH and two-browser serial plus one browser-admin full mix with HTTPS polling. Capture `memory`, transport/broker counters and SSH stack margins at settled boot, serial-only, serial+admin, full load and after five open/close/logout cycles. Compare internal/DMA/PSRAM free/minimum/largest blocks without inventing reserve floors.
|
||||
7. Run a bounded 15-minute mixed-client soak at the user's supported workload (record actual baud; prior 8D.5 was 230400), then disconnect and collect 60-second cleanup telemetry. Check no watchdog/stack faults, monotonic leaks, declining largest-block trend or unexplained lease loss. Profile browser memory across repeated switches and output overflow where feasible.
|
||||
|
||||
8D.6 is closed by user sign-off above. Wait for a separate **8D.7** implementation request; numeric reserves and the M2 acceptance milestone remain open.
|
||||
@@ -0,0 +1,296 @@
|
||||
# Phase 8D.0 — Baseline and M1 browser contract
|
||||
|
||||
Status: **8D.0 and 8D.1 validated by user sign-off on 2026-09-05.** Documentation/source audit, builds and target runtime samples are recorded. Numeric reserve floors and future incremental budgets remain open engineering gates, not blockers to these user-approved closures. The M1 browser contract below was established during baseline planning; the subsequent [8D.3 live cutover record](phase8d3_implementation.md) now documents implemented/host-tested/build-verified authentication and explicit 8D.3/M1 user sign-off after post-soak evidence on 2026-09-06. Numeric reserve gates remain open. Baseline measurements and source-behavior descriptions here remain historical, not measurements of the live cutover. See [execution plan](phase8d_plan.md) and [acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration).
|
||||
|
||||
## Validation sign-off
|
||||
|
||||
The subsequent [8D.2 implementation record](phase8d2_implementation.md) contains its separate build/resource accounting, target samples and user sign-off. The baseline and 8D.1 measurements below remain historical evidence, not 8D.2 target validation.
|
||||
|
||||
- The user explicitly marked **8D.0 validated** and identified the tested firmware with the latest checked-in project state, resolved at sign-off to Git revision **`d8999cd4a96e477fabd392ced02d810c3cd22d0f`**. This is user-confirmed source provenance, not an independently read-back device binary hash. The earlier reproducible build revision and SHA-256 table remain historical build evidence, not newly generated hashes for this revision.
|
||||
- The user attributes the SSH I/O errors to testing at **out-of-spec 460400 baud**. Preserve that exact reported rate separately from the transcripts' **460800-baud UART configuration**; the differing rate may describe the test setup, and no firmware baud-support change or independently reproduced diagnosis is implied. The session-revocation counter remains recorded without a separately supplied causal explanation.
|
||||
- This sign-off closes 8D.0 using the supplied evidence. Per-sample missing-provenance and unverified-check notes below describe the evidence available when collected; source-revision uncertainty is superseded by this sign-off. Unrecorded detailed checks remain regression coverage, not claims that the agent executed them or outstanding blockers to 8D.0 closure. Numeric memory/stack reserve approval and post-change 8D.1 validation are not implied.
|
||||
|
||||
## Reproducible build baseline
|
||||
|
||||
- Recorded 2026-09-05 on `devel`, revision `af89dd1bd96cdd97d8d57eee7a29f68e3874506b` (`Define staged Phase 8D delivery plan`). Working tree was clean before measurement. No experimental-branch inspection/import, firmware edits, asset regeneration, upload, erase, or device reconfiguration.
|
||||
- `pio run` passed in 36.08 seconds (120-second timeout). Release environment `esp32-s3-devkitc-1-n16r8`, `platformio/espressif32@6.12.0`, ESP-IDF 5.5.0, Xtensa toolchain 14.2.0+20241119. Existing framework Kconfig invalid-bool-default notes appeared; they did not fail the build.
|
||||
- N16R8: 16 MB QIO flash, 8 MB octal PSRAM at 80 MHz, custom partitions. Application flash budget is 4,194,304 bytes, not the full chip. Wi-Fi/lwIP allocations prefer external RAM; the configured mbedTLS allocator is PSRAM-only, without internal fallback. Software crypto configuration is retained.
|
||||
- `pio device list` completed with no ports listed. No target/browser connection was available to this task. Historical target reports are not fresh evidence for this revision.
|
||||
|
||||
| Measurement | This build | Historical mDNS reference | Difference |
|
||||
|---|---:|---:|---:|
|
||||
| Linked static RAM | 94,532 B | 94,532 B | 0 B |
|
||||
| Program flash reported by PlatformIO | 1,599,973 B | 1,599,765 B | +208 B |
|
||||
|
||||
The flash difference predates this documentation-only change; its cause was not diagnosed. Linked RAM is not runtime heap headroom. No runtime delta can be inferred from these figures.
|
||||
|
||||
SHA-256 identification (configuration and generated binary, not secrets):
|
||||
|
||||
| File | SHA-256 |
|
||||
|---|---|
|
||||
| `platformio.ini` | `65ca5103c7aaf36b685a05371a856294ba6f7f5f53209c68d61a828d3beb78e5` |
|
||||
| `sdkconfig.defaults` | `af8fb8a9866888a12219a9d4ff5148d9f45f17c40067dce90fbc55c0b75986e5` |
|
||||
| `sdkconfig.esp32-s3-devkitc-1-n16r8` | `c9c8c08b18027e959ba8131686f0e42573e507574079be3cf8fb72a9e1955905` |
|
||||
| `partitions.csv` | `9107a2aab52f02633aea72683ffede979d9db2050e95ba4788cd0f8580c7f0ff` |
|
||||
| `.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin` | `c5f10b2137d2cbffad59cda025274dd82aad1a42cdea2144d31424fd918b4e6d` |
|
||||
|
||||
## Source-verified current behavior and capacities
|
||||
|
||||
Authoritative paths: `src/web_server.c`, `src/web_serial_transport.{h,c}`, `src/web_ui.c`, `src/user_database.h`, `src/system_console.c`, `src/session_broker.{h,c}`, `src/ssh_transport.{h,c}`, `src/admin_ssh_console.c`. Configuration-dependent defaults below were also checked against installed IDF 5.5 HTTPD/HTTPS headers and implementation and targeted generated sdkconfig entries.
|
||||
|
||||
- Both roles currently use Basic authentication for the same serial/status UI; there is no login page, cookie session, logout, or browser admin route. All five app assets require authentication. Basic header buffers are wiped after verification. The four-entry keyed-digest cache has a 300-second sliding TTL and rechecks principal currentness; cache expiry is not browser logout.
|
||||
- Ticket issuance accepts only an empty POST body. Serial upgrade consumes a one-use, principal-bound ticket; supplied Origin must match `https://<Host>`, but missing Origin is currently accepted. Four tickets, 30-second lifetime, 24 random bytes encoded to 32 characters, digest-only storage. Full ticket storage currently evicts the earliest-expiring entry.
|
||||
- Serial admission opportunistically requests writer ownership. Explicit browser Disconnect closes the socket and pauses reconnect; Connect resumes it. Binary data and existing `request-writer`/`release-writer` messages remain unchanged in M1. Status polling runs every five seconds. Current fetch failures do not distinguish expired login from transport failure; cutover must change this.
|
||||
- Principals copy account ID, authentication generation, role, method and username. Database limits are eight accounts, username capacity 16 bytes, password capacity 64 bytes. Mutation/recreation invalidates principal currentness; transport notification supplements, not replaces, that authority.
|
||||
|
||||
### Allocation and execution inventory
|
||||
|
||||
These are **source array capacities/configured stacks**, not measured allocator costs or complete `sizeof` totals. Do not add stack-local scratch to the stack allocation again.
|
||||
|
||||
| Resource | Existing bounded allocation or limit |
|
||||
|---|---|
|
||||
| HTTPS owner | One dynamic internal 10,240 B stack; serialized request handlers; dynamically allocated server/socket/route/TLS state |
|
||||
| HTTP request/response scratch | Status JSON 3,072 B on stack; Basic header 115 B, decoded credentials 81 B, digest 32 B on stack; startup certificate/key scratch 1,024 + 256 B |
|
||||
| HTTP parser/response headers | Request headers 1,024 B, URI 512 B, eight additional response-header slots; implementation must count cookie + security headers before cutover |
|
||||
| TLS records | Configured input/output content limits 16,384/4,096 B per TLS connection; external allocation, dynamic resizing disabled; contexts, certificates, TCP and allocator overhead additional; HTTPS TLS resumption tickets disabled |
|
||||
| Web serial | Two static slots, each 1,024 B RX + 512 B TX (3,072 B payload total), principals and metadata; four ticket records; one static 6,144 B stack + TCB; at most one pending HTTPD TX work item per slot |
|
||||
| Broker | Eight preallocated clients, 4,096 B usable output/client (32,776 B backing storage including sentinel bytes); 16 events/client, additional `8 × 16 × sizeof(session_broker_event_t)` storage; payloads PSRAM-preferred with internal fallback, controls internal; one dynamic 4,096 B stack |
|
||||
| SSH | Two total slots shared by serial/admin/handshakes, 512 B RX + 512 B TX each (2,048 B payload total), plus snapshots/metadata; one dynamic 20,480 B owner stack pinned to core 1; wolfSSH allocations PSRAM-preferred with internal fallback |
|
||||
| Shared administration | Two static remote states with 5,899 B explicit byte arrays each (including 4,096 B output and four history entries); additional 1,024 B SSH and 1,024 B UART completion formatters; command queue four, deferred-control queue two; 256-character commands, ten parsed arguments |
|
||||
| Admin tasks | Dynamic dispatcher 12,288 B, deferred control 4,096 B, UART frontend 6,144 B; no task per remote session |
|
||||
| Serial service | 16 KiB RX + 8 KiB TX usable stream payloads PSRAM-preferred with internal fallback; UART driver/control/task storage additional |
|
||||
|
||||
The seven listed HTTPD/web/broker/SSH/admin task stacks total **63,488 B**; this is not a whole-firmware task inventory. Other existing tasks, including Wi-Fi, USB, serial, UI, mDNS and system tasks, contribute to the measured baseline. No new task is allowed for M1. Internal fallback is a worst-case reserve cost, not free PSRAM capacity. Exact padded structure sizes, per-TLS/SSH handshake peaks and fragmentation remain unmeasured.
|
||||
|
||||
### Supported concurrency and socket accounting
|
||||
|
||||
Configured capacity is not evidence that every combination has passed this revision's target tests.
|
||||
|
||||
| Resource | Baseline ceiling / intended workload |
|
||||
|---|---|
|
||||
| Broker clients | Eight, exactly one writer; normal physical-transport maximum is USB + two web serial + two user SSH = five |
|
||||
| Web serial | Two simultaneously connected serial WebSockets, independently of four outstanding tickets |
|
||||
| SSH | Two connections total; baseline full mixed workload uses one user and one admin |
|
||||
| HTTPD routes | Nine registered / nine slots: `/`, `/api/status`, `/api/ws-ticket`, `/ws/serial`, five `/assets/` routes; no spare route slot |
|
||||
| HTTPS connections | Six accepted clients total, including WebSockets; two serial sockets leave four HTTP/TLS slots, not four parallel handlers |
|
||||
| HTTPD infrastructure | Three more descriptors: listen, UDP control receive, UDP control send; full HTTPS therefore uses nine |
|
||||
| SSH descriptors | Listener + two sessions = three; combined HTTPS/SSH subtotal twelve of sixteen lwIP sockets |
|
||||
| Remaining socket allowance | Four before other services (including mDNS), network diagnostics and transient accept/reject costs; not four guaranteed spare connections |
|
||||
|
||||
HTTPS has LRU purge **enabled**, one-second send/receive waits and five-second handshake timeout; SSH has a two-connection listen backlog and 15-second handshake deadline. Before M2, admission must protect retained serial sockets from LRU eviction; do not claim current settings already guarantee that protection. M2 must budget two browser sockets (serial + admin) per dual-mode browser: two such browsers would occupy four of the six HTTPS slots, leaving only two for ordinary HTTPS. This is arithmetic, **not approval of a future admin capacity**. Do not raise lwIP/HTTPD limits or reduce existing serial/SSH capacities to conceal pressure.
|
||||
|
||||
## Minimal M1 browser contract (planned)
|
||||
|
||||
Policy constants below are bounded functional choices, **not approved memory reserve floors**. No settings API, admin shell, generic command endpoint, new permanent task, Basic compatibility path, stored browser passwords/localStorage credentials/remember-me tokens, or generated-asset changes are included.
|
||||
|
||||
### Routes and small wire schema
|
||||
|
||||
All API JSON is UTF-8, length-bounded and safely encoded. Login accepts only `application/json`; no form/query credentials, duplicate/unknown fields, embedded NULs, oversized bodies, or unbounded parser allocations. Maximum login body: **512 bytes**, decoded username/password limits **16/64 bytes**, using existing database validation. Reject excessive bodies before buffering. New authentication JSON responses have a **512-byte serialized ceiling**; the existing status schema/buffer is unchanged. `X-CSRF-Token` is the only CSRF request header.
|
||||
|
||||
| Route | Access and contract |
|
||||
|---|---|
|
||||
| `GET /login` | Public minimal standalone login document; inline bounded CSS/script with matching CSP hash, no dependency on protected app assets. No automatic login or credential verification. |
|
||||
| `GET /api/login-challenge` | Public same-origin pre-login bootstrap, empty body; returns `{ "csrf": string, "expires_in": integer }` (remaining whole seconds, at most 120) and pre-login cookie described below. |
|
||||
| `POST /api/login` | Strict Origin + pre-login cookie + CSRF header; body `{ "username": string, "password": string }`; success `200 { "authenticated": true }` and new session cookie. Browser navigates to `/`. |
|
||||
| `GET /api/session` | Session required; returns `{ "username": string, "role": "user" or "admin", "csrf": string, "expires_in": integer }`, remaining whole seconds. No raw session token or account/verifier internals. |
|
||||
| `POST /api/logout` | Session + strict Origin + CSRF; empty body. Invalidate current session before `204`, expire cookie, browser navigates to `/login`. Never a state-changing GET. |
|
||||
| `GET /`, five existing `/assets/` routes | Session protected. Unauthenticated document navigation to `/` receives `303 /login`; assets/API receive `401`, never login HTML masquerading as JS/JSON. |
|
||||
| `GET /api/status` | Session required; existing status for either role. |
|
||||
| `POST /api/ws-ticket` | Session + strict Origin + CSRF; empty body; preserve existing ticket response fields/protocol, bind ticket to originating session as well as principal. |
|
||||
| `GET /ws/serial` upgrade | Session cookie + strict Origin + one-use matching-session ticket; no Basic fallback. Currentness and expiry checked before attach. |
|
||||
|
||||
Five new handlers mean **14 route slots total** at cutover if using these exact routes. This is a separately accounted bounded route-table increase, not a socket increase. Preflight/CORS access is unsupported. Other methods must not bypass policy. API failures use a bounded `{ "error": code }` without echoing submitted input; existing non-auth errors may remain plain text and the browser must tolerate both.
|
||||
|
||||
### Sessions, CSRF bootstrap and admission
|
||||
|
||||
- **Four authenticated sessions globally**, independent of two serial sockets and four outstanding serial tickets. New login issues 32 random bytes as 64 lower-case hex characters; store only a SHA-256 token digest with copied principal, origin binding, CSRF state, monotonic deadline and generation-safe session identity. Use `secure_random`; fail closed on RNG/init failure. Keep synchronization explicit for HTTPD lookups versus console invalidation; no cross-subsystem callbacks while holding session storage locks.
|
||||
- Cookie: `__Host-sak-session=<token>; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`, no Domain. **Absolute lifetime one hour; idle expiry disabled in M1.** Polling, traffic and refresh never extend it. No sliding renewal or refresh token; reauthentication after expiry issues a new identity. This deliberately simple policy will interrupt a long-running serial session at expiry and must be visible to the user. Reboot/web-server stop clears sessions and tickets. Browser cookie lifetime is not server authority.
|
||||
- CSRF state: independent 32 random bytes encoded as 64 hex characters, retained only in bounded session state and browser memory. Return via `/api/session` on page load; never localStorage, URL or logs. It does not substitute for the HttpOnly session cookie. Refresh does not rotate it or invalidate another tab.
|
||||
- **Four independent pre-login challenges**, 120-second absolute lifetime. Bootstrap issues `__Host-sak-prelogin` with the same cookie attributes and `Max-Age=120`, backed by token digest, origin binding, separate random CSRF value and deadline. `/login` itself allocates nothing. Bootstrap reuses an unexpired matching challenge for that browser without reissuing the cookie or extending its lifetime, returning remaining seconds; otherwise reclaim expired slots and reject at capacity. Require a same-origin custom header `X-Login-Bootstrap: 1` on the bootstrap fetch; reject cross-site Fetch Metadata when present, and validate any supplied Origin. No CORS headers: another origin cannot read a challenge or make that custom-header request. This handles same-origin GETs where browsers omit Origin without weakening POST checks.
|
||||
- Login consumes a valid challenge **on every credential attempt**, including wrong passwords; clear its cookie, wipe request/password scratch on all exits, and fetch a new challenge for a user-initiated retry. Concurrent tabs share the cookie: a stale form gets a recoverable challenge-expired error, not a login loop. Successful login always generates a fresh authenticated token (no fixation). Already authenticated browsers receive `409 already_authenticated` instead of silently replacing an account/session; account switching requires logout.
|
||||
- Reclaim expired/stale records first; full live session/challenge/ticket tables return `503 capacity` with `Retry-After: 5`, with **no live-session/ticket eviction**. Thus ticket overflow intentionally changes from baseline earliest-expiry eviction to explicit rejection at cutover. A session never reserves a serial socket; full serial capacity can reject Connect without discarding login or disturbing an existing writer.
|
||||
- Login throttle: one global bounded monotonic bucket allowing **five credential-verification attempts per 60-second window**, successes included; further attempts get `429 throttled` with seconds until window reset in `Retry-After`. Invalid CSRF/Origin never reaches password verification. No per-username/IP table, sleep in HTTPD, secret retention or persistent lockout. Tradeoff: a hostile client can exhaust shared login capacity; UART0/USB and established sessions remain usable. Do not claim comprehensive DoS resistance; measure PBKDF2 blocking/TLS pressure on target.
|
||||
|
||||
### Origin, expiry and revocation rules
|
||||
|
||||
- Every mutation (login, logout, ticket issuance) and WS upgrade requires a single non-null HTTPS Origin equal to the request's validated Host authority after lower-casing host and normalizing default port 443. Reject absent/malformed/oversized Origin, userinfo, paths, query/fragment, comma lists, invalid Host or non-443 port. Use bounded host/origin buffers; do not trust forwarded headers. A Referer is not a substitute. Bind challenge/session/ticket to that canonical origin. No arbitrary return URLs.
|
||||
- Direct IP and `sak-<suffix>.local` are separate host-only cookie origins, even on one device. Login/logout on one does not affect the other's unchanged session; no alias cookie sharing. Both require trusted/accepted device TLS in the browser. Hostname changes do not migrate cookies.
|
||||
- Lookup/admission, ticket mint/consume and sensitive WS input recheck session liveness plus principal currentness. Extend existing owner-task periodic checks to enforce expiry/revocation for idle sockets, with a planned **at most one-second detection-latency acceptance target**, no new task. This bound is not established by the current loop; implementation and target validation must include lock contention and concurrent load. Mark invalid immediately and request generation-safe close through HTTPD ownership; network delivery/close completion can take longer. Failed notification must not allow more serial input or revive an expired session.
|
||||
- Logout invalidates only the originating session and its tickets/serial sockets (and later admin sockets); other sessions for the same unchanged account survive. Logout in one tab affects all tabs sharing that cookie. Account password/role/key changes, deletion/recreation and explicit account revocation invalidate all that account's sessions/tickets/sockets across origins, not unrelated accounts. Preserve distinct account, web-session and transport generations. Wipe retired records; late cleanup cannot close reused slots.
|
||||
- All login/session/auth errors, protected documents, API and ticket responses use `Cache-Control: no-store`; preserve nosniff, no-referrer, frame denial and restrictive CSP. Existing immutable vendored asset policy can remain; no auth-dependent data in those assets. Count response headers against HTTPD's eight-slot default, including Set-Cookie (two on login); adjust only if the exact count requires it. Never log cookies, CSRF, ticket query strings, passwords, verifier or private-key material.
|
||||
|
||||
### Browser recovery behavior
|
||||
|
||||
- `401`: stop polling/reconnect work, close local socket, clear in-memory CSRF, navigate once to `/login`. On login submission, `401 invalid_credentials` stays on the form with a generic usable error; it must not reveal account existence. No `WWW-Authenticate`, cached Basic header authorization or browser credential-reset instructions after cutover.
|
||||
- `403`: report CSRF/origin failure without retrying a mutation automatically; allow challenge/session reload and explicit retry. `400`/`413`/`415`: show input/request error. `409`: offer return to app/logout. `429`/`503`: display bounded retry information and do not retry credentials automatically.
|
||||
- Transport/network error: show connection failure without claiming logout succeeded. A lost logout response can follow successful invalidation: check session status when reachable; `401` confirms login is needed. Do not rely solely on client cookie deletion (HttpOnly).
|
||||
- Refresh/back navigation validates `/api/session` before ticket/reconnect; expired cookies, reboot and previously Basic-authenticated profiles must all land on usable login. Explicit Disconnect still pauses reconnect while leaving login valid. Capacity and network failure must not look like bad credentials. Cancel pending work on logout/page exit and ignore late responses via generation checks.
|
||||
|
||||
## Runtime measurements, reserve gates and exact target checklist
|
||||
|
||||
**The user-provided settled-boot and concurrent-session samples below form the 8D.0 runtime memory baseline. Other workload measurements remain pending, not zero.** `memory` reports free/minimum-free/largest-block for internal 8-bit, internal DMA and PSRAM. Record all nine values at every point. DMA overlaps internal heap: do not sum them. Minimum-free is the conservative sum of per-region lifetime minima, not a synchronized low-water sample. Only SSH currently exposes stack minimum-free via `ssh status`; HTTPD/web/broker/admin/UART task margins lack instrumentation and remain pending (no telemetry changes in 8D.0).
|
||||
|
||||
| Workload point | Internal free/min/largest | DMA free/min/largest | PSRAM free/min/largest | SSH stack minimum-free |
|
||||
|---|---|---|---|---|
|
||||
| **Settled clean boot, ~60 seconds, Wi-Fi STA connected, mDNS running (8D.0, user-provided)** | **68,036 / 67,192 / 31,744 B** | **60,280 / 59,436 / 31,744 B** | **8,198,508 / 8,188,800 / 8,126,464 B** | **18,464 B** |
|
||||
| **Basic-authenticated browser/status, browser serial explicitly disconnected, UART service running; fresh settled boot (user-provided)** | **65,340 / 60,088 / 31,744 B** | **57,584 / 52,332 / 31,744 B** | **8,212,812 / 8,158,868 / 8,126,464 B** | **18,464 B** |
|
||||
| **Basic-authenticated browser/status, browser serial explicitly disconnected, UART service stopped (user-provided)** | **69,988 / 60,280 / 31,744 B** | **62,232 / 52,524 / 31,744 B** | **8,223,088 / 8,158,832 / 8,126,464 B** | **18,464 B** |
|
||||
| **Browser/status sample: one active browser serial writer, no SSH sessions (user-provided; disconnected label unconfirmed)** | **60,980 / 55,880 / 31,744 B** | **53,224 / 48,124 / 31,744 B** | **8,163,616 / 8,109,644 / 7,995,392 B** | **18,464 B** |
|
||||
| **Updated baseline: one browser serial + user SSH + admin SSH (user-provided)** | **44,552 / 17,812 / 31,744 B** | **36,796 / 10,056 / 31,744 B** | **8,187,416 / 8,176,688 / 8,126,464 B** | **16,288 B** |
|
||||
| **Baseline follow-up: two browser serial + USB + user SSH + admin SSH (user-provided)** | **42,056 / 17,812 / 29,696 B** | **34,300 / 10,056 / 29,696 B** | **8,162,828 / 8,126,736 / 7,995,392 B** | **16,288 B** |
|
||||
| **After five browser serial disconnect/reconnect cycles, ending connected as user writer (user-provided)** | **60,776 / 59,792 / 31,744 B** | **53,020 / 52,036 / 31,744 B** | **8,163,656 / 8,153,968 / 8,126,464 B** | **18,464 B** |
|
||||
| **After 15-minute concurrent soak, clients disconnected, then 60-second settled cleanup; UART running (user-provided)** | **65,040 / 17,812 / 31,744 B** | **57,284 / 10,056 / 31,744 B** | **8,212,596 / 8,126,736 / 8,126,464 B** | **16,288 B** |
|
||||
| Cookie login/logout and browser admin connected | Not implemented | Not implemented | Not implemented | Not implemented |
|
||||
|
||||
### Settled clean-boot sample (8D.0 firmware)
|
||||
|
||||
The user supplied this transcript on 2026-09-05 after 8D.1 work had begun, explicitly identifying the measured firmware as based on **8D.0**, not 8D.1. Conditions reported: clean boot, settled after approximately 60 seconds, Wi-Fi STA connected and mDNS running. Commands were `memory`, `ssh sessions`, `broker clients`, `ssh counters`, `web counters`, `web status`, then `mdns status`; these are sequential user-provided snapshots, not an atomic sample or an agent-executed test.
|
||||
|
||||
- SSH sessions **0/2**; no broker clients connected. HTTPS initialized/running, not transitioning, port 443, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins. Web serial attached, sessions **0/2**, zero tickets. Endpoints reported: `GET /`, `GET /api/status`, `POST /api/ws-ticket`, `WSS /ws/serial`.
|
||||
- mDNS initialized and announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. This records announcement status, not an independently verified hostname-resolution test.
|
||||
- SSH counters: lifecycle starts **1**, start-failures/stops/TCP connections/capacity rejects **0**. All handshake, authentication, request-rejection, broker, admin-console and stream counters **0**, including failures, backpressure and revocations.
|
||||
- Web counters: lifecycle starts **1**, start-failures/stops **0**. Requests total **29**, authenticated **29**, status **29**; auth-failures/root/tickets/assets/response-errors **0**. All ticket, WebSocket session, RX, TX, writer-control and failure counters **0**, including service-start/broker failures and closes.
|
||||
- The 29 authenticated status requests mean this is a settled boot with HTTPS status activity, not a no-HTTP-traffic idle sample. Browser/version, request origin and polling source were not supplied; do not infer an additional controlled browser-only workload measurement from these counters.
|
||||
- Follow-up user-provided `ssh status` for settled boot: initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**. Authentication reports role-based password and SSH public key via the user database; admission reports shell/PTY only, with exec, subsystem, forwarding, SCP and SFTP disabled. Owner task on core **1**, configured stack **20,480 B**, stack minimum-free **18,464 B**. This is a settled-boot stack low-water measurement, not a loaded-workload margin or approved reserve floor.
|
||||
- Exact flashed revision/configuration or binary hash was not supplied. The user's 8D.0 firmware attribution is retained without independently tying this device to the build hash above. This sample does not validate 8D.1, establish reserve floors or measure post-load cleanup; do not treat comparisons with the earlier concurrent samples as controlled per-client allocation deltas.
|
||||
|
||||
### Browser/status sample with active serial WebSocket
|
||||
|
||||
The user supplied this additional baseline transcript on 2026-09-05 labelled "Basic-authenticated browser/status, serial explicitly disconnected." However, the supplied `web status` reports **one active serial writer**, and counters report one connection with zero disconnects/closes. Preserve the measurements separately; the later disconnected sample below also stops the UART service. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, then `ssh status`, so the snapshots are not atomic.
|
||||
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoint list. Web serial attached, sessions **1/2**, zero tickets; slot **0**, fd **56**, generation **1**, role-`user` password authentication, broker **8 writer**, no TX pending or closing state. No `broker clients` snapshot was supplied, so other broker-client presence is not established.
|
||||
- Web lifecycle: starts **1**, start-failures/stops **0**. Requests total/authenticated **28**, root **1**, status **26**, tickets **1**, assets **0**, auth-failures/response-errors **0**. Tickets issued/consumed **1**, rejected/expired **0**. WebSocket connects **1**, disconnects **0**, connect/service-start/broker failures **0**.
|
||||
- WebSocket RX: **14** accepted frames / **14 B**, no rejected frames/bytes. TX: **18** binary frames / **753 B**, **2** control frames / **148 B**. Writer requests/grants **1**, denials/releases/revocations **0**; send/queue/protocol failures and closes **0**.
|
||||
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**; role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **18,464 B**. SSH lifecycle starts **1**; all other supplied SSH counters **0**.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`.
|
||||
- This is user-provided baseline evidence, not an 8D.1 target-validation claim. Exact flashed revision/hash, browser/version/origin, settling duration and serial fixture were not supplied. Web request totals are lower than in the settled-boot transcript; same-boot continuity is not established, and no controlled heap delta is inferred. Lifetime minima include prior activity, including the reported serial traffic.
|
||||
|
||||
### Browser disconnected, UART service stopped
|
||||
|
||||
The user supplied this follow-up 8D.0 baseline transcript on 2026-09-05, reporting that the browser automatically connected serial after login, then was explicitly disconnected and the serial service stopped. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, then `serial status`; these are sequential observations, not an atomic sample or an agent-executed test.
|
||||
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **0/2**, zero tickets. This confirms browser serial disconnection; no `broker clients` snapshot was supplied to establish other broker-client presence.
|
||||
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **26**, root **1**, status **24**, tickets **1**, assets **0**, auth-failures/response-errors **0**. Tickets issued/consumed **1**, rejected/expired **0**. WebSocket connects/disconnects **1** each; connect/service-start/broker failures **0**.
|
||||
- WebSocket RX accepted/rejected frames and bytes **0**; TX binary frames/bytes **0**, control frames **2** / **148 B**. Writer requests/grants **1**, denials/releases/revocations **0**. Send/queue/protocol failures and closes **0**; the zero closes counter is preserved separately from the reported disconnect count.
|
||||
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**; role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **18,464 B**. SSH lifecycle starts **1**, all other supplied SSH counters **0**.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`.
|
||||
- UART service **stopped**, RS-232 owner **idle**. Configuration v1: **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Phase 0 hardware commands reported available while stopped; none are claimed executed.
|
||||
- Stopping the UART service changes the allocation workload, so this sample is not a substitute for browser-disconnected memory with the service running. That separate workload is recorded in the fresh-boot sample below. Prior automatic connection/startup can contribute to lifetime minima. Exact build hash, browser/version/origin, settling duration and same-boot continuity with earlier samples remain unspecified; do not infer controlled allocation savings, reserve floors or 8D.1 validation.
|
||||
|
||||
### Browser disconnected, UART service running
|
||||
|
||||
The user supplied this follow-up 8D.0 baseline transcript on 2026-09-05 after a **fresh, settled boot**, with the UART service running and browser serial explicitly disconnected after login. It fills the intended Basic-authenticated browser/status workload row. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, then `serial status`; these are sequential user observations, not an atomic sample or an agent-executed test. Exact settling duration was not supplied.
|
||||
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **0/2**, zero tickets. No `broker clients` snapshot was supplied to establish other broker-client presence.
|
||||
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **31**, root **1**, status **28**, tickets **1**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **1**, rejected/expired **0**. WebSocket connects/disconnects **1** each; connect/service-start/broker failures **0**.
|
||||
- WebSocket RX accepted/rejected frames and bytes **0**; TX binary frames/bytes **0**, control frames **2** / **148 B**. Writer requests/grants **1**, denials/releases/revocations **0**. Send/queue/protocol failures and closes **0**; closes and disconnects are distinct reported counters.
|
||||
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**; role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **18,464 B**. SSH lifecycle starts **1**, all other supplied SSH counters **0**.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`.
|
||||
- UART service **running**, RS-232 owner **serial service**. Configuration v1: **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted: **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
|
||||
- Lifetime minima include startup and the browser's initial automatic serial connection, not just the disconnected state. This fresh boot is separate from previous samples; no controlled per-connection or service-stop allocation delta is inferred. Exact flashed revision/hash and browser/version/origin remain unspecified. This fills the workload measurement, not reserve-floor approval, serial data-integrity testing or 8D.1 target validation.
|
||||
|
||||
### After five browser serial disconnect/reconnect cycles
|
||||
|
||||
The user supplied this 8D.0 baseline transcript on 2026-09-05, reporting **five browser serial disconnect/reconnect cycles**. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, then `serial status`; these are sequential user observations, not an atomic sample or an agent-executed test. The endpoint is **connected**, not settled disconnected cleanup.
|
||||
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **1/2**, zero tickets. Slot **0**, fd **54**, generation **8**, role-`user` password authentication, broker **64 writer**, no TX pending or closing state. No `broker clients` snapshot was supplied to establish other clients or independently check old-client cleanup.
|
||||
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **152**, root **3**, status **140**, tickets **8**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **8**, rejected/expired **0**. WebSocket connects **8**, disconnects **7**, connect/service-start/broker failures **0**. These cumulative counts include more activity than the reported five test cycles; no per-cycle snapshots or counter-reset boundary were supplied.
|
||||
- WebSocket RX **11** accepted frames / **33 B**, rejected frames/bytes **0**. TX **43** binary frames / **8,413 B**, **16** control frames / **1,218 B**. Writer requests/grants **8**, denials/releases/revocations **0**. Send/queue/protocol failures and closes **0**; the zero closes counter is retained separately from seven disconnects.
|
||||
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**; role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **18,464 B**. SSH lifecycle starts **1**, all other supplied SSH counters **0**.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. UART service **running**, RS-232 owner **serial service**; configuration v1 **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
|
||||
- This records the reported five-cycle endpoint, successful cumulative admissions/writer grants and traffic without reported web failures. It does not prove byte integrity, paused reconnect behavior, observer isolation, per-cycle cleanup or a leak/fragmentation trend. Only the final normal-user role is shown; five cycles per role, including admin-role browser coverage, remain unverified. Exact flashed hash, browser/version/origin, timing and same-boot continuity with prior samples were not supplied. No reserve-floor approval or 8D.1 target validation is inferred.
|
||||
|
||||
### Updated one-browser plus user/admin SSH baseline
|
||||
|
||||
The user supplied this updated 8D.0 baseline transcript on 2026-09-05 for **one browser serial + user SSH + admin SSH**. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, then `serial status`; these are sequential user observations, not an atomic sample or an agent-executed test. The superseded original measurement was removed at the user's request.
|
||||
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **1/2**, zero tickets. Slot **0**, fd **53**, generation **1**, role-`user` password authentication, broker **8 writer**, no TX pending or closing state.
|
||||
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **2/2**, both public-key authenticated. Admin session **5**, slot **0**, routes to the admin console with no broker client; user session **6**, slot **1**, routes to broker **9 observer**. Both report active/authenticated, admin-command idle, zero admin output, no RX/TX pending or closing state. Authentication/admission remains role-based password/public-key, shell/PTY only, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **16,288 B**.
|
||||
- SSH lifecycle starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, handshake failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, writer requests **1**, grants **0**, denials **1**, failures/disconnects/revocations **0**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted/rejected **0**, TX **657 B**, I/O failures/session revocations **0**.
|
||||
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **49**, status **48**, tickets **1**, root/assets/auth-failures/response-errors **0**. Tickets issued/consumed **1**, rejected/expired **0**. WebSocket connects **1**, disconnects/connect failures/service-start failures/broker failures **0**.
|
||||
- WebSocket RX **19** accepted frames / **19 B**, rejected frames/bytes **0**. TX **39** binary frames / **7,681 B**, **3** control frames / **237 B**. Writer requests/grants **1**, denials/releases/revocations **0**; send/queue/protocol failures and closes **0**. The SSH writer denial is consistent with the browser retaining writer ownership.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. UART service **running**, RS-232 owner **serial service**; configuration v1 **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
|
||||
- No `broker clients` snapshot was supplied, so the transport snapshots establish the browser writer/user SSH observer but not the absence of additional broker clients such as USB. Exact flashed revision/hash, browser/version/origin, uptime, settling/soak duration and same-boot continuity with earlier samples remain unspecified. This records concurrent admission, traffic counters and an SSH stack low-water measurement, not byte integrity, a leak trend, reserve-floor approval or 8D.1 validation.
|
||||
|
||||
### Follow-up full-client-mix sample
|
||||
|
||||
The user supplied this replacement 8D.0 baseline transcript on 2026-09-05 for **two browser serial + USB + user SSH + admin SSH**, including broker/USB snapshots and requesting that it overwrite the previous measurement. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, `serial status`, `broker clients`, then `usb status`; these are sequential observations, not an atomic sample or an agent-executed test.
|
||||
|
||||
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **2/2**, both public-key authenticated: admin session **5**, slot **0**, admin-console route with no broker; user session **6**, slot **1**, broker **9 observer**. Both active/authenticated, admin-command idle, zero admin output, no RX/TX pending or closing state. Role-based password/public-key authentication and shell/PTY-only admission remain reported, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **16,288 B**.
|
||||
- SSH lifecycle starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, handshake failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, writer requests **1**, grants **0**, denials **1**, failures/disconnects/revocations **0**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted/rejected **0**, TX **17,312 B**, I/O failures/session revocations **0**.
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **2/2**, zero tickets, both password-authenticated normal-user sessions for the same account. Slot **0**, fd **56**, generation **2**, broker **16 writer**; slot **1**, fd **57**, generation **1**, broker **10 observer**. Neither slot TX-pending or closing.
|
||||
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **181**, root **2**, status **175**, tickets **3**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **3**, rejected/expired **0**. WebSocket connects **3**, disconnects **1**, connect/service-start/broker failures **0**.
|
||||
- WebSocket RX **25** accepted frames / **25 B**, rejected frames/bytes **0**. TX **274** binary frames / **41,456 B**, **10** control frames / **823 B**. Writer requests **3**, grants **2**, denials **1**, releases/revocations **0**; send/queue/protocol failures and closes **0**. Writer denials are consistent with observer admission, not reported transport failures.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. UART service **running**, RS-232 owner **serial service**; configuration v1 **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
|
||||
- Broker lists exactly four clients: web **16 writer** (`web-0-2`), SSH **9 observer** (`ssh-1-1`), web **10 observer** (`web-1-1`), USB **11 observer** (`usb-cdc`), all with **zero pending bytes/events**. Admin SSH is not a broker client.
|
||||
- USB CDC initialized/attached, host-open **yes**, host DTR/RTS **yes**; broker client **11 observer**. Last host line coding **38400 baud, 8N1** is reported only and does not change UART1's **460800 baud, 8N1** configuration. This confirms USB attachment/admission, not USB byte-integrity testing. Both browsers are normal-user sessions, so admin-role browser login remains unverified.
|
||||
- Exact flashed revision/hash, browser/version/origin, fixture, durations and same-boot continuity remain unspecified. This records concurrent network admission and traffic, not serial byte integrity, a timed soak, controlled per-client allocation costs or 8D.1 validation. Internal/DMA minima **17,812 / 10,056 B** are observed lows, not approved reserve floors; DMA overlaps internal heap.
|
||||
|
||||
### Settled post-soak cleanup
|
||||
|
||||
The user supplied this 8D.0 baseline transcript on 2026-09-05 and subsequently confirmed the timing: **15-minute soak**, then client disconnection, then **60 seconds of settled cleanup** before running the commands. This fills the timed soak/cleanup memory row. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, `serial status`, `broker clients`, then `usb status`; these are sequential user observations, not an atomic sample or an agent-executed test.
|
||||
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **0/2**, zero tickets. SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**. SSH role-based password/public-key authentication and shell/PTY-only admission remain reported, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **16,288 B**.
|
||||
- Broker reports **no clients connected**. USB CDC initialized/attached but host-open **no**, DTR/RTS **no**, broker disconnected. Last host line coding **9600 baud, 8N1** is reported only; UART1 remains independently configured at **460800 baud**. Physical USB attachment therefore remains, without an active broker client.
|
||||
- SSH lifecycle starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, handshake failures/timeouts/auth failures/request rejects **0**. Broker connects/disconnects **1** each, writer requests **1**, grants **0**, denials **1**, failures/revocations **0**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted **5 B**, rejected **0**, TX **84,546 B**, **I/O failures 1**, **session revocations 1**. These two nonzero counters are retained without attributing them to normal disconnect or a defect; the transcript does not establish their cause.
|
||||
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **527**, root **2**, status **521**, tickets **3**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **3**, rejected/expired **0**. WebSocket connects/disconnects **3** each, connect/service-start/broker failures **0**.
|
||||
- WebSocket RX **25** accepted frames / **25 B**, rejected frames/bytes **0**. TX **1,490** binary frames / **177,163 B**, **11** control frames / **918 B**. Writer requests **3**, grants **2**, denials **1**, releases/revocations **0**; send/queue/protocol failures and closes **0**.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. UART service **running**, RS-232 owner **serial service**; configuration v1 **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
|
||||
- Relative to the recorded full-client-mix snapshot, free internal/DMA heap is **22,984 B higher** and free PSRAM **49,768 B higher**; largest blocks return to **31,744 B internal/DMA** and **8,126,464 B PSRAM**. Lifetime minima are unchanged. These endpoint observations show headroom recovery, not a controlled per-client allocation measurement or proof of no leak. Relative to the fresh-boot browser-disconnected/UART-running sample, free internal/DMA heap is **300 B lower** and PSRAM **216 B lower**, with equal largest blocks; differing HTTP activity and uncontrolled allocation state prevent treating those differences as a diagnosed leak.
|
||||
- All serial transport clients are disconnected, but cumulative authenticated status requests are present; this is not evidence of no HTTPS polling or retained TLS connections. Exact flashed hash, browser/version/origin, fixture and traffic pattern/verified byte counts remain unspecified. The 15-minute soak and 60-second cleanup durations are user-confirmed; the reported UART configuration is **460800 baud**, not the checklist's separate **115200-baud** soak workload. Full soak/stress acceptance, SSH counter diagnosis, reserve floors and 8D.1 validation remain pending.
|
||||
|
||||
Resource gates:
|
||||
|
||||
| Gate | Budget / status |
|
||||
|---|---|
|
||||
| 8D.0 firmware allocation/route/task/socket change | **0**; documentation only |
|
||||
| Baseline internal free/minimum/largest-block reserve floors | **Pending reserve analysis and remaining workload measurements**; the recorded sample is not a safety floor |
|
||||
| PSRAM reserve and largest-block floor | **Pending**, including TLS/SSH connection and handshake peaks |
|
||||
| Stack reserve floors | **Pending** reserve analysis; SSH measurements recorded, other owner-task telemetry missing |
|
||||
| 8D.1 incremental budget | **Actual static delta +672 B**, including 664 B store/lock symbols; no module heap allocation, task, route or socket increase. Basic cache retained. Runtime reserve acceptance still pending; see implementation record below. |
|
||||
| 8D.2 incremental budget | **Pending** exact generation/session bindings added to four tickets/two slots and cleanup integration |
|
||||
| 8D.3 incremental and cumulative M1 budget | **Pending** four challenge records, throttle, five handlers, parser/CSRF scratch, minus removed Basic cache/key; no new task/socket allowance |
|
||||
| M2 admin allocation/socket budget | **Pending** retained-serial admission design and target M1 measurements; not approved here |
|
||||
|
||||
To close these gates, record each chunk's actual linked/static and allocated structure sizes, internal-versus-PSRAM placement and worst-case fallback, then measure peak incremental handshakes/traffic and post-cleanup fragmentation under the same workloads. Set numeric reserve floors sufficient for measured recovery/USB/SSH needs and the largest required allocation, with an explicitly justified margin; subtract these floors before approving incremental/cumulative budgets. A percentage of total RAM or a source payload subtotal is not a reserve. Stop on monotonic leak/declining largest block, watchdog/stack fault, lost lease or inaccessible login.
|
||||
|
||||
Target operator checklist (user-provided samples cover status/memory collection and concurrent admission of two web serial clients, USB and user/admin SSH, with traffic counters and broker/USB snapshots confirming four broker clients and USB host DTR. Remaining checks are unverified, and none were executed by the agent):
|
||||
|
||||
1. Identify flashed revision/configuration against this record; record browser/version, IP/mDNS origin, Wi-Fi mode, serial framing/flow/baud and attached peer/fixture. Use existing provisioned accounts; do not expose credentials in evidence. Firmware upload is a separate user-controlled action; no erase is required for this documentation task.
|
||||
2. On UART0 run `memory`, `serial status`, `broker status`, `broker clients`, `usb status`, `web status`, `ssh status` at settled boot and each table point. Record output without secrets. Use `pio device monitor -b 115200` once a port is available; this is interactive, not a completed automated test.
|
||||
3. In separate browser profiles authenticate an existing admin and user through **current Basic auth**; verify protected assets/status and bidirectional serial bytes, single writer/request/release, and observer isolation. Test direct-IP and mDNS separately where STA mDNS is available. If baseline login fails, stop and diagnose in a separate task, not during auth replacement.
|
||||
4. Verify native USB UART1 with host DTR, user SSH serial and admin SSH console, while UART0 remains usable. Open two browser serial sockets plus USB, one user SSH and one admin SSH; collect the full-workload row. This is four broker clients and two SSH sessions; admin SSH is not a broker client. Also check the alternative two-user-SSH mix (five broker clients) without increasing any limit.
|
||||
5. Run **five** explicit browser serial Disconnect/Connect cycles per role, observing paused reconnect, old broker cleanup, writer/observer correctness and memory after each cycle. Baseline has no logout: do not report Basic-cache reset as a logout test. M1 will repeat five login/serial/logout cycles per role instead.
|
||||
6. Run a **15-minute** concurrent soak at **115200 baud, 8N1, no flow control**, using a safe known peer/fixture and a recorded binary pattern/byte count. Transfer writer ownership explicitly among transports, collect memory/status every minute, and record bytes/drops, latency and any watchdog/reset. Repeat a separately recorded **five-minute 460800-baud** stress run with the same mix; historical drops at that load are not permission for new exhaustion or lease loss. These workloads do not reduce supported baud rates. Record any fixture limitation rather than inventing results.
|
||||
7. Disconnect network clients and USB DTR, wait 60 seconds and capture cleanup memory/status. Compare free/largest-block recovery and lifetime minima with boot/full-load rows. Record SSH stack margin; explicitly retain other stack floors as pending until instrumentation is separately scoped.
|
||||
8. Fill measured reserve/incremental budget cells with justified numbers and review remaining failures. The user explicitly requested 8D.1 with this gate pending; that is not approval to skip later regression checks or M1/M2 milestone pauses.
|
||||
|
||||
## 8D.1 implementation and validation record
|
||||
|
||||
- **Checkpoint:** Work started from clean `devel` at `02fdeee3453654680c11096c9a6224c26233eced` (documentation-only successor to the build baseline). The user requested 8D.1 after the initial runtime sample and provided the full-client-mix sample while work began. Remaining reserve floors are explicitly pending; this is not an automatic budget approval for subsequent chunks.
|
||||
- **Implemented:** `src/web_session_store.{h,c}` provides four fixed internal records; 32-byte token digests, 32-byte origin digests, separate 32-byte CSRF state, copied principals, one-hour absolute deadlines and non-reused 64-bit IDs. Issue/lookup/currentness/prune/session and user invalidation APIs exist, plus secret-free counts/storage sizes. No raw bearer token is retained. Failed output and retired record cleanup is explicit. Stop wipes records, cancels in-flight issuance/init and never resets IDs/epochs. No task, module heap allocation or internal-fallback allocation is introduced.
|
||||
- **Concurrency:** A short portMUX protects only bounded state operations; RNG/SHA/database work is outside it. Resolution rechecks ID/liveness/deadline after principal validation. Issuance rechecks an invalidation epoch before publication; any explicit invalidation conservatively cancels concurrent issuance, including issuance for another account, without removing unrelated live sessions. Epoch/ID exhaustion is fail-closed. Snapshot counts are cumulative for the boot; views containing CSRF are sensitive request-local results, never status snapshots. HTTP Origin validation remains the future caller's responsibility; the primitive only binds a bounded canonical origin digest.
|
||||
- **Lifecycle integration:** Only an admitted HTTPS start initializes the store; failed starts and accepted stops disable it before cleanup, including failed teardown. Store-init failure is warning-only for the unchanged Basic-auth service. No production caller issues/looks up sessions yet. No login page, cookie route, ticket binding, account-mutation notification hook, new URI, protocol change or generated asset change. Those remain 8D.2/8D.3 scope.
|
||||
- **Build:** Final `pio run` passed in 8.90 seconds after review fixes, with the same PlatformIO/IDF configuration: **95,204 B linked RAM (+672 B)**, **1,600,505 B flash (+532 B)** relative to 8D.0. Target object symbol inspection reports **656 B state + 8 B portMUX = 664 B**; DWARF confirms **152 B per record × 4 = 608 B**, principal 40 B. The aggregate link delta includes eight additional bytes beyond those store symbols. All storage coexists with the existing Basic cache. No post-change runtime heap claim is made.
|
||||
- **Dormant-code accounting:** All functions compile and are exercised by the host harness, but the firmware link currently retains only init/stop and their dependencies; unused issue/lookup/etc. code is garbage-collected. The static state is retained in full. Later production callers must account for the newly linked code and stack/crypto execution costs rather than treating this flash delta as the full M1 cost.
|
||||
- **Focused executable checks:** `python3 tests/web_session_store/run.py` passed using OpenSSL SHA-256 and deterministic RNG/database/time/FreeRTOS doubles. Coverage includes failed init/retry/idempotence, capacity/no eviction, token/origin mismatch, exact expiry/no renewal, per-session/account isolation, stale principals and DB failure, stop/reinit/stale IDs, invalidation during pruning and candidate issuance, replacement during lookup, expiry during revalidation, stop during init, and post-token SHA failure output wiping. RNG/SHA/database doubles assert that no store lock is held. This narrow harness is not a general firmware test suite.
|
||||
- **Review-only limits:** ID/epoch exhaustion, collision rejection and private record wiping were inspected, not forced by public-API tests. Callback races are deterministic interleavings, not real multicore scheduling. Host tests do not test device DRBG, mbedTLS integration, HTTPD lifecycle races, heap/stack margins or future dormant browser paths. Independent source review found and verified the fix for reinitialization during a rejected concurrent start.
|
||||
- **Post-change target gate: validated by user sign-off on 2026-09-05.** In addition to the boot and full-client-mix samples below, the user reports a long-lasting command producing output at full **115200-baud line speed with no dropped broker packets**, and explicitly requests 8D.1 validation closure. Exact command, duration and byte/drop-counter totals were not supplied; this is user-reported hardware evidence, not an agent-executed test. Unrecorded detailed lifecycle/HTTPS restart/cleanup checks remain regression coverage, not blockers to this sign-off or claims of execution. Numeric reserve approval and target testing of future cookie-authentication paths are not implied. Stop before 8D.2 until requested.
|
||||
|
||||
### 8D.1 target samples: boot and full client mix
|
||||
|
||||
Supplied by the user on 2026-09-05 as **Phase 8D.1 validation**. Each transcript runs `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, `serial status`, `broker clients`, then `usb status`. These are sequential user observations, not atomic samples or agent-executed tests. Exact flashed revision/hash, boot settling interval, loaded duration, browser/version/origin and serial fixture were not supplied for this phase; the 8D.0 source sign-off does not identify this changed firmware.
|
||||
|
||||
| 8D.1 workload | Internal free/min/largest | DMA free/min/largest | PSRAM free/min/largest | SSH stack minimum-free |
|
||||
|---|---|---|---|---|
|
||||
| Fresh boot, UART stopped, no broker clients | 69,564 / 67,312 / 31,744 B | 61,808 / 59,556 / 31,744 B | 8,223,100 / 8,216,900 / 8,126,464 B | 18,464 B |
|
||||
| SSH writer + admin SSH + USB observer + two web observers; UART running at 115200 baud | 41,420 / 19,384 / 31,744 B | 33,664 / 11,628 / 31,744 B | 8,162,824 / 8,115,028 / 8,126,464 B | 16,288 B |
|
||||
|
||||
- **Both samples:** HTTPS and SSH initialized/running, not transitioning, ports **443/22**, `last-error=ESP_OK`. HTTPS retains HTTP Basic via the user database, four users/two admins and unchanged endpoints. SSH retains role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. SSH owner core **1**, configured stack **20,480 B**. mDNS initialized/announced as `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`.
|
||||
- **Boot state:** SSH **0/2**, WebSocket **0/2**, web serial attached, zero tickets, no broker clients. UART stopped, RS-232 owner idle; configuration v1 **115200 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**, RX available/TX pending **0**. Phase 0 commands reported available, not executed. USB initialized/attached, host-open/DTR/RTS **no**, broker disconnected; reported host line coding **9600 baud, 8N1**.
|
||||
- **Boot counters:** SSH starts **1**, every other supplied SSH counter **0**. Web starts **1**, start-failures/stops **0**; requests total/authenticated/status **9**, root/tickets/assets/auth-failures/response-errors **0**. All ticket, WebSocket session/RX/TX/control/failure counters **0**. Thus the boot sample includes authenticated status activity, not a no-HTTP-traffic idle state.
|
||||
- **Loaded clients:** SSH **2/2**, public-key user session **5**, slot **0**, broker **8 writer**; public-key admin session **6**, slot **1**, admin-console route, no broker. Both active/authenticated, admin-command idle, zero admin output, no RX/TX pending or closing state. Web **2/2**, both password-authenticated normal-user observers for the same account: slot **0**, fd **55**, generation **1**, broker **10**; slot **1**, fd **56**, generation **1**, broker **11**. Zero tickets, neither web slot TX-pending or closing.
|
||||
- **Loaded broker/USB/UART:** Exactly four broker clients: SSH **8 writer**, USB **9 observer**, web **10/11 observers**, all with zero pending bytes/events. USB initialized/attached, host-open/DTR/RTS **yes**, broker **9 observer**, reported host line coding **38400 baud, 8N1**. UART running, owner serial service, unchanged **115200-baud 8N1** configuration; RX available/TX pending **0**, modem **DCD=0, DSR=1, CTS=1, RI=0**, **VLD=1**. USB line coding does not reconfigure UART1.
|
||||
- **Loaded SSH counters:** Starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, failures/disconnects **0**, writer requests/grants **1**, denials **0**, **broker revocations 1**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted **152 B**, rejected **0**, TX **20,012 B**, I/O failures/session revocations **0**. The broker revocation is preserved as an unexplained cumulative event, distinct from session revocation; the current snapshot confirms SSH writer ownership.
|
||||
- **Loaded web counters:** Starts **1**, start-failures/stops **0**. Requests total/authenticated **119**, status **117**, tickets **2**, root/assets/auth-failures/response-errors **0**. Tickets issued/consumed **2**, rejected/expired **0**. WebSocket connects **2**, disconnects/connect failures/service-start failures/broker failures **0**. RX accepted/rejected frames/bytes **0**; TX **334** binary frames / **37,242 B**, **5** control frames / **398 B**. Writer requests **2**, grants **0**, denials **2**, releases/revocations **0**; send/queue/protocol failures and closes **0**. Denials are consistent with both browsers being observers.
|
||||
- **Interpretation:** These samples establish post-change service startup, authenticated status requests and concurrent admission/traffic with the full client mix, without reported SSH I/O or web failures. They do not establish byte integrity, lifecycle/soak completion or target exercise of dormant cookie-session APIs. Boot UART is stopped; loaded UART is **115200 baud** and SSH is writer, unlike the **460800-baud browser-writer** 8D.0 full mix. No controlled incremental heap cost or memory improvement is inferred. DMA overlaps internal heap; lifetime minima and SSH stack low-water marks are not approved reserve floors.
|
||||
@@ -0,0 +1,190 @@
|
||||
# Phase 8D — Incremental web administration plan
|
||||
|
||||
Status: **8D.0–8D.5 and M1 validated by explicit user sign-off. 8D.6 implemented, host-tested and build-verified; target validation and independent parent review pending, no phase sign-off. 8D.5 closure on 2026-09-06 stands. Numeric reserve gates remain open. 8D.7–8D.22 remain planned, each requiring a separate implementation request.** See the [8D.6 implementation record](phase8d6_implementation.md), [8D.5 implementation record](phase8d5_implementation.md), [8D.4 implementation record](phase8d4_implementation.md), [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md).
|
||||
|
||||
This is the execution plan for [roadmap Phase 8D](roadmap.md#phase-8--role-based-users-and-administrative-access). The roadmap retains the feature/security requirements; this document defines small work units, dependencies, and release gates. The [administration test matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration) remains the final acceptance checklist.
|
||||
|
||||
## Why this phase is split
|
||||
|
||||
The previous all-in-one attempt was rolled back from `devel`. The user reports a roughly +10k/-1k-line change, repeated agent/time/context interruptions, incomplete validation, broken browser login, and severe memory pressure. These are reported symptoms, not a diagnosed root cause. The separate experimental branch is not the implementation baseline: do not merge/cherry-pick it wholesale or copy its abstractions without a separately scoped review.
|
||||
|
||||
Deliver three independently useful milestones before attempting feature completeness:
|
||||
|
||||
1. **M1: reliable browser login/logout and the existing serial UI** — 8D.0–8D.3.
|
||||
2. **M2: browser admin shell alongside uninterrupted serial access** — 8D.4–8D.7.
|
||||
3. **M3: guided administration, one settings domain at a time** — 8D.8–8D.21.
|
||||
|
||||
8D.22 is final integration acceptance, not the first time anyone builds or tries the firmware. Stopping after M1 or M2 is valid incremental delivery, but does not mean all of Phase 8D is complete.
|
||||
|
||||
## Work-unit rules
|
||||
|
||||
- **One numbered chunk per implementation request.** Do not interpret “continue Phase 8D” as permission to implement all remaining chunks. Select the first unblocked chunk and state its scope before editing.
|
||||
- Each chunk should fit one normal agent session, including review, a bounded build, focused validation, and handoff. Plan for roughly 60–90 minutes of implementation and reserve at least the final third for validation/documentation. These are scope limits, not runtime guarantees; split further before coding if the estimate does not fit.
|
||||
- Aim for a few hundred changed authored lines in a small source set. An expected change above roughly 600–800 authored lines, more than one new task, or several independent subsystem changes triggers a scope review and another split. This is not an incentive to compress code, omit tests, or hide generated changes.
|
||||
- Declare allowed files, behavior changes, explicit exclusions, resource deltas, and acceptance checks first. Source sets below are starting points, not permission to refactor every listed module. New files must have a narrow responsibility justified by that chunk.
|
||||
- Build the smallest complete increment. Internal preparatory chunks may leave unused interfaces, but must not expose half-protected routes. Do not ship an intermediate cookie-authentication route without CSRF/origin protection, currentness checks, expiry, and logout cleanup.
|
||||
- Reuse subsystem APIs, the existing HTTPD ownership model, the canonical command dispatcher, and authored UI assets. Do not introduce a generic web framework, CLI-over-HTTP endpoint, second command registry, second dispatcher, task-per-request model, or speculative settings infrastructure.
|
||||
- Do not rename/extract the entire `admin_ssh_console` module just to give it a generic name. Adapt the smallest necessary boundary and retain existing SSH callers. Do not regenerate `src/web_assets_data.*` or update vendored xterm dependencies as incidental work.
|
||||
- Add focused counters and checks with the feature that needs them, not as a final observability project. New tests must be local/bounded and document their exact command; this repository currently has no automated host test command. Avoid scaffolding a general test platform as part of implementation.
|
||||
- Run `pio run` with a finite tool timeout. A timeout is **incomplete validation**, not a pass; record it and stop rather than starting overlapping/repeated build jobs. Fix only failures caused by the chunk.
|
||||
- End with a reviewed diff and handoff: implemented behavior, build result, memory delta, checks actually run, hardware checks pending, and exact next chunk. Keep `docs/agent/current-state.md` current. Never include credentials, cookies, CSRF values, tickets, or verifier material in evidence.
|
||||
- A chunk is **implemented / build-verified / target-verified** as separate states. If device access is unavailable, provide the small manual checklist and mark it blocked for target validation. Do not proceed past an M1/M2 gate, or stack further runtime-changing chunks on an unverified predecessor, without an explicit user decision. Never silently promote documented tests to passed tests.
|
||||
- No automatic commits, branch changes, uploads, erase, NVS migration, or imports from the abandoned branch. The user chooses commit/revert checkpoints; preserve independently reviewable diffs.
|
||||
|
||||
## Baseline and resource gates
|
||||
|
||||
8D.0 must record the actual baseline revision and configuration before resource budgets become acceptance criteria. The current memory notes report 94,532 bytes linked RAM and 1,599,765 bytes flash for an mDNS-enabled build; these are historical reference values, **not a fresh measurement or sufficient runtime headroom**.
|
||||
|
||||
For every chunk that changes allocation, concurrency, or routes:
|
||||
|
||||
- Inventory static internal RAM, normal and worst-case internal heap, PSRAM, task count/stack sizes, request/response scratch buffers, queue depth, HTTPD handlers, sockets/TLS connections, and session/ticket capacity. Record limits before increasing any of them. Cookie sessions, serial sockets, admin sockets, and outstanding tickets are different resources.
|
||||
- Use UART0 `memory` to record internal/PSRAM free, minimum-free, and largest-block values at the same defined workload points: settled boot, login, serial connected, admin connected when available, full supported concurrent workload, and after repeated close/logout/reconnect. Compare both each chunk and cumulative growth against the baseline. Collect relevant task stack high-water marks where available; explicitly record missing instrumentation.
|
||||
- Set numeric internal-heap/largest-block/stack reserve floors and per-chunk incremental budgets from baseline measurements and actual allocation sizes in 8D.0. No invented “safe free heap” constant and no percentage-of-total-RAM substitute. If the floor cannot be measured, mark the gate pending rather than guessing.
|
||||
- Account for **two simultaneous browser WebSockets** when serial and admin coexist, plus HTTPS requests and existing SSH connections. Capacity rejection must be explicit and must not silently evict an active serial client/writer to admit an admin socket. Do not merely raise HTTPD/lwIP limits until a page happens to work.
|
||||
- Prefer bounded PSRAM payload storage only where cache-disable/lifetime constraints allow it; retain required internal control structures/stacks. Record fallback behavior: opportunistic internal fallback must not consume the recovery reserve unnoticed. Optional web-admin allocation failure must leave current serial, UART0, USB, and SSH paths usable.
|
||||
- Do not accept a monotonic heap leak, declining largest-block trend, watchdog/stack fault, unexplained reserve-floor violation, or unrecoverable login failure. Stop and fix/split the current chunk instead of borrowing from future budgets. Supported baud rates and client capacities must not be silently reduced to pass.
|
||||
|
||||
### Always-on regression smoke check
|
||||
|
||||
After each runtime-changing chunk: build; boot and read UART0 status/`memory`; log in through the currently supported browser authentication path; open serial, explicitly disconnect and reconnect; exercise native USB UART1 access and existing user/admin SSH routes. Add the chunk-specific checks below. Use a fixed small repetition count selected in 8D.0 for routine lifecycle checks and a longer bounded soak at milestone gates; record actual counts and durations, not just “stable.”
|
||||
|
||||
## M1 — Authentication without changing the rest of the UI
|
||||
|
||||
### 8D.0 — Baseline, browser contract, and resource budget
|
||||
|
||||
**Scope:** Documentation and measurement only. Read the relevant `web_server`, `web_serial_transport`, `web_ui`, user-principal, and memory-reporting paths. Confirm current login/serial operation on rolled-back `devel`; no investigation of the experimental branch is required.
|
||||
|
||||
**Deliver:** A short baseline record, measured resource table, supported concurrency/socket budget, and exact M1 browser contract: public login assets; protected routes; session/ticket capacities and lifetimes; absolute/idle expiry rules; capacity rejection; cookie renewal; pre-login CSRF bootstrap; strict origin policy; no-store responses; logout-versus-account-wide revocation; and browser error handling. Preserve mDNS and direct-IP access as separate host-only cookie origins. Choose the simplest bounded policy, with no Basic compatibility path by default. Explicitly list the few authentication request/response fields rather than designing all future settings APIs.
|
||||
|
||||
**Gate:** Existing admin and user browser login, serial data, USB, and SSH work; baseline `pio run` and target memory evidence are recorded. If the rolled-back baseline already fails login, diagnose that in a separate task before changing authentication. If target evidence is unavailable, the budget and runtime gate remain pending.
|
||||
|
||||
**Record:** [8D.0 baseline, browser contract, resource inventory and target checklist](phase8d_baseline.md). **Validated by user sign-off on 2026-09-05**, with tested source identified as `d8999cd4a96e477fabd392ced02d810c3cd22d0f`. Historical baseline build: 94,532 B linked RAM / 1,599,973 B flash. User-provided boot, browser, lifecycle, mixed-client and 15-minute soak/60-second cleanup samples include heap and SSH stack measurements. The user attributes SSH I/O errors to out-of-spec 460400-baud testing; see sign-off for distinction from reported UART configuration. Numeric reserve floors and incremental budgets remain open, without blocking user-approved 8D.0 closure. Do not treat the documented contract as implemented behavior.
|
||||
|
||||
### 8D.1 — Bounded server-side session primitives, not yet browser-facing
|
||||
|
||||
**Start in:** `src/web_server.{c,h}`, `src/user_database.h`, `src/secure_random.h`; add a narrowly owned session module only if needed.
|
||||
|
||||
**Scope:** Fixed-capacity session issue/lookup/expiry/invalidation with digest-only token storage, copied principal and authentication-generation binding, CSRF state, and secret-free capacity/expiry/invalidation counters. Decide ownership/locking explicitly because future console revocation is not necessarily on the HTTPD task. Wipe transient secrets and use existing secure randomness. No login page, HTTP auth cutover, admin route, new permanent task, or settings work.
|
||||
|
||||
**Gate:** Focused local/component checks for lifecycle, capacity, slot reuse, stale principals, and failed initialization; bounded storage accounting and build. Existing Basic-auth behavior remains unchanged. If no executable harness is practical, distinguish code review from target execution and carry the missing checks into 8D.3; do not claim unused code was exercised by a boot test.
|
||||
|
||||
**Record:** Implemented `web_session_store` plus admitted-start/stop lifecycle hooks; no route uses it yet. `python3 tests/web_session_store/run.py` and `pio run` pass. Linked RAM **95,204 B (+672 B)**, flash **1,600,505 B (+532 B)** versus 8D.0; static store/lock symbols total 664 B, no module heap allocation/new task/routes/sockets. Only init/stop are currently retained in the firmware link; host tests exercise the full production module. See the [8D.1 implementation record](phase8d_baseline.md#8d1-implementation-and-validation-record) for exact accounting, review limits and target evidence. **Target validated by user sign-off on 2026-09-05**, following boot/full-client-mix samples and a reported long-lasting command at full 115200-baud line speed with no dropped broker packets. Numeric reserve gates remain open. Stop before 8D.2 until requested.
|
||||
|
||||
### 8D.2 — Bind existing serial tickets and sockets to a web-session identity
|
||||
|
||||
**Start in:** `src/web_serial_transport.{c,h}`, `src/web_server.{c,h}`, the session module from 8D.1, and the existing revocation call sites in `src/user_console.c`.
|
||||
|
||||
**Scope:** Add generation-safe originating web-session identity to serial tickets/slots, specific-session cleanup, and account-wide invalidation hooks. Keep web-session identity distinct from account authentication generation and transport slot generation. Preserve authoritative currentness checks when best-effort notification fails. Keep the current Basic path working until the atomic cutover; no public cookie-auth route yet.
|
||||
|
||||
**Gate:** Build and existing serial regression. Exercise session-specific versus account-wide cleanup through focused checks where available: one session's logout must not disconnect another session for the same unchanged account; account mutation must invalidate all affected account sessions/tickets. Stale cleanup cannot close a reused slot. Record any dormant paths that require the next chunk's browser validation.
|
||||
|
||||
**Record:** [8D.2 implementation, accounting and target checklist](phase8d2_implementation.md). Distinct session IDs now bind tickets/slots; internal cleanup and account-notification hooks are present, while Basic remains the only public authentication path. Both focused host modes and `pio run` pass: **95,260 B linked RAM (+56 B)**, **1,601,925 B flash (+1,420 B)** versus 8D.1. No capacity/task/route/asset changes. **Target validated by user sign-off on 2026-09-05**, following post-flash boot and full-client-mix samples. Sanitizer execution is blocked by missing host runtime libraries; numeric reserve gates remain pending. Stop before 8D.3 until separately requested.
|
||||
|
||||
### 8D.3 — Atomic login/logout cutover with the unchanged serial application
|
||||
|
||||
**Start in:** `src/web_server.{c,h}`, `src/web_ui.{c,h}`, session primitives, and the session-bound serial integration.
|
||||
|
||||
**Scope:** Minimal same-origin login page, session status, explicit logout, bounded login throttling, and cookie-based authorization of the existing app/status/ticket routes. Use a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain`, and explicit lifetime. Enforce the agreed pre-login CSRF protection plus strict origin checks on login, and session CSRF plus strict origin checks on every authenticated mutation including logout and ticket issuance. Keep passwords/tokens transient, preserve security headers/CSP, update the loader hash atomically if it changes, and handle JSON/text safely. Remove browser Basic challenges/cache authorization so stale credentials cannot bypass logout. No admin shell, settings, visual redesign, or new serial protocol.
|
||||
|
||||
**Gate — M1 (mandatory target/browser pause):** Both roles log in; incorrect credentials give a usable error; logout and account switching need no browser credential-cache reset. Refresh/reboot/expiry return to a usable login; no redirect loop or missing login asset. Test fresh and previously Basic-authenticated browser profiles, direct IP and mDNS where available, bounded capacity/backoff, CSRF/origin rejection, current-session logout cleanup, password/role/key changes and deletion/recreation via UART0, and unrelated-session/account isolation. Existing serial data, writer ownership, and explicit reconnect still work. Record memory before/during/after repeated login/serial/logout and simultaneous SSH/USB operation. Stop here for user confirmation before M2.
|
||||
|
||||
If 8D.3 exceeds the work-unit limit, first split out inert login-page rendering or private request-parsing helpers. Do not split the live security cutover into an insecure intermediate deployment.
|
||||
|
||||
**M1 sign-off (2026-09-06):** User explicitly completed M1 after successful both-role login, full-client-mix operation and post-soak telemetry. See [sign-off/evidence](phase8d3_implementation.md#m1-validation-sign-off-and-post-soak-evidence-2026-09-06). Minima/largest blocks are unchanged from the earlier loaded sample, with no reported transport failures. Clients remain connected; exact soak duration/revision and disconnected cleanup are not claimed. Numeric reserves stay open; unrecorded detailed checks are evidence limitations, not blockers to the user-approved milestone closure. **Wait for a separate 8D.4 request.** Older checkpoint notes below are historical.
|
||||
|
||||
**Live cutover checkpoint (2026-09-05):** [Implementation, HTTPD boundary, resource accounting and M1 handoff](phase8d3_implementation.md). Cookie login/logout replaces Basic for app/status/ticket routes; explicit pre-101 admission and strict header/Origin/CSRF policy use an isolated version-checked private IDF adapter, **not an SDK patch**. Resumed another agent's implementation and fixed pending-buffer wiping to preserve right-aligned unread data. All five focused suites and `pio run` pass. Final **95,508 B RAM / 1,625,689 B flash**, +248/+23,764 B versus 8D.2. No hardware/browser execution or reserve-floor approval. **Stop for mandatory M1 acceptance before 8D.4.** The following preparatory records are historical, superseded for current implementation status.
|
||||
|
||||
**Preparatory split (2026-09-05):** Scope review selected private request parsing first; the complete challenge/throttle/route/browser/test change exceeds the authored-line work-unit target. Added allocation-free `src/web_auth_parse.{c,h}` with no live HTTP callers: bounded canonical same-origin comparison, unique cookie extraction and strict UTF-8 login JSON decoding. `python3 tests/web_auth_parse/run.py` passes **268 cases** against production C; both existing session-store host modes pass. Final `pio run` passes in **7.50 seconds** and reports **95,260 B RAM / 1,601,925 B flash**, unchanged from 8D.2 because helpers are not live linked paths. No route/task/socket/stack-size/asset changes. No target/browser validation, runtime reserve approval or M1 completion is implied. Continue within **8D.3**, with the full atomic cutover still pending; see `docs/agent/current-state.md` for exact integration obligations.
|
||||
|
||||
**Login-renderer preparatory split (2026-09-05):** Added `src/web_login_ui.{c,h}` with no live caller/route, leaving Basic and existing serial UI unchanged. Standalone 7,387-byte HTML plus terminator has no protected asset dependencies, five security headers including no-store and exact script-hash CSP. Explicit-only challenge/login flow, bounded request/response handling, safe errors/manual backoff, disabled pending inputs, best-effort password/reference cleanup and generation-safe page-exit cancellation. Review fixes abort every attempt on exit (including unread error bodies) and clear re-entered passwords. `python3 tests/web_login_ui/run.py` passes production C rendering/failure checks, exact CSP hash and eight Node VM groups; parser and both session test modes also pass. Final `pio run` passes in **8.25 seconds**, unchanged **95,260 B RAM / 1,601,925 B flash**: unused renderer costs are not live-linked/runtime costs yet. No new task/socket/route/stack-size/module heap or generated-asset change. No real-browser/HTTPD/hardware validation or M1 completion. **Next is the atomic live 8D.3 cutover using both prepared pieces**, followed by the mandatory M1 target gate; no additional login-rendering split is needed.
|
||||
|
||||
## M2 — Reuse the admin shell, then expose it
|
||||
|
||||
### 8D.4 — Small transport-neutral console boundary
|
||||
|
||||
**Implementation checkpoint (2026-09-06):** [8D.4 implementation, resource accounting and target sign-off](phase8d4_implementation.md). Implemented / host-tested / build-verified / validated by explicit user sign-off after boot/full-client-mix evidence and successful empty Enter and soak testing. Owner callbacks and transport-qualified identity retain the existing two shared slots and SSH API, with no browser routes. Build **95,084 B RAM / 1,627,173 B flash**, **-424 / +1,448 B** versus recorded latest 8D.3. Numeric reserves remain open; unrecorded detailed checks do not reopen this user-approved closure. Wait for a separate 8D.5 request.
|
||||
|
||||
**Start in:** `src/admin_ssh_console.{c,h}`, `src/console_input.{c,h}`, and only the necessary `src/ssh_transport.c` callers.
|
||||
|
||||
**Scope:** Introduce the minimal transport identity/output/lifecycle boundary needed by a future web frontend. Retain the single dispatcher, fixed queue, line editing/history/completion/prompts, currentness checks, generation tokens, and existing SSH API compatibility where practical. Do not duplicate per-SSH buffers for hypothetical web capacity or rename the whole module. No web endpoint or second dispatcher/task.
|
||||
|
||||
**Gate:** UART0/admin-SSH serialization, hidden prompts, completion/history, deferred actions, disconnect/revocation with queued work, and slow-output behavior regressions pass. Memory delta is explained before adding browser slots. This refactor must stand alone and leave behavior unchanged.
|
||||
|
||||
### 8D.5 — Bounded admin WebSocket backend, no normal UI entry yet
|
||||
|
||||
**Target sign-off (2026-09-06):** User explicitly validates 8D.5 after settled cold-boot telemetry and successful 15-minute full-client-mix active-use soak at **230400 baud**, reporting a few broker drops under extremely fast/dmesg output. [Evidence and limitations](phase8d5_implementation.md#target-sign-off-2026-09-06). This supersedes the older pending acceptance/checkpoint notes below. No zero-drop claim or loaded/cleanup telemetry is inferred. Numeric reserves remain open; missing detailed results do not reopen signed-off 8D.5. Wait for a separate 8D.6 request; M2 is not yet complete.
|
||||
|
||||
**Combined backend checkpoint (2026-09-06):** User authorized finishing all of 8D.5, superseding the prerequisite-only pause below. Backend, shared-console allocation, protected admission, revocation, fail-before-side-effect restrictions and local/manual test tooling are **implemented / host-tested / build-verified**. All relevant host suites pass, including real cookie/store/ticket/transport endpoint integration. Parent reports the sequential final `pio run` after the HTTPD-owned shutdown/reuse fix passed at **95,580 B RAM / 1,637,273 B flash**, **23.55 s**. Deltas: **+416/+9,224 B** versus prerequisite, **+496/+10,100 B** versus 8D.4, **+1,048/+37,300 B** versus 8D.0. Final independent security integration review reported no actionable findings. **Target runtime/socket measurements, numeric reserves and acceptance remain pending.** No device operation or 8D.6/UI/M2 completion. Details and historical build evidence: [implementation record](phase8d5_implementation.md). 8D.6 onward remain separately requested work.
|
||||
|
||||
**Preparatory checkpoint (2026-09-06):** [8D.5 prerequisite, validation and handoff](phase8d5_implementation.md). Resumed existing uncommitted console-owner currentness/prompt cleanup work; reviewed and extended production-publication tests. Both console suites and `pio run` pass: **95,164 B RAM / 1,628,049 B flash**, **+80 / +876 B** versus recorded 8D.4. No routes/tasks/sockets/UI added. Work-unit review keeps the live backend in the next increment within 8D.5; backend/M2 remain incomplete. Target regression or explicit user decision is needed before stacking runtime changes; numeric reserves remain open.
|
||||
|
||||
**Start in:** The console boundary from 8D.4, `src/web_server.{c,h}`, and a small web-admin transport adapter as justified. Reuse existing HTTPD scheduling patterns without mixing admin data into the serial transport.
|
||||
|
||||
**Scope:** Admin-only, short-lived single-use tickets bound to both current web session and principal; bounded console admission/input/output; session expiry/logout/revocation cleanup. HTTPD owns socket work and the dispatcher owns command execution. No broker client for this route. An absent UI is not authorization: every ticket, upgrade, and sensitive operation is checked on the server. For self-affecting web actions not safely supported yet, explicitly reject before side effects and list the temporary restrictions for 8D.7.
|
||||
|
||||
**Gate:** Focused authenticated test-client or temporary local development-page checks (not a shipped debug endpoint): user-role rejection, admin command/output, prompt/backpressure, stale ticket/slot rejection, cleanup, and concurrent UART0/admin SSH. Admission failure does not remove the serial writer. Build and measure actual socket/console-slot cost. No generic HTTP command runner.
|
||||
|
||||
### 8D.6 — Browser terminal selector and serial-lease preservation
|
||||
|
||||
**Target sign-off (2026-09-06):** User confirms the remaining validation checks after 60-second boot/full-client-mix/partial-cleanup telemetry, and closes 8D.6. [Evidence and handoff](phase8d6_implementation.md#target-sign-off-and-evidence-2026-09-06). Full mix at **230400 baud** includes browser/admin SSH and four serial broker clients with one web writer. Internal/DMA lifetime minima **6,516 / 1,580 B** remain a numeric-reserve follow-up, not a claim of exhaustion or grounds to reopen sign-off. Exact flashed revision/browser/repetition counts/soak duration were not separately supplied. Latest cosmetic toolbar build passes in **7.60 s**, unchanged **95,580 B RAM / 1,646,489 B flash**, with 17 UI groups/CSP checks passing. Supersedes older pending notes below. Wait for separately requested **8D.7**; M2 remains open.
|
||||
|
||||
**Implementation checkpoint (2026-09-06, updated after review fixes):** [8D.6 implementation, accounting and target checklist](phase8d6_implementation.md). Implemented / host-tested / build-verified; target validation and parent re-review of fixes pending, no phase sign-off or M2 completion. Both P2 findings fixed: session identity changes require a clean document before adopting a new view, and fit caches only successful measurements with three bounded readiness retries. Sixteen production-rendered UI groups and focused UI/auth/store reruns pass; prior admin/console regression results remain recorded. Final finite `pio run`: **21.35 s, 95,580 B RAM / 1,646,489 B flash**, **0 / +9,216 B** versus 8D.5; cumulative **+1,048 / +46,516 B** versus 8D.0; review-only delta **0 / +1,376 B**. No backend/capacity/8D.7 restriction/asset change. Numeric reserves remain open; prior sign-offs stand. Stop for validation/user decision before separately requested 8D.7. This supersedes older planned-8D.6 status text in this document.
|
||||
|
||||
**Start in:** `src/web_ui.{c,h}`, using the completed serial/admin protocols.
|
||||
|
||||
**Scope:** Add admin-only Serial/Admin selection and separate bounded terminal state; no new settings. Keep serial connected and drained/observed while hidden. Keep writer/observer identity and Request control/Release control visible in both modes. Mode changes route displayed output and keyboard input only. Closing the admin route or shell `exit` leaves serial intact; explicit serial Disconnect retains its documented cleanup/reconnect behavior. Bound scrollback and avoid leaking browser listeners/sockets across switches.
|
||||
|
||||
**Gate:** Repeated switches preserve the same broker client ID and writer ID; background serial remains observed, with any bounded overflow visible rather than silently stopping observation. Ordinary users retain serial-only navigation and server-side denial. Exercise hidden prompts, line editing/history/completion, resize, admin-route reconnect, full logout, and expired-session UI. Measure simultaneous two-WebSocket plus HTTPS request headroom; no eviction to make the selector work.
|
||||
|
||||
### 8D.7 — Web-shell lifecycle parity and M2 acceptance
|
||||
|
||||
**Start in:** The console deferred-control boundary, web lifecycle owner, and only the affected command handlers.
|
||||
|
||||
**Scope:** Close the explicit 8D.5 restrictions for self-terminating web-shell operations (including HTTPS stop/identity changes and reboot where supported by the canonical registry). Reuse bounded deferred acknowledgement/close semantics; never claim application-buffer draining proves browser receipt. Prevent further input during pending actions. Preserve UART0-only bootstrap/recovery and safe policy for one-time self-generated credentials. No typed danger-zone API yet. If this requires several distinct owner changes, split them before implementation and keep unimplemented actions explicitly rejected.
|
||||
|
||||
**Gate — M2 (mandatory target/browser pause):** Browser/UART0/admin-SSH commands serialize with no output or hidden-prompt crossover; queued work is discarded after logout/revocation/slot reuse. Deferred operations acknowledge or fail within bounds and reconnect behaves as documented. Run simultaneous USB, serial WebSocket, admin WebSocket, user SSH, admin SSH, UART0 and UART1 traffic; verify lease retention, recovery availability, and measured memory/stack floors. Web-admin initialization/admission failure must leave M1 serial login and existing non-web paths usable. Stop for user confirmation before settings.
|
||||
|
||||
## M3 — Typed settings, one domain per chunk
|
||||
|
||||
Every row is a **separate implementation request**, not a batch. Add only the endpoints and UI needed for that row. All typed routes require current admin authorization, bounded bodies/responses, secret-safe encoding, and the established CSRF/origin/no-store policy. Do not send constructed command strings to `esp_console_run()`.
|
||||
|
||||
Typed operations must preserve subsystem owner/lock/persistence contracts and coexist safely with console operations. The current `admin_command_gate` is a narrow user-command wrapper, not an existing global typed-operation serializer; do not assume it solves concurrency. Specify per-domain serialization and committed-mutation notification before adding writes. Reuse a small common mechanism only when a concrete second caller needs it.
|
||||
|
||||
| Chunk | Bounded deliverable and starting source set | Focused acceptance gate / exclusions |
|
||||
|---|---|---|
|
||||
| **8D.8 — Read-only settings entry and Serial page** | Minimal admin Settings navigation, common bounded error handling, and a typed serial snapshot; start in `web_server`, `web_ui`, `serial_service.h`, `serial_config.h`. | Normal users are denied by server, unknown/oversized input fails safely, serial values match UART0. No mutations, schema generator, empty placeholder pages, or all-subsystem snapshot. |
|
||||
| **8D.9 — Serial edits and persistence** | Typed framing/lifecycle and explicit apply/save/load/default/reset controls through existing serial APIs. | Invalid framing is rejected; working versus persisted state and stop/reconfigure data-discard effects are explicit. Writer ownership semantics stay unchanged; compare CLI/browser edits and reboot persistence. No quick popover yet. |
|
||||
| **8D.10 — Accounts and passwords** | User list/create/delete/role/password workflows, final-admin protection, one-time generated-password handling; start in `user_database`, existing `user_console` mutation/revocation behavior, and web handlers/UI. | No raw database export, verifier fields, or UART0 recovery endpoints. Commit/invalidation behavior matches CLI, unrelated users remain connected, own-account changes have safe reconnect/credential-delivery semantics, secret fields are cleared after use. If CRUD and password UX exceed budget, split at read-only/role/delete versus create/password before editing. |
|
||||
| **8D.11 — SSH authorized keys** | List fingerprints and add/delete/clear supported public keys through bounded user APIs. | Ed25519/P-256 import, maximum supported length, malformed input, duplicates, targeted revocation, and unchanged SSH authentication behavior. No private-key upload/export or host-identity management. |
|
||||
| **8D.12 — Network settings without secret mutation** | Secret-free STA/AP/profile and mDNS settings, non-secret edits, and explicit persistence through `wifi_manager`, `wifi_config`, `mdns_service`/`mdns_config`. | Responses never serialize saved PSKs; validate working/persisted semantics, live hostname changes, and behavior after connection loss. No new manager/task or Wi-Fi blob migration. Split mDNS into a follow-up if needed. |
|
||||
| **8D.13 — Wi-Fi secrets and connection controls** | Explicit password replacement/clear semantics, bounded transient input, profile selection/reconnect and AP policy actions using manager-owned operations. | Preserve existing secrets when fields are omitted; never prefill saved secrets; document apply/save and likely connection loss; reconnect via STA/AP and verify UART0/USB recovery. No background secret fetch or general credential export. |
|
||||
| **8D.14 — Display settings** | Typed local display configuration and explicit persistence via `local_ui_config`/public UI APIs. | Validate limits, save/reboot, absent-display behavior, and concurrent buttons/CLI edits. No I2C ownership changes or electrical diagnostics UI. |
|
||||
| **8D.15 — Bounded network diagnostics** | Secret-free network status and a narrowly bounded diagnostic workflow through existing network facilities. | Diagnostic start/result/cancel/expiry behavior and concurrent CLI use are bounded; callbacks do not format/send HTTP directly. No unbounded result/history buffer or new generic jobs framework. Split asynchronous ping from read-only status if necessary. |
|
||||
| **8D.16 — Broker client visibility and writer transfer** | Admin-only detailed client snapshot plus explicit confirmed writer assignment using existing broker APIs; smallest broker change only if authoritative generation-safe validation is missing. | Stale/disconnected/reused target fails without changing the current lease; exactly one writer; normal users cannot obtain management details or transfer. Test concurrent USB/SSH/browser requests. No transfer on page open or selection alone. |
|
||||
| **8D.17 — Serial/Wi-Fi quick popovers** | UI-only reuse of completed typed endpoints, with full-page links and shared validation; start in `web_ui`. | Hover, focus, click/tap parity, Escape/outside-click dismissal, no mutation on opening, explicit apply/save, no secret exposure. No duplicate backend or new settings scope. |
|
||||
| **8D.18 — Client/writer contextual dialogs** | Reuse 8D.16 for live client popover and confirmed Active writer dialog. | Accessible pointer/keyboard/touch paths; refresh preserves explicit selection safely; stale confirmation is rejected visibly; normal users retain only ordinary status. No new writer policy. |
|
||||
| **8D.19 — Ordinary service/session controls** | Typed service status and targeted disconnect/start/stop controls, excluding actions that cut off the invoking HTTPS session; start in existing service APIs and generation-safe snapshots. | Explicit scope/confirmation, stale target rejection, owner-safe execution and failure isolation. Stop/start and disconnect do not clear settings/identities. Split by service if more than one owner adaptation is necessary. |
|
||||
| **8D.20 — Self-affecting service actions and reboot** | Confirmed typed operations for connection-losing HTTPS/Wi-Fi/service actions and reboot, reusing lifecycle behavior established in 8D.7/8D.13. | Acknowledgement/pending-action handling is bounded, no duplicate action on UI retry, connection loss is explained, recovery and reconnect work. No new unbounded queue or certificate/key rotation yet. |
|
||||
| **8D.21 — Security/danger-zone settings** | Carefully separated HTTPS/SSH identity rotation/reset and any explicitly retained recovery-secret operation through existing security APIs. Enumerate allowed operations first; split HTTPS and SSH work into separate requests if both need owner changes. | Confirmation, secret-safe one-time responses, no routine private-key export, expected trust/fingerprint changes, bounded self-disconnect, no accidental configuration wipe. Bootstrap/unavailable-database recovery remain UART0-only. NVS encryption, secure boot, OTA, and new factory-reset semantics stay out of scope. |
|
||||
|
||||
Dependencies: 8D.8 establishes only the minimal typed-request/UI pattern. 8D.9 follows 8D.8; 8D.11 follows 8D.10; 8D.13 follows 8D.12; 8D.17 follows 8D.9/8D.13; 8D.18 follows 8D.16; 8D.20 follows 8D.7/8D.13/8D.19; 8D.21 follows 8D.20. Independent domains can be reordered by the user, but should not be implemented concurrently against shared `web_server`/`web_ui` files.
|
||||
|
||||
## 8D.22 — Final integration acceptance and documentation
|
||||
|
||||
No new feature work. Run the complete [Phase 8D acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration), including the accumulated per-chunk regression checks and a bounded concurrent-transport soak at supported workloads. Record the exact revision/configuration, browser(s), client mix, baud rate, test duration/cycle counts, linked RAM/flash, runtime free/minimum/largest internal heap and PSRAM, relevant stack margins, and queue/drop observations. Compare against 8D.0 and milestone measurements. Known high-load serial drops are not permission for new unbounded blocking, hidden lease loss, login failure, or resource exhaustion.
|
||||
|
||||
Verify optional web-session/admin/settings initialization failures preserve UART0 and native USB; admin-only failure must not disable otherwise working serial web access. Check logout/expiry/revocation across every added route, and verify no retained Basic path or direct typed endpoint bypasses policy. Review all routine snapshots/logs/DOM status for secret exposure and confirm changes did not regenerate unrelated vendor assets.
|
||||
|
||||
Update the roadmap and user/command documentation to distinguish completed features, explicit restrictions, build results, and tests actually passed. Update durable agent architecture/code-map notes only for implemented ownership/contracts. If a check fails, open a bounded repair chunk and rerun affected checks; do not append features or declare the whole phase done with unrecorded failures.
|
||||
|
||||
## Progress and next-request template
|
||||
|
||||
Progress: **8D.0–8D.5 and M1 validated by user sign-off; numeric reserves remain open. 8D.6 implemented/host-tested/build-verified, target pending. 8D.7–8D.22 planned.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds. The baseline records user-provided evidence and sign-off; this does not imply completion of later browser-authentication acceptance checks.
|
||||
|
||||
Suggested next request:
|
||||
|
||||
> Review and target-validate Phase 8D.6 using its implementation checklist. Preserve the serial connection, broker identity and writer lease while switching terminals. Record actual browser/target results and open numeric reserve gates; do not implement 8D.7 lifecycle parity or settings without a separate request.
|
||||
|
||||
For later chunks:
|
||||
|
||||
> Work on Phase 8D.N only. First verify its prerequisites and last target-validation checkpoint. State allowed files, exclusions, resource budget, and focused checks. Split the chunk if it does not fit one session with validation time reserved. Build, report actual versus pending validation and memory deltas, update the handoff, and stop; do not continue to the next chunk.
|
||||
+4
-1
@@ -198,7 +198,10 @@ Implementation sequence:
|
||||
- `ssh sessions` and `ssh counters` identify broker versus admin-console routes, worker command state, queued admin output, admission failures, and input backpressure. `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort application-buffer draining. Admin sessions are checked for a current `admin` principal before command execution and during the active-session reconciliation.
|
||||
- Keep SFTP, SCP, `exec`, forwarding, subsystems, and unauthenticated shells disabled.
|
||||
- Target-hardware validation passed for route separation, history/Tab editing, interactive visible/hidden prompts, output/backpressure, generated and entered user/password/key management including the longest ECDSA P-256 import, ping event routing, deferred reboot/SSH lifecycle drain behavior, bootstrap/recovery rejection, targeted self/other-user revocation during queued work, UART0/SSH administration serialization, and concurrent USB/WebSocket/user-SSH/admin-SSH operation. Stress at 460800 baud with SSH and WebSocket clients caused substantial expected packet drops and slower display controls, but did not exhaust memory or require lowering the supported baud-rate range.
|
||||
4. **Phase 8D — Integrated web administration — Planned**
|
||||
4. **Phase 8D — Integrated web administration — Planned, staged delivery**
|
||||
- **Implementation checkpoint:** 8D.0–8D.5 and M1 validated by user sign-off. [8D.5 admin WebSocket backend](phase8d5_implementation.md) closed on 2026-09-06 after settled cold-boot telemetry and a reported successful 15-minute full-client-mix soak at 230400 baud, with a few broker drops under heavy output. Backend is implemented, host-tested and build-verified without a normal UI entry: **95,580 B RAM / 1,637,273 B flash**. Numeric reserves remain open. Browser selector/lifecycle parity and settings remain planned; wait for separately requested 8D.6. M2 is not yet accepted.
|
||||
- **Execution plan:** [Phase 8D incremental plan](phase8d_plan.md). Implement one numbered chunk per request, with a build, focused regression checks, memory accounting, and a handoff before stopping. The requirements below describe the final scope, not one implementation task.
|
||||
- **Milestones:** 8D.0–8D.3 establish a measured baseline and reliable login/logout with the existing serial UI; 8D.4–8D.7 add the shared browser admin shell and verify retained serial ownership; 8D.8–8D.21 add typed settings and contextual controls one domain at a time; 8D.22 performs final integration acceptance. Login and runtime-memory target validation gate the first two milestones; do not defer them until the entire phase is implemented. No wholesale import of the rolled-back experimental implementation.
|
||||
- Begin with integrated authentication: replace browser-facing HTTP Basic authentication with a same-origin HTTPS login page, explicit logout, and bounded opaque server-side sessions. Store only a digest of each random session token with a copied secret-free principal, expiry, CSRF state, and authentication-generation binding. Send the raw token only in a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain`, and an explicit lifetime; never retain passwords, Basic headers, raw tokens, verifiers, or SSH-key blobs in snapshots or logs.
|
||||
- Make logout invalidate the current server-side session, expire its cookie, close that session's serial and administrative WebSockets, and redirect to login. Password/role/key mutation, deletion, recreation, and explicit revocation invalidate the affected account's web sessions and tickets without disturbing unrelated accounts. Require the CSRF token plus strict same-origin checks for every state-changing request, including logout, and rate-limit login attempts with bounded secret-free accounting.
|
||||
- Add an admin-only **Serial terminal**/**Admin shell** selector. The administrative route uses a short-lived, single-use, admin-principal-bound ticket and a bounded WebSocket frontend for the same serialized command registry used by UART0 and admin SSH; it is not a generic HTTP command-execution endpoint. Normal users retain the existing serial interface and cannot mint, upgrade, or invoke administrative routes. Remote policy still rejects physical-only `user bootstrap` and `user recover --force`.
|
||||
|
||||
@@ -168,7 +168,17 @@ Finally, issue commands concurrently from UART0 and admin SSH, including `user l
|
||||
|
||||
## Planned Phase 8D integrated web administration
|
||||
|
||||
These are acceptance requirements for the planned implementation, not tests that have passed yet.
|
||||
These are acceptance requirements; listing an item does not claim its execution. The [8D.3/M1 checkpoint](phase8d3_implementation.md) is host-tested/build-verified and validated by explicit user sign-off on 2026-09-06 after both-role login, mixed-client operation and post-soak evidence. Unrecorded individual checks remain coverage/evidence limitations, not blockers to that sign-off. Browser administration remains planned. Execute them incrementally using the [Phase 8D work-unit plan](phase8d_plan.md), not only at the end of the phase.
|
||||
|
||||
Validation checkpoints:
|
||||
|
||||
- **8D.0:** Establish working browser login/serial, UART0/USB/SSH, and measured build/runtime memory baselines before changing authentication. Record the revision/configuration, workload, numeric resource budgets, and repetition counts.
|
||||
- **8D.1–8D.3 / M1:** Exercise session primitives and session-specific cleanup as they land, then run section 1's authentication checks against the real login page and existing serial UI. Administration-denial checks apply as each admin route is added. Test new and previously Basic-authenticated browser profiles and direct-IP/mDNS access where available. **Stop for target/browser validation before adding the admin shell.**
|
||||
- **8D.4–8D.7 / M2:** Regress UART0/admin SSH after the console-boundary change; then run section 2 and the applicable section 5 concurrency/failure checks with both browser WebSockets active. **Stop for target/browser and memory validation before settings.**
|
||||
- **8D.8–8D.21:** Run sections 3–4 one settings/control domain at a time, including direct server-side role/CSRF/origin denial, malformed/oversized input, persistence, and applicable lifecycle/revocation checks. Reuse previously verified endpoints for popovers rather than postponing API validation until popovers exist.
|
||||
- **8D.22:** Run all sections together plus the bounded concurrent soak. This is cumulative acceptance, not a substitute for the earlier checkpoints.
|
||||
|
||||
Every runtime-changing chunk requires a bounded `pio run`, the small transport/login smoke check, and its focused acceptance checks. Record **implemented**, **build-verified**, and **target-verified** separately. Measure UART0 `memory` internal/PSRAM free, minimum-free, and largest-block values at settled boot, connected load, and after repeated logout/disconnect/reconnect; include stack margins where available and compare incremental and cumulative resource costs. Record pending hardware checks or timed-out commands honestly; neither is a pass. The incremental plan defines the stop/split policy if a resource budget or validation gate fails.
|
||||
|
||||
### 1. Integrated login and authorization
|
||||
|
||||
|
||||
@@ -31,9 +31,16 @@ idf_component_register(
|
||||
"user_console.c"
|
||||
"web_security.c"
|
||||
"web_serial_transport.c"
|
||||
"web_admin_tickets.c"
|
||||
"web_admin_transport.c"
|
||||
"web_assets_data.c"
|
||||
"web_ui.c"
|
||||
"web_server.c"
|
||||
"web_session_store.c"
|
||||
"web_auth_parse.c"
|
||||
"web_httpd_adapter.c"
|
||||
"web_cookie_auth.c"
|
||||
"web_login_ui.c"
|
||||
"web_console.c"
|
||||
"wifi_config.c"
|
||||
"wifi_manager.c"
|
||||
@@ -67,6 +74,16 @@ idf_component_register(
|
||||
wolfssl__wolfssl
|
||||
)
|
||||
|
||||
# Only web_httpd_adapter.c uses this private, version-checked boundary.
|
||||
target_include_directories(${COMPONENT_LIB} PRIVATE
|
||||
"$ENV{IDF_PATH}/components/esp_http_server/src"
|
||||
"$ENV{IDF_PATH}/components/esp_http_server/src/port/esp32")
|
||||
|
||||
# HTTPD debug logs include header values; URI warnings include ticket queries.
|
||||
# Compile those out, independently of runtime log-level changes.
|
||||
idf_component_get_property(httpd_lib esp_http_server COMPONENT_LIB)
|
||||
target_compile_definitions(${httpd_lib} PRIVATE LOG_LOCAL_LEVEL=ESP_LOG_ERROR)
|
||||
|
||||
# Public wolfSSH headers include wolfCrypt configuration from user_settings.h.
|
||||
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
||||
WOLFSSL_USER_SETTINGS
|
||||
|
||||
+190
-90
@@ -9,14 +9,12 @@
|
||||
|
||||
#include "console_completion.h"
|
||||
#include "esp_console.h"
|
||||
#include "esp_system.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "linenoise/linenoise.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "user_database.h"
|
||||
|
||||
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
|
||||
@@ -47,6 +45,7 @@ typedef struct {
|
||||
bool executing;
|
||||
bool deferred_action_pending;
|
||||
admin_ssh_console_token_t token;
|
||||
const admin_console_owner_t *owner;
|
||||
user_principal_t principal;
|
||||
size_t input_length;
|
||||
size_t input_cursor;
|
||||
@@ -87,12 +86,14 @@ typedef struct {
|
||||
typedef struct {
|
||||
admin_ssh_deferred_action_type_t action;
|
||||
admin_ssh_console_token_t token;
|
||||
const admin_console_owner_t *owner;
|
||||
uint32_t argument;
|
||||
} admin_control_request_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_MAX_SESSIONS];
|
||||
/* admin_ssh_console_feed_input() is called only by the sole SSH owner task. */
|
||||
/* Claimed under s_lock, used outside it; competing TAB input is backpressured. */
|
||||
static bool s_completion_busy;
|
||||
static char s_completion_output[CONSOLE_COMPLETION_OUTPUT_CAPACITY];
|
||||
|
||||
static StaticQueue_t s_request_queue_storage;
|
||||
@@ -115,7 +116,6 @@ static bool s_dispatch_remote;
|
||||
static bool s_dispatch_output_previous_cr;
|
||||
static admin_ssh_console_token_t s_dispatch_token;
|
||||
static user_principal_t s_dispatch_principal;
|
||||
static ssh_transport_snapshot_t s_control_ssh_snapshot;
|
||||
|
||||
bool admin_ssh_console_dispatch_is_remote(void)
|
||||
{
|
||||
@@ -137,6 +137,8 @@ static bool token_identity_matches(const admin_session_t *session,
|
||||
const admin_ssh_console_token_t *token)
|
||||
{
|
||||
return token_valid(token) && session->token.session_id == token->session_id &&
|
||||
session->token.transport == token->transport &&
|
||||
session->token.slot_index == token->slot_index &&
|
||||
session->token.slot_generation == token->slot_generation;
|
||||
}
|
||||
|
||||
@@ -146,6 +148,31 @@ static bool token_matches(const admin_session_t *session,
|
||||
return session->active && token_identity_matches(session, token);
|
||||
}
|
||||
|
||||
static bool session_is_current(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[token->slot_index];
|
||||
const admin_console_owner_t *owner = token_matches(session, token)
|
||||
? session->owner : NULL;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (owner == NULL) {
|
||||
return false;
|
||||
}
|
||||
bool account_current = false;
|
||||
bool current = principal->role == USER_ROLE_ADMIN &&
|
||||
user_database_principal_is_current(principal, &account_current) == ESP_OK &&
|
||||
account_current && owner->is_current(token, principal);
|
||||
/* External checks may close/reuse a slot. Never act on its replacement. */
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool matched = token_matches(session, token) && session->owner == owner;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (matched && !current) {
|
||||
admin_ssh_console_close(token);
|
||||
}
|
||||
return matched && current;
|
||||
}
|
||||
|
||||
static bool append_output_locked(admin_session_t *session,
|
||||
const uint8_t *data, size_t length)
|
||||
{
|
||||
@@ -306,6 +333,9 @@ esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
*output_length = 0U;
|
||||
memset(output, 0, capacity);
|
||||
(void)xSemaphoreTake(s_prompt_done, 0U);
|
||||
if (!session_is_current(&s_dispatch_token, &s_dispatch_principal)) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[s_dispatch_token.slot_index];
|
||||
@@ -328,27 +358,36 @@ esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
if (!published) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
if (xSemaphoreTake(s_prompt_done, portMAX_DELAY) != pdTRUE) {
|
||||
return ESP_FAIL;
|
||||
for (;;) {
|
||||
/* The semaphore is only a hint: delayed/stale wakes cannot submit input. */
|
||||
(void)xSemaphoreTake(s_prompt_done, pdMS_TO_TICKS(250U));
|
||||
bool current = session_is_current(&s_dispatch_token, &s_dispatch_principal);
|
||||
esp_err_t result = ESP_ERR_INVALID_STATE;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
session = &s_sessions[s_dispatch_token.slot_index];
|
||||
if (!token_identity_matches(session, &s_dispatch_token)) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
if (current && session->active && session->prompt_state == ADMIN_PROMPT_WAITING) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
continue;
|
||||
}
|
||||
if (!current || !session->active || session->prompt_state == ADMIN_PROMPT_DISCONNECTED) {
|
||||
result = ESP_ERR_NOT_FOUND;
|
||||
} else if (session->prompt_state == ADMIN_PROMPT_SUBMITTED) {
|
||||
memcpy(output, session->prompt_input, session->prompt_length);
|
||||
*output_length = session->prompt_length;
|
||||
result = ESP_OK;
|
||||
}
|
||||
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
|
||||
session->prompt_length = 0U;
|
||||
session->prompt_capacity = 0U;
|
||||
session->prompt_hidden = false;
|
||||
session->prompt_state = ADMIN_PROMPT_NONE;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t result = ESP_ERR_INVALID_STATE;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
session = &s_sessions[s_dispatch_token.slot_index];
|
||||
if (session->prompt_state == ADMIN_PROMPT_SUBMITTED) {
|
||||
memcpy(output, session->prompt_input, session->prompt_length);
|
||||
*output_length = session->prompt_length;
|
||||
result = ESP_OK;
|
||||
} else if (session->prompt_state == ADMIN_PROMPT_DISCONNECTED) {
|
||||
result = ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
|
||||
session->prompt_length = 0U;
|
||||
session->prompt_capacity = 0U;
|
||||
session->prompt_hidden = false;
|
||||
session->prompt_state = ADMIN_PROMPT_NONE;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_dispatch_defer(
|
||||
@@ -361,6 +400,12 @@ esp_err_t admin_ssh_console_dispatch_defer(
|
||||
admin_session_t *session = &s_sessions[s_dispatch_token.slot_index];
|
||||
bool valid = token_matches(session, &s_dispatch_token) &&
|
||||
!session->deferred_action_pending;
|
||||
const admin_console_owner_t *owner = valid ? session->owner : NULL;
|
||||
if (valid && ((unsigned)action > ADMIN_CONSOLE_DEFER_SELF_CLOSE ||
|
||||
!(owner->supported_actions & (1U << action)))) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
if (valid) {
|
||||
session->deferred_action_pending = true;
|
||||
}
|
||||
@@ -371,6 +416,7 @@ esp_err_t admin_ssh_console_dispatch_defer(
|
||||
admin_control_request_t request = {
|
||||
.action = action,
|
||||
.token = s_dispatch_token,
|
||||
.owner = owner,
|
||||
.argument = argument,
|
||||
};
|
||||
if (xQueueSend(s_control_queue, &request, 0U) == pdTRUE) {
|
||||
@@ -439,11 +485,37 @@ static bool remote_command_allowed(const admin_request_t *request)
|
||||
char *argv[ADMIN_SSH_CONSOLE_MAX_ARGUMENTS] = {0};
|
||||
/* Use exactly the same quote/escape parser as esp_console_run(). */
|
||||
size_t argc = esp_console_split_argv(copy, argv, ADMIN_SSH_CONSOLE_MAX_ARGUMENTS);
|
||||
bool allowed = argc > 0U;
|
||||
if (allowed && strcmp(argv[0], "user") == 0 && argc >= 2U &&
|
||||
/* Empty input is handled quietly by esp_console_run(), not UART0 policy. */
|
||||
bool allowed = true;
|
||||
if (argc >= 2U && strcmp(argv[0], "user") == 0 &&
|
||||
(strcmp(argv[1], "bootstrap") == 0 || strcmp(argv[1], "recover") == 0)) {
|
||||
allowed = false;
|
||||
}
|
||||
/* Temporary browser policy until lifecycle acknowledgements/revocation are
|
||||
* coordinated (8D.7). Classify parsed canonical arguments, not raw prefixes.
|
||||
* User mutations remain available through UART0/SSH, subject to their policy.
|
||||
*/
|
||||
if (request->token.transport == ADMIN_CONSOLE_TRANSPORT_WEB && argc > 0U) {
|
||||
if (strcmp(argv[0], "web") == 0 || strcmp(argv[0], "wifi") == 0 ||
|
||||
strcmp(argv[0], "mdns") == 0) {
|
||||
allowed = argc == 2U && strcmp(argv[1], "status") == 0;
|
||||
} else if (strcmp(argv[0], "user") == 0) {
|
||||
allowed = argc == 1U ||
|
||||
(argc == 2U && (strcmp(argv[1], "status") == 0 ||
|
||||
strcmp(argv[1], "list") == 0)) ||
|
||||
(argc == 3U && strcmp(argv[1], "show") == 0);
|
||||
} else if (strcmp(argv[0], "reboot") == 0) {
|
||||
allowed = false;
|
||||
} else if (strcmp(argv[0], "ssh") == 0 && argc >= 2U) {
|
||||
/* These handlers defer for every remote; WEB supports SELF_CLOSE only. */
|
||||
if (strcmp(argv[1], "stop") == 0 || strcmp(argv[1], "disconnect") == 0 ||
|
||||
strcmp(argv[1], "reset") == 0 ||
|
||||
(strcmp(argv[1], "host-key") == 0 &&
|
||||
!(argc == 3U && strcmp(argv[2], "info") == 0))) {
|
||||
allowed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
secure_wipe(copy, sizeof(copy));
|
||||
return allowed;
|
||||
}
|
||||
@@ -473,12 +545,14 @@ static int command_exit(int argc, char **argv)
|
||||
}
|
||||
|
||||
esp_err_t error = admin_ssh_console_dispatch_defer(
|
||||
ADMIN_SSH_DEFER_DISCONNECT, s_dispatch_token.session_id);
|
||||
ADMIN_CONSOLE_DEFER_SELF_CLOSE, s_dispatch_token.session_id);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not schedule SSH session close: %s\n", esp_err_to_name(error));
|
||||
printf("Could not schedule %s session close: %s\n",
|
||||
s_dispatch_token.transport == 0U ? "SSH" : "remote", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("SSH session close scheduled after output drains.\n");
|
||||
printf("%s session close scheduled after output drains.\n",
|
||||
s_dispatch_token.transport == 0U ? "SSH" : "Remote");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -506,8 +580,11 @@ static void dispatch_registered_command(admin_request_t *request)
|
||||
}
|
||||
|
||||
int command_result = 0;
|
||||
esp_err_t error = esp_console_run((const char *)request->line, &command_result);
|
||||
report_command_result(error, command_result);
|
||||
if (request->origin == ADMIN_REQUEST_UART0 ||
|
||||
session_is_current(&request->token, &request->principal)) {
|
||||
esp_err_t error = esp_console_run((const char *)request->line, &command_result);
|
||||
report_command_result(error, command_result);
|
||||
}
|
||||
fflush(stdout);
|
||||
|
||||
s_dispatch_remote = false;
|
||||
@@ -538,35 +615,33 @@ static void worker_task(void *context)
|
||||
continue;
|
||||
}
|
||||
|
||||
bool current = false;
|
||||
esp_err_t auth_error = user_database_principal_is_current(&request.principal, ¤t);
|
||||
bool current = session_is_current(&request.token, &request.principal);
|
||||
bool active;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[request.token.slot_index];
|
||||
active = token_matches(session, &request.token) && session->command_pending &&
|
||||
active = current && token_matches(session, &request.token) && session->command_pending &&
|
||||
!session->executing;
|
||||
if (active) {
|
||||
session->executing = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
bool authorized = active && auth_error == ESP_OK && current &&
|
||||
request.principal.role == USER_ROLE_ADMIN &&
|
||||
remote_command_allowed(&request);
|
||||
bool authorized = active && remote_command_allowed(&request);
|
||||
if (authorized) {
|
||||
dispatch_registered_command(&request);
|
||||
} else if (active) {
|
||||
(void)worker_write(&request.token,
|
||||
auth_error == ESP_OK && current
|
||||
? "Command is restricted to physical UART0.\r\n"
|
||||
: "Administrative authorization is no longer current; closing session.\r\n");
|
||||
request.token.transport == ADMIN_CONSOLE_TRANSPORT_WEB
|
||||
? "Command is unavailable from the web console; use UART0 or SSH where permitted. Bootstrap/recovery require UART0.\r\n"
|
||||
: "Command is restricted to physical UART0.\r\n");
|
||||
}
|
||||
current = session_is_current(&request.token, &request.principal);
|
||||
bool prompt = false;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
session = &s_sessions[request.token.slot_index];
|
||||
if (token_matches(session, &request.token)) {
|
||||
session->executing = false;
|
||||
session->command_pending = false;
|
||||
prompt = auth_error == ESP_OK && current &&
|
||||
prompt = current &&
|
||||
request.principal.role == USER_ROLE_ADMIN &&
|
||||
!session->deferred_action_pending;
|
||||
} else if (!session->active && session->executing &&
|
||||
@@ -586,16 +661,18 @@ static void finish_deferred_request(const admin_control_request_t *request,
|
||||
esp_err_t result, bool cancelled)
|
||||
{
|
||||
char message[160];
|
||||
const char *transport = request->token.transport == 0U ? "SSH" : "remote";
|
||||
if (cancelled) {
|
||||
snprintf(message, sizeof(message),
|
||||
"Deferred action cancelled before SSH output drained.\r\nadmin@serial-tool> ");
|
||||
"Deferred action cancelled before %s output drained.\r\nadmin@serial-tool> ",
|
||||
transport);
|
||||
} else if (result == ESP_OK) {
|
||||
snprintf(message, sizeof(message),
|
||||
"Deferred SSH action completed.\r\nadmin@serial-tool> ");
|
||||
"Deferred %s action completed.\r\nadmin@serial-tool> ", transport);
|
||||
} else {
|
||||
snprintf(message, sizeof(message),
|
||||
"Deferred SSH action failed: %s\r\nadmin@serial-tool> ",
|
||||
esp_err_to_name(result));
|
||||
"Deferred %s action failed: %s\r\nadmin@serial-tool> ",
|
||||
transport, esp_err_to_name(result));
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[request->token.slot_index];
|
||||
@@ -626,18 +703,8 @@ static void control_task(void *context)
|
||||
if (!current) {
|
||||
break;
|
||||
}
|
||||
bool transport_drained = false;
|
||||
if (console_drained &&
|
||||
ssh_transport_get_snapshot(&s_control_ssh_snapshot) == ESP_OK) {
|
||||
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
const ssh_transport_session_snapshot_t *slot =
|
||||
&s_control_ssh_snapshot.sessions[index];
|
||||
if (slot->active && slot->session_id == request.token.session_id) {
|
||||
transport_drained = !slot->tx_pending;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bool transport_drained = console_drained &&
|
||||
request.owner->drained(&request.token);
|
||||
if (console_drained && transport_drained) {
|
||||
drained = true;
|
||||
break;
|
||||
@@ -650,27 +717,11 @@ static void control_task(void *context)
|
||||
continue;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(200U));
|
||||
esp_err_t result = ESP_OK;
|
||||
switch (request.action) {
|
||||
case ADMIN_SSH_DEFER_REBOOT:
|
||||
esp_restart();
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_STOP:
|
||||
result = ssh_transport_stop();
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_DISCONNECT:
|
||||
result = ssh_transport_disconnect(request.argument);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_HOST_KEY_ROTATE:
|
||||
result = ssh_transport_replace_host_key(false);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_HOST_KEY_RESET:
|
||||
result = ssh_transport_replace_host_key(true);
|
||||
break;
|
||||
default:
|
||||
result = ESP_ERR_NOT_SUPPORTED;
|
||||
break;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool current = token_matches(&s_sessions[request.token.slot_index], &request.token);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
esp_err_t result = current ? request.owner->perform(
|
||||
&request.token, request.action, request.argument) : ESP_ERR_NOT_FOUND;
|
||||
finish_deferred_request(&request, result, false);
|
||||
secure_wipe(&request, sizeof(request));
|
||||
}
|
||||
@@ -750,7 +801,7 @@ esp_err_t admin_ssh_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
.command = "exit",
|
||||
.help = "Close the current administrative SSH session",
|
||||
.help = "Close the current administrative remote session",
|
||||
.hint = NULL,
|
||||
.func = &command_exit,
|
||||
.argtable = NULL,
|
||||
@@ -782,10 +833,14 @@ esp_err_t admin_ssh_console_start_uart_frontend(void)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
static esp_err_t open_session(admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_console_owner_t *owner, bool available)
|
||||
{
|
||||
if (!token_valid(token) || principal == NULL || principal->role != USER_ROLE_ADMIN) {
|
||||
if (token == NULL || token->session_id == 0U || token->slot_generation == 0U ||
|
||||
(!available && !token_valid(token)) || principal == NULL || principal->role != USER_ROLE_ADMIN ||
|
||||
owner == NULL || owner->is_current == NULL ||
|
||||
owner->drained == NULL || owner->perform == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
@@ -799,26 +854,59 @@ esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[token->slot_index];
|
||||
if (session->executing) {
|
||||
size_t index = token->slot_index;
|
||||
if (available) {
|
||||
for (index = 0U; index < ADMIN_SSH_CONSOLE_MAX_SESSIONS; ++index) {
|
||||
if (!s_sessions[index].active && !s_sessions[index].executing) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (index == ADMIN_SSH_CONSOLE_MAX_SESSIONS) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
admin_session_t *session = &s_sessions[index];
|
||||
if (session->active || session->executing) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
secure_wipe(session, sizeof(*session));
|
||||
session->active = true;
|
||||
session->history_position = -1;
|
||||
token->slot_index = (uint8_t)index;
|
||||
session->token = *token;
|
||||
session->owner = owner;
|
||||
session->principal = *principal;
|
||||
static const char banner[] =
|
||||
"ESP32 Serial Swiss Army Knife administrative SSH shell\r\n";
|
||||
const char *banner = token->transport == 0U
|
||||
? "ESP32 Serial Swiss Army Knife administrative SSH shell\r\n"
|
||||
: "ESP32 Serial Swiss Army Knife administrative remote shell\r\n";
|
||||
static const char prompt[] =
|
||||
"Run 'help' for supported remote administrative commands.\r\nadmin@serial-tool> ";
|
||||
(void)append_output_locked(session, (const uint8_t *)banner, sizeof(banner) - 1U);
|
||||
(void)append_output_locked(session, (const uint8_t *)banner, strlen(banner));
|
||||
(void)append_output_locked(session, (const uint8_t *)prompt, sizeof(prompt) - 1U);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_console_owner_t *owner)
|
||||
{
|
||||
return open_session(token, principal, owner, true);
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_console_owner_t *owner)
|
||||
{
|
||||
if (token == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
admin_ssh_console_token_t copy = *token;
|
||||
return open_session(©, principal, owner, false);
|
||||
}
|
||||
|
||||
void admin_ssh_console_close(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
if (!token_valid(token)) {
|
||||
@@ -829,8 +917,10 @@ void admin_ssh_console_close(const admin_ssh_console_token_t *token)
|
||||
bool matched = token_matches(session, token);
|
||||
bool wake_prompt = false;
|
||||
if (matched) {
|
||||
if (session->prompt_state == ADMIN_PROMPT_WAITING) {
|
||||
if (session->prompt_state != ADMIN_PROMPT_NONE) {
|
||||
session->prompt_state = ADMIN_PROMPT_DISCONNECTED;
|
||||
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
|
||||
session->prompt_length = 0U;
|
||||
wake_prompt = true;
|
||||
}
|
||||
session->active = false;
|
||||
@@ -854,7 +944,8 @@ bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token)
|
||||
bool shell_input = !session->command_pending && !session->deferred_action_pending;
|
||||
bool prompt_input = session->command_pending && session->executing &&
|
||||
session->prompt_state == ADMIN_PROMPT_WAITING;
|
||||
bool accepts = token_matches(session, token) && (shell_input || prompt_input) &&
|
||||
bool accepts = token_matches(session, token) && !session->deferred_action_pending &&
|
||||
(shell_input || prompt_input) &&
|
||||
session->output_length <= ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY -
|
||||
ADMIN_SSH_CONSOLE_RESPONSE_RESERVE;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
@@ -874,7 +965,7 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
bool submit = false;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[token->slot_index];
|
||||
if (!token_matches(session, token)) {
|
||||
if (!token_matches(session, token) || session->deferred_action_pending) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return *consumed != 0U;
|
||||
}
|
||||
@@ -992,6 +1083,10 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
continue;
|
||||
}
|
||||
if (value == '\t') {
|
||||
if (s_completion_busy) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return *consumed != 0U;
|
||||
}
|
||||
if (session->input_cursor != session->input_length) {
|
||||
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
|
||||
++*consumed;
|
||||
@@ -1000,6 +1095,7 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
}
|
||||
char current[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
memcpy(current, session->input, sizeof(current));
|
||||
s_completion_busy = true;
|
||||
++*consumed;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
char completed[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U] = {0};
|
||||
@@ -1032,6 +1128,8 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
|
||||
}
|
||||
}
|
||||
secure_wipe(s_completion_output, sizeof(s_completion_output));
|
||||
s_completion_busy = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(current, sizeof(current));
|
||||
secure_wipe(completed, sizeof(completed));
|
||||
@@ -1137,8 +1235,10 @@ esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
|
||||
first = ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_start;
|
||||
}
|
||||
memcpy(data, session->output + session->output_start, first);
|
||||
secure_wipe(session->output + session->output_start, first);
|
||||
if (copied > first) {
|
||||
memcpy(data + first, session->output, copied - first);
|
||||
secure_wipe(session->output, copied - first);
|
||||
}
|
||||
session->output_start = (session->output_start + copied) %
|
||||
ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY;
|
||||
|
||||
+65
-4
@@ -1,5 +1,5 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded, transport-neutral administrative command worker for SSH sessions. */
|
||||
/* Bounded administrative dispatcher with a small remote-owner boundary. */
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -17,10 +17,14 @@ extern "C" {
|
||||
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
|
||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||
|
||||
#define ADMIN_CONSOLE_TRANSPORT_SSH 0U
|
||||
#define ADMIN_CONSOLE_TRANSPORT_WEB 1U
|
||||
|
||||
typedef struct {
|
||||
uint8_t slot_index;
|
||||
uint32_t session_id;
|
||||
uint32_t slot_generation;
|
||||
uint8_t transport; /* Zero is SSH, including legacy designated initializers. */
|
||||
} admin_ssh_console_token_t;
|
||||
|
||||
typedef enum {
|
||||
@@ -30,8 +34,62 @@ typedef enum {
|
||||
ADMIN_SSH_DEFER_DISCONNECT,
|
||||
ADMIN_SSH_DEFER_HOST_KEY_ROTATE,
|
||||
ADMIN_SSH_DEFER_HOST_KEY_RESET,
|
||||
ADMIN_CONSOLE_DEFER_SELF_CLOSE,
|
||||
} admin_ssh_deferred_action_type_t;
|
||||
|
||||
/* Small owner boundary; module/API names are retained for existing SSH callers.
|
||||
* Exactly two shared console slots, not two per transport. slot_index addresses
|
||||
* this pool; open_available atomically selects a free slot. Owners must not reuse an identity while
|
||||
* old work can exist. transport is a firmware-assigned namespace (0 = SSH).
|
||||
* An occupied or still-executing slot cannot be replaced by open_owned().
|
||||
*
|
||||
* The immutable adapter lives for firmware lifetime. Callbacks run on the
|
||||
* control task OUTSIDE console locks for drained/perform. Required is_current
|
||||
* runs on the dispatcher outside console locks; it must be bounded and validate
|
||||
* full transport identity, originating-session liveness and principal binding,
|
||||
* without calling socket libraries or handlers. Core separately checks accounts.
|
||||
* drained must be nonblocking, validate the full identity and include pending
|
||||
* owner output. perform must revalidate identity and marshal lifecycle work to
|
||||
* its owner, never call socket libraries here. Neither callback may call console
|
||||
* handlers. supported_actions is a bitmask (1U << action); reject unsupported
|
||||
* actions before side effects. Legacy STOP/DISCONNECT/key actions mean SSH;
|
||||
* SELF_CLOSE means this frontend, with argument ignored.
|
||||
*
|
||||
* One owner serializes feed calls per session; different owners may feed in
|
||||
* parallel. Shared completion scratch is nonblocking/serialized by the core.
|
||||
* The owner alone consumes output, maintains authentication/session liveness,
|
||||
* and calls close on disconnect/revocation. Core copies/rechecks principals at
|
||||
* admission and dispatch. Dispatch and prompts also check owner currentness;
|
||||
* blocked prompts recheck every 250ms (plus check/scheduling latency). This does
|
||||
* not cancel or roll back arbitrary executing handlers. Admission remains the
|
||||
* owner's responsibility; is_current need not accept unpublished admission.
|
||||
* Close wakes prompts; executing state is retained until the handler returns.
|
||||
* Output remains bounded (5s write backpressure); deferred work waits at most
|
||||
* 10s for application drain plus 200ms, NOT peer-delivery confirmation.
|
||||
* No new tasks, queues, slots, or browser endpoint are provided by this API.
|
||||
*/
|
||||
typedef struct {
|
||||
uint32_t supported_actions;
|
||||
bool (*is_current)(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal);
|
||||
bool (*drained)(const admin_ssh_console_token_t *token);
|
||||
esp_err_t (*perform)(const admin_ssh_console_token_t *token,
|
||||
admin_ssh_deferred_action_type_t action, uint32_t argument);
|
||||
} admin_console_owner_t;
|
||||
|
||||
/* Selects any inactive, nonexecuting slot from the shared two-slot pool.
|
||||
* Input slot_index is ignored; only slot_index changes, and only on success.
|
||||
* Caller supplies transport/session_id/slot_generation and must retain the
|
||||
* returned token. Full pool returns ESP_ERR_INVALID_STATE, like open_owned.
|
||||
*/
|
||||
esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_console_owner_t *owner);
|
||||
|
||||
esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_console_owner_t *owner);
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
bool command_pending;
|
||||
@@ -57,18 +115,21 @@ esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
esp_err_t admin_ssh_console_dispatch_defer(
|
||||
admin_ssh_deferred_action_type_t action, uint32_t argument);
|
||||
|
||||
/* The token and principal are copied; no SSH or socket objects cross this boundary. */
|
||||
/* SSH compatibility entry point, implemented by the owner in ssh_transport.c.
|
||||
* Token/principal are copied; no SSH or socket objects cross this boundary.
|
||||
* Existing feed/close/read/snapshot APIs below also accept open_owned tokens.
|
||||
*/
|
||||
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal);
|
||||
void admin_ssh_console_close(const admin_ssh_console_token_t *token);
|
||||
|
||||
/* Called only by the SSH owner task. Returns false when input must be backpressured. */
|
||||
/* Called by the session owner. Returns false when input must be backpressured. */
|
||||
bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token);
|
||||
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
const uint8_t *data, size_t length,
|
||||
size_t *consumed);
|
||||
|
||||
/* Called only by the SSH owner task; copies already-produced output without blocking. */
|
||||
/* Called by the session owner; copies already-produced output without blocking. */
|
||||
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
|
||||
uint8_t *data, size_t capacity,
|
||||
size_t *received);
|
||||
|
||||
+128
-2
@@ -12,6 +12,7 @@
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
@@ -65,6 +66,7 @@ typedef struct {
|
||||
bool pending_principal_valid;
|
||||
bool authenticated;
|
||||
bool shell_requested;
|
||||
uint8_t console_slot_index;
|
||||
uint8_t authentication_attempts;
|
||||
word32 io_read_budget;
|
||||
bool writer;
|
||||
@@ -86,6 +88,9 @@ static ssh_slot_t s_slots[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static ssh_transport_session_snapshot_t
|
||||
s_session_snapshots[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static uint32_t s_external_close_id[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
/* Published with snapshots; dispatcher never reads owner-task slot storage. */
|
||||
static user_principal_t s_console_principals[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static uint8_t s_console_slot_indices[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static ssh_transport_counters_t s_counters;
|
||||
static SemaphoreHandle_t s_command_mutex;
|
||||
static bool s_initializing;
|
||||
@@ -131,8 +136,9 @@ static void notify_task(void)
|
||||
static admin_ssh_console_token_t admin_console_token(const ssh_slot_t *slot,
|
||||
size_t slot_index)
|
||||
{
|
||||
(void)slot_index; /* Physical SSH index is not the shared console index. */
|
||||
return (admin_ssh_console_token_t){
|
||||
.slot_index = (uint8_t)slot_index,
|
||||
.slot_index = slot->console_slot_index,
|
||||
.session_id = slot->session_id,
|
||||
.slot_generation = slot->generation,
|
||||
};
|
||||
@@ -176,14 +182,132 @@ static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_session_snapshots[slot_index] = snapshot;
|
||||
s_console_slot_indices[slot_index] =
|
||||
snapshot.active && slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE
|
||||
? slot->console_slot_index : UINT8_MAX;
|
||||
if (slot->principal_valid) {
|
||||
s_console_principals[slot_index] = slot->principal;
|
||||
} else {
|
||||
secure_wipe(&s_console_principals[slot_index], sizeof(user_principal_t));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
/* Caller holds s_lock. Match session identity first, then the assigned console
|
||||
* binding; callbacks must never index physical SSH storage by console slot.
|
||||
*/
|
||||
static size_t admin_console_snapshot_index_locked(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
if (token == NULL || token->transport != ADMIN_CONSOLE_TRANSPORT_SSH ||
|
||||
token->session_id == 0U || token->slot_generation == 0U) {
|
||||
return SSH_TRANSPORT_MAX_SESSIONS;
|
||||
}
|
||||
for (size_t i = 0U; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
|
||||
const ssh_transport_session_snapshot_t *slot = &s_session_snapshots[i];
|
||||
if (slot->active && slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE &&
|
||||
slot->session_id == token->session_id &&
|
||||
slot->generation == token->slot_generation &&
|
||||
s_console_slot_indices[i] != UINT8_MAX &&
|
||||
s_console_slot_indices[i] == token->slot_index) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return SSH_TRANSPORT_MAX_SESSIONS;
|
||||
}
|
||||
|
||||
/* Dispatcher/control adapters: published state only, no runtime wolfSSH calls. */
|
||||
static bool admin_console_is_current(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
if (principal == NULL) {
|
||||
return false;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
size_t index = admin_console_snapshot_index_locked(token);
|
||||
if (index == SSH_TRANSPORT_MAX_SESSIONS) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return false;
|
||||
}
|
||||
const ssh_transport_session_snapshot_t *slot = &s_session_snapshots[index];
|
||||
const user_principal_t *bound = &s_console_principals[index];
|
||||
bool current = slot->active && slot->authenticated && slot->principal_valid &&
|
||||
slot->state == SSH_TRANSPORT_SESSION_ACTIVE &&
|
||||
slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE && !slot->close_requested &&
|
||||
s_external_close_id[index] != token->session_id &&
|
||||
slot->session_id == token->session_id && slot->generation == token->slot_generation &&
|
||||
bound->user_id == principal->user_id && bound->auth_generation == principal->auth_generation &&
|
||||
bound->role == USER_ROLE_ADMIN && bound->role == principal->role &&
|
||||
bound->method == principal->method && bound->username_length == principal->username_length &&
|
||||
bound->username_length <= USER_DATABASE_USERNAME_CAPACITY &&
|
||||
memcmp(bound->username, principal->username, bound->username_length) == 0;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return current;
|
||||
}
|
||||
|
||||
static bool admin_console_drained(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
size_t index = admin_console_snapshot_index_locked(token);
|
||||
bool drained = index < SSH_TRANSPORT_MAX_SESSIONS &&
|
||||
!s_session_snapshots[index].tx_pending;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return drained;
|
||||
}
|
||||
|
||||
static esp_err_t admin_console_perform(const admin_ssh_console_token_t *token,
|
||||
admin_ssh_deferred_action_type_t action,
|
||||
uint32_t argument)
|
||||
{
|
||||
if (!admin_console_drained(token)) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
switch (action) {
|
||||
case ADMIN_SSH_DEFER_REBOOT:
|
||||
esp_restart();
|
||||
return ESP_OK;
|
||||
case ADMIN_SSH_DEFER_STOP:
|
||||
return ssh_transport_stop();
|
||||
case ADMIN_CONSOLE_DEFER_SELF_CLOSE:
|
||||
return ssh_transport_disconnect(token->session_id);
|
||||
case ADMIN_SSH_DEFER_DISCONNECT:
|
||||
return ssh_transport_disconnect(argument);
|
||||
case ADMIN_SSH_DEFER_HOST_KEY_ROTATE:
|
||||
return ssh_transport_replace_host_key(false);
|
||||
case ADMIN_SSH_DEFER_HOST_KEY_RESET:
|
||||
return ssh_transport_replace_host_key(true);
|
||||
default:
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
}
|
||||
|
||||
static const admin_console_owner_t s_admin_console_owner = {
|
||||
.supported_actions = (1U << ADMIN_SSH_DEFER_REBOOT) |
|
||||
(1U << ADMIN_SSH_DEFER_STOP) | (1U << ADMIN_SSH_DEFER_DISCONNECT) |
|
||||
(1U << ADMIN_SSH_DEFER_HOST_KEY_ROTATE) |
|
||||
(1U << ADMIN_SSH_DEFER_HOST_KEY_RESET) | (1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE),
|
||||
.drained = admin_console_drained,
|
||||
.is_current = admin_console_is_current,
|
||||
.perform = admin_console_perform,
|
||||
};
|
||||
|
||||
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
if (token == NULL || token->transport != ADMIN_CONSOLE_TRANSPORT_SSH) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
return admin_ssh_console_open_owned(token, principal, &s_admin_console_owner);
|
||||
}
|
||||
|
||||
static bool consume_external_close(const ssh_slot_t *slot, size_t slot_index)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool requested = s_external_close_id[slot_index] != 0U &&
|
||||
s_external_close_id[slot_index] == slot->session_id;
|
||||
if (requested) {
|
||||
/* Keep close intent visible while the owner begins cleanup. */
|
||||
s_session_snapshots[slot_index].close_requested = true;
|
||||
}
|
||||
if (requested || slot->state == SSH_TRANSPORT_SESSION_FREE) {
|
||||
s_external_close_id[slot_index] = 0U;
|
||||
}
|
||||
@@ -924,12 +1048,14 @@ static void process_handshake(ssh_slot_t *slot, size_t slot_index)
|
||||
slot->route = SSH_TRANSPORT_ROUTE_BROKER;
|
||||
} else if (slot->principal.role == USER_ROLE_ADMIN) {
|
||||
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
|
||||
error = admin_ssh_console_open(&token, &slot->principal);
|
||||
error = admin_ssh_console_open_available(&token, &slot->principal,
|
||||
&s_admin_console_owner);
|
||||
if (error != ESP_OK) {
|
||||
add_counter(&s_counters.admin_console_admission_failures, 1U);
|
||||
request_slot_close(slot, false);
|
||||
return;
|
||||
}
|
||||
slot->console_slot_index = token.slot_index;
|
||||
slot->route = SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
|
||||
add_counter(&s_counters.admin_console_admissions, 1U);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_admin_tickets.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <string.h>
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
typedef struct {
|
||||
uint64_t generation;
|
||||
web_session_id_t id;
|
||||
int64_t expires_at_us;
|
||||
user_principal_t principal;
|
||||
uint8_t digest[32];
|
||||
} ticket_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static struct {
|
||||
ticket_t tickets[WEB_ADMIN_TICKET_CAPACITY];
|
||||
uint64_t epoch;
|
||||
uint64_t generation;
|
||||
uint32_t issued, consumed, rejected, capacity_rejections;
|
||||
bool ready;
|
||||
} s_state;
|
||||
|
||||
static void increment(uint32_t *counter)
|
||||
{
|
||||
if (*counter != UINT32_MAX) ++*counter;
|
||||
}
|
||||
|
||||
static bool equal_digest(const uint8_t *a, const uint8_t *b)
|
||||
{
|
||||
volatile uint8_t difference = 0;
|
||||
for (size_t i = 0; i < 32; ++i) difference |= a[i] ^ b[i];
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
static bool admin(const user_principal_t *p)
|
||||
{
|
||||
return p != NULL && p->role == USER_ROLE_ADMIN &&
|
||||
p->method == USER_AUTH_METHOD_PASSWORD && p->user_id != 0 &&
|
||||
p->auth_generation != 0 && p->username_length != 0 &&
|
||||
p->username_length <= USER_DATABASE_USERNAME_CAPACITY;
|
||||
}
|
||||
|
||||
static bool same_principal(const user_principal_t *a, const user_principal_t *b)
|
||||
{
|
||||
return admin(b) && a->user_id == b->user_id &&
|
||||
a->auth_generation == b->auth_generation && a->role == b->role &&
|
||||
a->method == b->method && a->username_length == b->username_length &&
|
||||
memcmp(a->username, b->username, a->username_length) == 0;
|
||||
}
|
||||
|
||||
static bool current(web_session_id_t id, const user_principal_t *p)
|
||||
{
|
||||
bool valid = false;
|
||||
return id != 0 && admin(p) &&
|
||||
web_session_store_check_principal(id, p, &valid) == ESP_OK && valid;
|
||||
}
|
||||
|
||||
static void expire_locked(int64_t now)
|
||||
{
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t *t = &s_state.tickets[i];
|
||||
if (t->generation && t->expires_at_us <= now) secure_wipe(t, sizeof(*t));
|
||||
}
|
||||
}
|
||||
|
||||
/* Fixed two-slot walk. Generation prevents an external check from deleting a
|
||||
* replacement, including when RNG returns the same bytes on a later issue. */
|
||||
static void prune(void)
|
||||
{
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t copy = {0};
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
copy = s_state.tickets[i];
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (copy.generation && !current(copy.id, ©.principal)) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_state.tickets[i].generation == copy.generation)
|
||||
secure_wipe(&s_state.tickets[i], sizeof(ticket_t));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
secure_wipe(©, sizeof(copy));
|
||||
}
|
||||
}
|
||||
|
||||
static void advance_epoch_locked(void)
|
||||
{
|
||||
if (s_state.epoch != UINT64_MAX) ++s_state.epoch;
|
||||
if (s_state.epoch == UINT64_MAX) {
|
||||
s_state.ready = false;
|
||||
secure_wipe(s_state.tickets, sizeof(s_state.tickets));
|
||||
}
|
||||
}
|
||||
|
||||
void web_admin_tickets_start(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!s_state.ready && s_state.epoch != UINT64_MAX &&
|
||||
s_state.generation != UINT64_MAX) {
|
||||
advance_epoch_locked();
|
||||
s_state.ready = s_state.epoch != UINT64_MAX;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_admin_tickets_stop(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
s_state.ready = false;
|
||||
secure_wipe(s_state.tickets, sizeof(s_state.tickets));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static bool capture_epoch(uint64_t *epoch)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
*epoch = s_state.epoch;
|
||||
bool ready = s_state.ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ready;
|
||||
}
|
||||
|
||||
static esp_err_t result(esp_err_t error)
|
||||
{
|
||||
if (error != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
increment(&s_state.rejected);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_admin_tickets_issue(web_session_id_t id,
|
||||
const user_principal_t *principal, char token[WEB_ADMIN_TICKET_LENGTH + 1U])
|
||||
{
|
||||
ticket_t candidate = {0};
|
||||
uint8_t random[32] = {0};
|
||||
uint64_t epoch = 0;
|
||||
esp_err_t error = ESP_ERR_INVALID_ARG;
|
||||
if (token == NULL) return result(error);
|
||||
secure_wipe(token, WEB_ADMIN_TICKET_LENGTH + 1U);
|
||||
if (id == 0 || principal == NULL) goto done;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
if (!capture_epoch(&epoch) || !current(id, principal)) goto done;
|
||||
candidate.id = id;
|
||||
candidate.principal = *principal;
|
||||
prune();
|
||||
if (!current(id, &candidate.principal)) goto done;
|
||||
error = secure_random_fill(random, sizeof(random));
|
||||
/* Recheck even when crypto fails; never use an old authorization result. */
|
||||
bool valid = current(id, &candidate.principal);
|
||||
if (error != ESP_OK) goto done;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
if (!valid) goto done;
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < sizeof(random); ++i) {
|
||||
token[2 * i] = hex[random[i] >> 4];
|
||||
token[2 * i + 1] = hex[random[i] & 15];
|
||||
}
|
||||
int crypto = mbedtls_sha256(random, sizeof(random), candidate.digest, 0);
|
||||
valid = current(id, &candidate.principal);
|
||||
error = crypto == 0 ? ESP_ERR_INVALID_STATE : ESP_FAIL;
|
||||
if (crypto != 0 || !valid) goto done;
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
if (s_state.ready && epoch == s_state.epoch && now >= 0 &&
|
||||
now <= INT64_MAX - WEB_ADMIN_TICKET_LIFETIME_US &&
|
||||
s_state.generation != UINT64_MAX) {
|
||||
ticket_t *free_slot = NULL;
|
||||
bool duplicate = false;
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t *t = &s_state.tickets[i];
|
||||
if (!t->generation) free_slot = t;
|
||||
else if (equal_digest(t->digest, candidate.digest)) duplicate = true;
|
||||
}
|
||||
if (duplicate) error = ESP_FAIL;
|
||||
else if (free_slot == NULL) {
|
||||
increment(&s_state.capacity_rejections);
|
||||
error = ESP_ERR_NO_MEM;
|
||||
} else {
|
||||
candidate.generation = ++s_state.generation;
|
||||
candidate.expires_at_us = now + WEB_ADMIN_TICKET_LIFETIME_US;
|
||||
*free_slot = candidate;
|
||||
increment(&s_state.issued);
|
||||
error = ESP_OK;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
done:
|
||||
secure_wipe(random, sizeof(random));
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
if (error != ESP_OK) secure_wipe(token, WEB_ADMIN_TICKET_LENGTH + 1U);
|
||||
return result(error);
|
||||
}
|
||||
|
||||
static int unhex(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;
|
||||
}
|
||||
|
||||
esp_err_t web_admin_tickets_consume(const char *token, web_session_id_t id,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
uint8_t bytes[32] = {0}, digest[32] = {0};
|
||||
ticket_t found = {0};
|
||||
uint64_t epoch = 0;
|
||||
esp_err_t error = ESP_ERR_INVALID_ARG;
|
||||
if (token == NULL) goto done;
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_LENGTH; ++i) {
|
||||
int n = unhex(token[i]);
|
||||
if (n < 0) goto done;
|
||||
bytes[i / 2] |= (uint8_t)(n << ((i % 2 == 0) ? 4 : 0));
|
||||
}
|
||||
if (token[WEB_ADMIN_TICKET_LENGTH] != '\0') goto done;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
if (!capture_epoch(&epoch)) goto done;
|
||||
bool before = current(id, principal);
|
||||
if (mbedtls_sha256(bytes, sizeof(bytes), digest, 0) != 0) {
|
||||
(void)current(id, principal);
|
||||
error = ESP_FAIL;
|
||||
goto done;
|
||||
}
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
if (s_state.ready && epoch == s_state.epoch) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t *t = &s_state.tickets[i];
|
||||
if (t->generation && equal_digest(t->digest, digest)) {
|
||||
found = *t;
|
||||
secure_wipe(t, sizeof(*t));
|
||||
increment(&s_state.consumed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
/* Burn precedes acting on either currentness result or identity binding. */
|
||||
bool after = current(id, principal);
|
||||
if (found.generation) {
|
||||
now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
error = before && after && found.id == id &&
|
||||
same_principal(&found.principal, principal) && s_state.ready &&
|
||||
epoch == s_state.epoch && now < found.expires_at_us ?
|
||||
ESP_OK : ESP_ERR_INVALID_STATE;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
done:
|
||||
secure_wipe(bytes, sizeof(bytes));
|
||||
secure_wipe(digest, sizeof(digest));
|
||||
secure_wipe(&found, sizeof(found));
|
||||
return result(error);
|
||||
}
|
||||
|
||||
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *username,
|
||||
size_t length)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t *t = &s_state.tickets[i];
|
||||
bool match = id != 0 ? t->id == id : username == NULL ||
|
||||
(length == t->principal.username_length &&
|
||||
length <= USER_DATABASE_USERNAME_CAPACITY &&
|
||||
memcmp(username, t->principal.username, length) == 0);
|
||||
if (match) secure_wipe(t, sizeof(*t));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_admin_tickets_get_snapshot(web_admin_tickets_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) return;
|
||||
prune();
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
*snapshot = (web_admin_tickets_snapshot_t) {
|
||||
.issued = s_state.issued, .consumed = s_state.consumed,
|
||||
.rejected = s_state.rejected,
|
||||
.capacity_rejections = s_state.capacity_rejections,
|
||||
.storage_bytes = sizeof(s_state) + sizeof(s_lock), .ready = s_state.ready,
|
||||
};
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i)
|
||||
if (s_state.tickets[i].generation) ++snapshot->active;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
|
||||
#include "web_session_store.h"
|
||||
|
||||
#define WEB_ADMIN_TICKET_LENGTH 64U
|
||||
#define WEB_ADMIN_TICKET_CAPACITY 2U
|
||||
#define WEB_ADMIN_TICKET_LIFETIME_US 30000000LL
|
||||
|
||||
typedef struct {
|
||||
uint32_t issued;
|
||||
uint32_t consumed;
|
||||
uint32_t rejected;
|
||||
uint32_t capacity_rejections;
|
||||
uint32_t active;
|
||||
size_t storage_bytes;
|
||||
bool ready;
|
||||
} web_admin_tickets_snapshot_t;
|
||||
|
||||
/* Trusted internal API, not HTTP authorization. Start is idempotent while ready;
|
||||
* stop wipes records. Neither lifecycle operation resets epochs or counters.
|
||||
* RNG must already be initialized. Exhausted generations fail closed. */
|
||||
void web_admin_tickets_start(void);
|
||||
void web_admin_tickets_stop(void);
|
||||
/* Only current password-authenticated administrators. No live eviction.
|
||||
* Output must not alias inputs; all 65 bytes are wiped on failure.
|
||||
* NO_MEM: capacity; INVALID_ARG: malformed input; INVALID_STATE: stopped,
|
||||
* stale, unauthorized or raced; FAIL: SHA failure; RNG errors propagate. */
|
||||
esp_err_t web_admin_tickets_issue(web_session_id_t id,
|
||||
const user_principal_t *principal, char token[WEB_ADMIN_TICKET_LENGTH + 1U]);
|
||||
/* Exact hex string (either case). Matching tickets are burned even for wrong
|
||||
* session/principal or failed currentness. NOT_FOUND means no live match.
|
||||
* Crypto failure cannot identify/burn a ticket. Success is not a session lease. */
|
||||
esp_err_t web_admin_tickets_consume(const char *token, web_session_id_t id,
|
||||
const user_principal_t *principal);
|
||||
/* Caller invalidates sessions FIRST. Nonzero ID takes precedence; otherwise
|
||||
* non-NULL username matches exact bytes/length; otherwise revoke all.
|
||||
* Every call cancels in-flight work, even when no record matches. */
|
||||
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *username,
|
||||
size_t length);
|
||||
/* Saturating lifetime counters; consumed counts burned matches, not admissions.
|
||||
* rejected counts failed issue/consume (including capacity). Snapshot prunes
|
||||
* expired/stale records; storage_bytes includes state and lock, no secrets. */
|
||||
void web_admin_tickets_get_snapshot(web_admin_tickets_snapshot_t *snapshot);
|
||||
@@ -0,0 +1,549 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* One optional admin socket. HTTPD owns IO; the canonical dispatcher owns commands. */
|
||||
#include "web_admin_transport.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "secure_random.h"
|
||||
#include "web_admin_tickets.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
|
||||
#define ADMIN_POLL_US 20000ULL
|
||||
#define ADMIN_INPUT_TIMEOUT_US 5000000LL
|
||||
#define ADMIN_DETACH_TIMEOUT_US 2000000LL
|
||||
|
||||
typedef struct {
|
||||
uint8_t rx[WEB_ADMIN_RX_CAPACITY];
|
||||
uint8_t tx[WEB_ADMIN_TX_CAPACITY];
|
||||
size_t rx_length, rx_offset;
|
||||
int64_t input_deadline;
|
||||
} admin_payload_t;
|
||||
|
||||
typedef struct {
|
||||
bool occupied, active, console_open, close_requested, close_triggered, sending;
|
||||
int fd;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
admin_ssh_console_token_t token;
|
||||
} admin_slot_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static admin_slot_t s_slot;
|
||||
static admin_payload_t *s_payload; /* PSRAM; touched only by HTTPD while attached. */
|
||||
static esp_timer_handle_t s_timer;
|
||||
static httpd_handle_t s_server;
|
||||
static bool s_initialized, s_accepting, s_queued;
|
||||
static unsigned s_submitting;
|
||||
static uint32_t s_generation; /* Never wrap/reuse within a boot. */
|
||||
static web_admin_transport_snapshot_t s_counts;
|
||||
|
||||
static void count(uint32_t *value, uint32_t amount)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
*value = UINT32_MAX - *value < amount ? UINT32_MAX : *value + amount;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static bool token_matches(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
return token && s_slot.occupied && s_slot.console_open &&
|
||||
token->transport == ADMIN_CONSOLE_TRANSPORT_WEB &&
|
||||
token->session_id == s_slot.token.session_id &&
|
||||
token->slot_generation == s_slot.token.slot_generation &&
|
||||
token->slot_index == s_slot.token.slot_index;
|
||||
}
|
||||
|
||||
static bool owner_current(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool valid = token_matches(token) && s_accepting && s_slot.active && !s_slot.close_requested;
|
||||
web_session_id_t id = valid ? s_slot.session : 0;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
bool current = false;
|
||||
if (!valid || !principal || principal->role != USER_ROLE_ADMIN ||
|
||||
web_session_store_check_principal(id, principal, ¤t) != ESP_OK || !current)
|
||||
return false;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
valid = token_matches(token) && s_accepting && s_slot.active &&
|
||||
!s_slot.close_requested && s_slot.session == id;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return valid;
|
||||
}
|
||||
|
||||
static bool owner_drained(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool drained = token_matches(token) && s_slot.active &&
|
||||
!s_slot.close_requested && !s_slot.sending;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return drained;
|
||||
}
|
||||
|
||||
static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
|
||||
admin_ssh_deferred_action_type_t action, uint32_t argument)
|
||||
{
|
||||
(void)argument;
|
||||
if (action != ADMIN_CONSOLE_DEFER_SELF_CLOSE) return ESP_ERR_NOT_SUPPORTED;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool valid = token_matches(token) && s_slot.active && s_accepting;
|
||||
if (valid) s_slot.close_requested = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (valid) admin_ssh_console_close(token);
|
||||
return valid ? ESP_OK : ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
static const admin_console_owner_t s_owner = {
|
||||
.supported_actions = 1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE,
|
||||
.is_current = owner_current, .drained = owner_drained, .perform = owner_perform,
|
||||
};
|
||||
|
||||
/* No IO and no payload mutation: safe on console/revocation/lifecycle callers. */
|
||||
static void request_close(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool opened = s_slot.console_open;
|
||||
admin_ssh_console_token_t token = s_slot.token;
|
||||
if (s_slot.occupied) s_slot.close_requested = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (opened) admin_ssh_console_close(&token);
|
||||
}
|
||||
|
||||
/* HTTPD callback, or lifecycle caller ONLY after HTTPD has successfully stopped. */
|
||||
static void session_free(void *context)
|
||||
{
|
||||
if (context != &s_slot) return;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool occupied = s_slot.occupied;
|
||||
bool opened = s_slot.console_open;
|
||||
bool active = s_slot.active;
|
||||
admin_ssh_console_token_t token = s_slot.token;
|
||||
secure_wipe(&s_slot, sizeof(s_slot));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (opened) admin_ssh_console_close(&token);
|
||||
if (occupied && s_payload) secure_wipe(s_payload, sizeof(*s_payload));
|
||||
if (active) count(&s_counts.disconnections, 1);
|
||||
}
|
||||
|
||||
static bool capture(admin_ssh_console_token_t *token, user_principal_t *principal, int *fd)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool active = s_slot.active;
|
||||
*token = s_slot.token;
|
||||
*principal = s_slot.principal;
|
||||
*fd = s_slot.fd;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return active;
|
||||
}
|
||||
|
||||
static bool input_current(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
if (owner_current(token, principal)) return true;
|
||||
count(&s_counts.authorization_rejections, 1);
|
||||
request_close();
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool feed_pending(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
if (s_payload->rx_offset == s_payload->rx_length) return true;
|
||||
if (!input_current(token, principal)) return false;
|
||||
if (esp_timer_get_time() >= s_payload->input_deadline) {
|
||||
count(&s_counts.input_backpressure, 1);
|
||||
request_close();
|
||||
return false;
|
||||
}
|
||||
size_t consumed = 0;
|
||||
(void)admin_ssh_console_feed_input(token, s_payload->rx + s_payload->rx_offset,
|
||||
s_payload->rx_length - s_payload->rx_offset, &consumed);
|
||||
secure_wipe(s_payload->rx + s_payload->rx_offset, consumed);
|
||||
s_payload->rx_offset += consumed;
|
||||
if (s_payload->rx_offset == s_payload->rx_length) {
|
||||
s_payload->rx_offset = s_payload->rx_length = 0;
|
||||
s_payload->input_deadline = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Only this HTTPD work callback sends console output or requests idle closure. */
|
||||
static void poll_work(void *argument)
|
||||
{
|
||||
httpd_handle_t server = argument;
|
||||
admin_ssh_console_token_t token;
|
||||
user_principal_t principal;
|
||||
int fd;
|
||||
bool active = capture(&token, &principal, &fd);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool attached = s_accepting && server == s_server;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!active || !attached) goto done;
|
||||
if (!input_current(&token, &principal)) goto closing;
|
||||
if (httpd_sess_get_ctx(server, fd) != &s_slot ||
|
||||
httpd_ws_get_fd_info(server, fd) != HTTPD_WS_CLIENT_WEBSOCKET) {
|
||||
request_close();
|
||||
goto closing;
|
||||
}
|
||||
admin_ssh_console_session_snapshot_t console;
|
||||
if (admin_ssh_console_get_session_snapshot(&token, &console) != ESP_OK || !console.active) {
|
||||
request_close();
|
||||
goto closing;
|
||||
}
|
||||
if (!feed_pending(&token, &principal)) goto closing;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_slot.sending = true; /* Covers the gap between ring consumption and socket send. */
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
size_t length = 0;
|
||||
esp_err_t error = admin_ssh_console_read_output(&token, s_payload->tx,
|
||||
sizeof(s_payload->tx), &length);
|
||||
if (error == ESP_OK && length && input_current(&token, &principal)) {
|
||||
httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_BINARY,
|
||||
.payload = s_payload->tx, .len = length};
|
||||
error = httpd_ws_send_frame_async(server, fd, &frame);
|
||||
if (error == ESP_OK) count(&s_counts.tx_bytes, (uint32_t)length);
|
||||
}
|
||||
secure_wipe(s_payload->tx, sizeof(s_payload->tx));
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_slot.sending = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (error != ESP_OK) {
|
||||
count(&s_counts.send_failures, 1);
|
||||
request_close();
|
||||
}
|
||||
closing:
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool close = s_slot.active && s_slot.close_requested && !s_slot.close_triggered;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (close && httpd_sess_get_ctx(server, fd) == &s_slot) {
|
||||
/* IDF's queued close retains a reusable sock_db pointer. Shutdown on
|
||||
* HTTPD instead: its next read owns deletion, with no late close that
|
||||
* could evict a replacement (including a serial client). */
|
||||
if (shutdown(fd, SHUT_RDWR) == 0) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_slot.close_triggered = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
} else count(&s_counts.send_failures, 1); /* Retry on the next bounded poll. */
|
||||
}
|
||||
done:
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_queued = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
/* ESP timer task: no database/console/socket calls, no waits, one queue entry max.
|
||||
* Detach prevents new submissions and fences any submission already outside lock. */
|
||||
static void poll_timer(void *argument)
|
||||
{
|
||||
(void)argument;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
httpd_handle_t server = NULL;
|
||||
uint32_t generation = 0;
|
||||
if (s_accepting && s_slot.active && !s_queued) {
|
||||
server = s_server;
|
||||
generation = s_slot.token.slot_generation;
|
||||
s_queued = true;
|
||||
++s_submitting;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!server) return;
|
||||
esp_err_t error = httpd_queue_work(server, poll_work, server);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
--s_submitting;
|
||||
if (error != ESP_OK) {
|
||||
s_queued = false;
|
||||
if (s_slot.active && s_slot.token.slot_generation == generation)
|
||||
s_slot.close_requested = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (error != ESP_OK) count(&s_counts.queue_failures, 1);
|
||||
}
|
||||
|
||||
esp_err_t web_admin_transport_init(void)
|
||||
{
|
||||
#if defined(CONFIG_HTTPD_QUEUE_WORK_BLOCKING) && CONFIG_HTTPD_QUEUE_WORK_BLOCKING
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
#else
|
||||
if (s_initialized) return ESP_OK; /* Lifecycle caller serializes initialization. */
|
||||
admin_payload_t *payload = heap_caps_calloc(1, sizeof(*payload), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
esp_err_t error = payload ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
esp_timer_handle_t timer = NULL;
|
||||
const esp_timer_create_args_t args = {
|
||||
.callback = poll_timer, .name = "web_admin", .skip_unhandled_events = true,
|
||||
};
|
||||
if (error == ESP_OK) error = esp_timer_create(&args, &timer);
|
||||
if (error == ESP_OK) error = esp_timer_start_periodic(timer, ADMIN_POLL_US);
|
||||
if (error != ESP_OK) {
|
||||
if (timer) (void)esp_timer_delete(timer);
|
||||
if (payload) heap_caps_free(payload);
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (error == ESP_OK) {
|
||||
s_payload = payload;
|
||||
s_timer = timer;
|
||||
s_initialized = true;
|
||||
}
|
||||
s_counts.last_error = error;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return error;
|
||||
#endif
|
||||
}
|
||||
|
||||
esp_err_t web_admin_transport_attach(httpd_handle_t server)
|
||||
{
|
||||
if (!server) return ESP_ERR_INVALID_ARG;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool allowed = s_initialized && !s_server && !s_queued && !s_submitting && !s_slot.occupied;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!allowed) return ESP_ERR_INVALID_STATE;
|
||||
web_admin_tickets_start();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_server = server;
|
||||
s_accepting = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_admin_transport_detach(httpd_handle_t server)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool owned = server && server == s_server;
|
||||
if (owned) s_accepting = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!owned) return ESP_ERR_INVALID_STATE;
|
||||
web_admin_tickets_stop();
|
||||
request_close();
|
||||
int64_t deadline = esp_timer_get_time() + ADMIN_DETACH_TIMEOUT_US;
|
||||
for (;;) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool submitting = s_submitting != 0;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!submitting) return ESP_OK;
|
||||
if (esp_timer_get_time() >= deadline) return ESP_ERR_TIMEOUT;
|
||||
vTaskDelay(1);
|
||||
}
|
||||
}
|
||||
|
||||
void web_admin_transport_stopped(httpd_handle_t server)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool owned = server && s_server == server && !s_accepting && !s_submitting;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!owned) return;
|
||||
session_free(&s_slot);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_server = NULL;
|
||||
s_queued = false; /* HTTPD is gone; its queued callbacks can no longer execute. */
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_admin_transport_revoke(web_session_id_t id, const uint8_t *username, size_t length)
|
||||
{
|
||||
web_admin_tickets_revoke(id, username, length);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool match = s_slot.occupied && (id ? s_slot.session == id :
|
||||
!username || (length == s_slot.principal.username_length &&
|
||||
length <= USER_DATABASE_USERNAME_CAPACITY &&
|
||||
memcmp(username, s_slot.principal.username, length) == 0));
|
||||
admin_ssh_console_token_t token = s_slot.token;
|
||||
bool opened = match && s_slot.console_open;
|
||||
if (match) s_slot.close_requested = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (opened) admin_ssh_console_close(&token);
|
||||
}
|
||||
|
||||
static esp_err_t response(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(request, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t deny(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
if (!strcmp(status, "503 Service Unavailable") &&
|
||||
httpd_resp_set_hdr(request, "Retry-After", "5") != ESP_OK) return ESP_FAIL;
|
||||
(void)response(request, status, body);
|
||||
return ESP_FAIL; /* Close after rejection, never leave unread frames/body alive. */
|
||||
}
|
||||
|
||||
esp_err_t web_admin_transport_ticket_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
char ticket[WEB_ADMIN_TICKET_LENGTH + 1U] = {0}, body[128] = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto cleanup;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
count(&s_counts.authorization_rejections, 1);
|
||||
error = deny(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool attached = s_accepting && s_server == request->handle;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
error = attached ? web_admin_tickets_issue(view.id, &view.principal, ticket) : ESP_ERR_INVALID_STATE;
|
||||
if (error != ESP_OK) {
|
||||
if (error == ESP_ERR_NO_MEM) count(&s_counts.capacity_rejections, 1);
|
||||
error = deny(request, "503 Service Unavailable", "{\"error\":\"admin_unavailable_or_capacity\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
int n = snprintf(body, sizeof(body), "{\"ticket\":\"%s\",\"expires_in\":30}", ticket);
|
||||
error = n > 0 && (size_t)n < sizeof(body) ? response(request, "200 OK", body) : ESP_FAIL;
|
||||
cleanup:
|
||||
secure_wipe(ticket, sizeof(ticket));
|
||||
secure_wipe(body, sizeof(body));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t frame_handler(httpd_req_t *request)
|
||||
{
|
||||
admin_ssh_console_token_t token;
|
||||
user_principal_t principal;
|
||||
int fd;
|
||||
bool active = capture(&token, &principal, &fd);
|
||||
bool valid = active && request->sess_ctx == &s_slot &&
|
||||
fd == httpd_req_to_sockfd(request) && input_current(&token, &principal);
|
||||
if (!valid) goto failure;
|
||||
httpd_ws_frame_t frame = {0};
|
||||
if (httpd_ws_recv_frame(request, &frame, 0) != ESP_OK || !frame.final ||
|
||||
frame.type != HTTPD_WS_TYPE_BINARY || frame.len > WEB_ADMIN_RX_CAPACITY) {
|
||||
count(&s_counts.protocol_errors, 1);
|
||||
goto failure;
|
||||
}
|
||||
if (s_payload->rx_length != s_payload->rx_offset) {
|
||||
count(&s_counts.input_backpressure, 1);
|
||||
goto failure;
|
||||
}
|
||||
frame.payload = s_payload->rx;
|
||||
/* IDF treats len==0 as another header probe, not an empty payload read. */
|
||||
if (frame.len && httpd_ws_recv_frame(request, &frame, sizeof(s_payload->rx)) != ESP_OK)
|
||||
goto failure;
|
||||
s_payload->rx_length = frame.len;
|
||||
s_payload->rx_offset = 0;
|
||||
s_payload->input_deadline = esp_timer_get_time() + ADMIN_INPUT_TIMEOUT_US;
|
||||
if (!feed_pending(&token, &principal)) goto failure;
|
||||
count(&s_counts.rx_bytes, (uint32_t)frame.len);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
return ESP_OK;
|
||||
failure:
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
request_close();
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
esp_err_t web_admin_transport_upgrade_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
char ticket[WEB_ADMIN_TICKET_LENGTH + 1U] = {0};
|
||||
admin_ssh_console_token_t token = {0};
|
||||
bool allowed = false, reserved = false, opened = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, false, true, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto cleanup;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
count(&s_counts.authorization_rejections, 1);
|
||||
error = deny(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
static const char prefix[] = WEB_ADMIN_WS_URI "?ticket=";
|
||||
if (!web_httpd_upgrade_requested(request) ||
|
||||
strncmp(request->uri, prefix, sizeof(prefix) - 1U) ||
|
||||
strlen(request->uri) != sizeof(prefix) - 1U + WEB_ADMIN_TICKET_LENGTH) {
|
||||
error = deny(request, "400 Bad Request", "{\"error\":\"invalid_upgrade\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
memcpy(ticket, request->uri + sizeof(prefix) - 1U, WEB_ADMIN_TICKET_LENGTH);
|
||||
if (web_admin_tickets_consume(ticket, view.id, &view.principal) != ESP_OK) {
|
||||
count(&s_counts.authorization_rejections, 1);
|
||||
error = deny(request, "403 Forbidden", "{\"error\":\"invalid_ticket\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
int socket_fd = httpd_req_to_sockfd(request);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (socket_fd >= 0 && s_accepting && s_server == request->handle &&
|
||||
!s_slot.occupied && s_generation != UINT32_MAX) {
|
||||
++s_generation;
|
||||
token = (admin_ssh_console_token_t){.transport = ADMIN_CONSOLE_TRANSPORT_WEB,
|
||||
.session_id = s_generation, .slot_generation = s_generation};
|
||||
s_slot.occupied = true;
|
||||
s_slot.session = view.id;
|
||||
s_slot.principal = view.principal;
|
||||
s_slot.fd = socket_fd;
|
||||
s_slot.token = token;
|
||||
reserved = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!reserved) {
|
||||
count(&s_counts.capacity_rejections, 1);
|
||||
error = deny(request, "503 Service Unavailable", "{\"error\":\"admin_capacity\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
error = admin_ssh_console_open_available(&token, &view.principal, &s_owner);
|
||||
if (error != ESP_OK) {
|
||||
count(&s_counts.capacity_rejections, 1);
|
||||
error = deny(request, "503 Service Unavailable", "{\"error\":\"console_capacity_or_unavailable\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
opened = true;
|
||||
bool current = false;
|
||||
error = web_session_store_check_principal(view.id, &view.principal, ¤t);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_slot.token = token;
|
||||
s_slot.console_open = true;
|
||||
bool admitted = error == ESP_OK && current && s_accepting && !s_slot.close_requested;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!admitted) {
|
||||
error = deny(request, "403 Forbidden", "{\"error\":\"session_revoked\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
error = web_httpd_upgrade(request, frame_handler);
|
||||
if (error != ESP_OK) goto cleanup;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admitted = s_accepting && !s_slot.close_requested;
|
||||
if (admitted) s_slot.active = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!admitted) { error = ESP_FAIL; goto cleanup; }
|
||||
request->sess_ctx = &s_slot;
|
||||
request->free_ctx = session_free;
|
||||
count(&s_counts.connections, 1);
|
||||
reserved = false; /* HTTPD context now owns cleanup. */
|
||||
cleanup:
|
||||
if (reserved) {
|
||||
if (opened) admin_ssh_console_close(&token);
|
||||
session_free(&s_slot);
|
||||
}
|
||||
secure_wipe(ticket, sizeof(ticket));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_admin_transport_get_snapshot(web_admin_transport_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot) return;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
*snapshot = s_counts;
|
||||
snapshot->initialized = s_initialized;
|
||||
snapshot->attached = s_accepting;
|
||||
snapshot->active = s_slot.active;
|
||||
snapshot->closing = s_slot.close_requested;
|
||||
snapshot->payload_bytes = s_payload ? sizeof(*s_payload) : 0;
|
||||
snapshot->static_bytes = sizeof(s_lock) + sizeof(s_slot) + sizeof(s_payload) +
|
||||
sizeof(s_timer) + sizeof(s_server) + sizeof(s_initialized) + sizeof(s_accepting) +
|
||||
sizeof(s_queued) + sizeof(s_submitting) + sizeof(s_generation) + sizeof(s_counts);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
|
||||
#include "esp_http_server.h"
|
||||
#include "web_session_store.h"
|
||||
|
||||
#define WEB_ADMIN_TICKET_URI "/api/admin/ws-ticket"
|
||||
#define WEB_ADMIN_WS_URI "/ws/admin"
|
||||
#define WEB_ADMIN_MAX_SESSIONS 1U
|
||||
#define WEB_ADMIN_RX_CAPACITY 512U
|
||||
#define WEB_ADMIN_TX_CAPACITY 1024U
|
||||
|
||||
typedef struct {
|
||||
bool initialized, attached, active, closing;
|
||||
uint32_t connections, disconnections, capacity_rejections, authorization_rejections;
|
||||
uint32_t protocol_errors, input_backpressure, send_failures, queue_failures;
|
||||
uint32_t rx_bytes, tx_bytes;
|
||||
size_t static_bytes, payload_bytes;
|
||||
esp_err_t last_error;
|
||||
} web_admin_transport_snapshot_t;
|
||||
|
||||
/* Lifecycle caller serializes init/attach/detach/stopped. Optional PSRAM-only
|
||||
* payload allocation; no internal fallback, new task, broker client or dispatcher.
|
||||
* Timer only queues at most one poll; HTTPD owns all payload/IO/session cleanup. */
|
||||
esp_err_t web_admin_transport_init(void);
|
||||
esp_err_t web_admin_transport_attach(httpd_handle_t server);
|
||||
/* Disable admission and console access, then wait a bounded time for timer
|
||||
* submissions to finish. On timeout do NOT stop/free HTTPD; retry detach first. */
|
||||
esp_err_t web_admin_transport_detach(httpd_handle_t server);
|
||||
/* Call ONLY after successful httpd_ssl_stop, including partial startup cleanup.
|
||||
* Retires any unexecuted queued poll before allowing reuse of its static storage. */
|
||||
void web_admin_transport_stopped(httpd_handle_t server);
|
||||
|
||||
/* Ordinary HTTP routes, never register is_websocket=true: admission before 101.
|
||||
* These handlers enforce cookie/Origin/CSRF/role themselves. Binary frames carry
|
||||
* console bytes, final/unfragmented, at most RX_CAPACITY; no serial controls. */
|
||||
esp_err_t web_admin_transport_ticket_handler(httpd_req_t *request);
|
||||
esp_err_t web_admin_transport_upgrade_handler(httpd_req_t *request);
|
||||
/* Notification after authoritative store invalidation. id wins; else exact
|
||||
* username; else all. Safe before init. No socket calls from notifier context. */
|
||||
void web_admin_transport_revoke(web_session_id_t id, const uint8_t *username, size_t length);
|
||||
void web_admin_transport_get_snapshot(web_admin_transport_snapshot_t *snapshot);
|
||||
@@ -0,0 +1,244 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_auth_parse.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static void wipe(void *buffer, size_t length)
|
||||
{
|
||||
volatile uint8_t *p = buffer;
|
||||
while (length--) *p++ = 0;
|
||||
}
|
||||
|
||||
static bool alnum_ascii(unsigned char c)
|
||||
{
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9');
|
||||
}
|
||||
|
||||
static bool authority(const char *text, size_t length, char *out)
|
||||
{
|
||||
if (!text || !length || length > WEB_AUTH_ORIGIN_CAPACITY - 5U) return false;
|
||||
if (length >= 4U && memcmp(text + length - 4U, ":443", 4U) == 0) length -= 4U;
|
||||
if (!length || length > WEB_AUTH_ORIGIN_CAPACITY - 9U) return false;
|
||||
size_t label = 0;
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
unsigned char c = (unsigned char)text[i];
|
||||
if (c == '.') {
|
||||
if (!label || text[i - 1U] == '-') return false;
|
||||
label = 0;
|
||||
} else {
|
||||
if (!alnum_ascii(c) && c != '-') return false;
|
||||
if ((!label && c == '-') || ++label > 63U) return false;
|
||||
}
|
||||
out[i] = c >= 'A' && c <= 'Z' ? (char)(c + ('a' - 'A')) : (char)c;
|
||||
}
|
||||
if (!label || text[length - 1U] == '-') return false;
|
||||
out[length] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_origin(const char *host, size_t host_length,
|
||||
const char *origin, size_t origin_length,
|
||||
char canonical[WEB_AUTH_ORIGIN_CAPACITY])
|
||||
{
|
||||
if (!canonical) return false;
|
||||
memset(canonical, 0, WEB_AUTH_ORIGIN_CAPACITY);
|
||||
char other[WEB_AUTH_ORIGIN_CAPACITY] = {0};
|
||||
if (!origin || origin_length < 9U || origin_length > WEB_AUTH_ORIGIN_CAPACITY + 3U ||
|
||||
memcmp(origin, "https://", 8U) != 0 ||
|
||||
!authority(host, host_length, canonical + 8U) ||
|
||||
!authority(origin + 8U, origin_length - 8U, other) ||
|
||||
strcmp(canonical + 8U, other) != 0) {
|
||||
memset(canonical, 0, WEB_AUTH_ORIGIN_CAPACITY);
|
||||
return false;
|
||||
}
|
||||
memcpy(canonical, "https://", 8U);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool cookie_name_char(unsigned char c)
|
||||
{
|
||||
return alnum_ascii(c) || (c && strchr("!#$%&'*+-.^_`|~", c));
|
||||
}
|
||||
|
||||
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present)
|
||||
{
|
||||
if (!present) return false;
|
||||
*present = false;
|
||||
if (!token) return false;
|
||||
memset(token, 0, WEB_AUTH_TOKEN_LENGTH + 1U);
|
||||
if (!header || !name || !*name || !length || length > WEB_AUTH_COOKIE_HEADER_MAX)
|
||||
return false;
|
||||
size_t pos = 0, selected = 0, name_length = strlen(name);
|
||||
bool found = false;
|
||||
while (pos < length) {
|
||||
while (pos < length && header[pos] == ' ') ++pos;
|
||||
size_t start = pos;
|
||||
while (pos < length && cookie_name_char((unsigned char)header[pos])) ++pos;
|
||||
size_t key_length = pos - start;
|
||||
if (!key_length || pos == length || header[pos++] != '=') return false;
|
||||
size_t value = pos;
|
||||
while (pos < length && header[pos] != ';') {
|
||||
unsigned char c = (unsigned char)header[pos++];
|
||||
if (c < 0x21 || c > 0x7e || c == '"' || c == ',' || c == '\\') return false;
|
||||
}
|
||||
if (key_length == name_length && memcmp(header + start, name, key_length) == 0) {
|
||||
if (found || pos - value != WEB_AUTH_TOKEN_LENGTH) return false;
|
||||
for (size_t i = value; i < pos; ++i)
|
||||
if (!((header[i] >= '0' && header[i] <= '9') ||
|
||||
(header[i] >= 'a' && header[i] <= 'f'))) return false;
|
||||
found = true;
|
||||
selected = value;
|
||||
}
|
||||
if (pos < length && ++pos == length) return false;
|
||||
}
|
||||
if (found) memcpy(token, header + selected, WEB_AUTH_TOKEN_LENGTH);
|
||||
*present = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U])
|
||||
{
|
||||
bool present = false;
|
||||
return web_auth_parse_optional_cookie(header, length, name, token, &present) && present;
|
||||
}
|
||||
|
||||
typedef struct { const uint8_t *data; size_t length; size_t pos; } json_cursor_t;
|
||||
|
||||
static void whitespace(json_cursor_t *c)
|
||||
{
|
||||
while (c->pos < c->length) {
|
||||
uint8_t b = c->data[c->pos];
|
||||
if (b != ' ' && b != '\t' && b != '\r' && b != '\n') break;
|
||||
++c->pos;
|
||||
}
|
||||
}
|
||||
|
||||
static bool take(json_cursor_t *c, uint8_t byte)
|
||||
{
|
||||
whitespace(c);
|
||||
if (c->pos == c->length || c->data[c->pos] != byte) return false;
|
||||
++c->pos;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool hex4(json_cursor_t *c, uint32_t *value)
|
||||
{
|
||||
*value = 0;
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
if (c->pos == c->length) return false;
|
||||
uint8_t b = c->data[c->pos++];
|
||||
unsigned digit;
|
||||
if (b >= '0' && b <= '9') digit = b - '0';
|
||||
else if (b >= 'a' && b <= 'f') digit = b - 'a' + 10U;
|
||||
else if (b >= 'A' && b <= 'F') digit = b - 'A' + 10U;
|
||||
else return false;
|
||||
*value = (*value << 4) | digit;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool codepoint(json_cursor_t *c, uint32_t *value)
|
||||
{
|
||||
if (c->pos == c->length) return false;
|
||||
uint8_t b = c->data[c->pos++];
|
||||
if (b == '\\') {
|
||||
if (c->pos == c->length) return false;
|
||||
b = c->data[c->pos++];
|
||||
switch (b) {
|
||||
case '"': case '\\': case '/': *value = b; return true;
|
||||
case 'b': *value = 8; return true;
|
||||
case 'f': *value = 12; return true;
|
||||
case 'n': *value = 10; return true;
|
||||
case 'r': *value = 13; return true;
|
||||
case 't': *value = 9; return true;
|
||||
case 'u': break;
|
||||
default: return false;
|
||||
}
|
||||
if (!hex4(c, value)) return false;
|
||||
if (*value >= 0xd800 && *value <= 0xdbff) {
|
||||
uint32_t low;
|
||||
if (c->length - c->pos < 2U || c->data[c->pos++] != '\\' ||
|
||||
c->data[c->pos++] != 'u' || !hex4(c, &low) ||
|
||||
low < 0xdc00 || low > 0xdfff) return false;
|
||||
*value = 0x10000 + ((*value - 0xd800) << 10) + low - 0xdc00;
|
||||
}
|
||||
return *value && !(*value >= 0xd800 && *value <= 0xdfff);
|
||||
}
|
||||
if (b < 0x20) return false;
|
||||
if (b < 0x80) { *value = b; return true; }
|
||||
unsigned extra;
|
||||
uint32_t minimum;
|
||||
if (b >= 0xc2 && b <= 0xdf) { extra = 1; minimum = 0x80; *value = b & 0x1f; }
|
||||
else if (b >= 0xe0 && b <= 0xef) { extra = 2; minimum = 0x800; *value = b & 0x0f; }
|
||||
else if (b >= 0xf0 && b <= 0xf4) { extra = 3; minimum = 0x10000; *value = b & 7; }
|
||||
else return false;
|
||||
while (extra--) {
|
||||
if (c->pos == c->length) return false;
|
||||
b = c->data[c->pos++];
|
||||
if ((b & 0xc0) != 0x80) return false;
|
||||
*value = (*value << 6) | (b & 0x3f);
|
||||
}
|
||||
return *value >= minimum && *value <= 0x10ffff &&
|
||||
!(*value >= 0xd800 && *value <= 0xdfff);
|
||||
}
|
||||
|
||||
static bool string(json_cursor_t *c, uint8_t *out, size_t capacity, size_t *length)
|
||||
{
|
||||
*length = 0;
|
||||
if (!take(c, '"')) return false;
|
||||
while (c->pos < c->length && c->data[c->pos] != '"') {
|
||||
uint32_t cp;
|
||||
if (!codepoint(c, &cp)) return false;
|
||||
size_t bytes = cp < 0x80 ? 1U : cp < 0x800 ? 2U : cp < 0x10000 ? 3U : 4U;
|
||||
if (bytes > capacity - *length) return false;
|
||||
if (bytes == 1U) out[(*length)++] = (uint8_t)cp;
|
||||
else {
|
||||
out[(*length)++] = (uint8_t)((bytes == 2U ? 0xc0 : bytes == 3U ? 0xe0 : 0xf0) |
|
||||
(cp >> (6U * (bytes - 1U))));
|
||||
for (size_t i = bytes - 1U; i > 0; --i)
|
||||
out[(*length)++] = (uint8_t)(0x80 | ((cp >> (6U * (i - 1U))) & 0x3f));
|
||||
}
|
||||
}
|
||||
if (c->pos == c->length) return false;
|
||||
++c->pos;
|
||||
out[*length] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_login(const char *body, size_t length,
|
||||
web_auth_credentials_t *credentials)
|
||||
{
|
||||
if (!credentials) return false;
|
||||
wipe(credentials, sizeof(*credentials));
|
||||
if (!body || !length || length > WEB_AUTH_LOGIN_BODY_MAX) return false;
|
||||
json_cursor_t c = { (const uint8_t *)body, length, 0 };
|
||||
unsigned seen = 0;
|
||||
if (!take(&c, '{')) return false;
|
||||
for (unsigned field = 0; field < 2; ++field) {
|
||||
uint8_t key[9] = {0};
|
||||
size_t key_length;
|
||||
if ((field && !take(&c, ',')) || !string(&c, key, 8U, &key_length) ||
|
||||
!take(&c, ':')) goto invalid;
|
||||
unsigned bit;
|
||||
uint8_t *output;
|
||||
size_t *output_length, capacity;
|
||||
if (key_length == 8U && memcmp(key, "username", 8U) == 0) {
|
||||
bit = 1; output = credentials->username;
|
||||
output_length = &credentials->username_length; capacity = WEB_AUTH_USERNAME_MAX;
|
||||
} else if (key_length == 8U && memcmp(key, "password", 8U) == 0) {
|
||||
bit = 2; output = credentials->password;
|
||||
output_length = &credentials->password_length; capacity = WEB_AUTH_PASSWORD_MAX;
|
||||
} else goto invalid;
|
||||
if ((seen & bit) || !string(&c, output, capacity, output_length)) goto invalid;
|
||||
seen |= bit;
|
||||
}
|
||||
if (!take(&c, '}')) goto invalid;
|
||||
whitespace(&c);
|
||||
if (c.pos == c.length && seen == 3U) return true;
|
||||
invalid:
|
||||
wipe(credentials, sizeof(*credentials));
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Private, allocation-free parsing only: these helpers do not authorize requests. */
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define WEB_AUTH_ORIGIN_CAPACITY 129U
|
||||
#define WEB_AUTH_COOKIE_HEADER_MAX 1024U
|
||||
#define WEB_AUTH_TOKEN_LENGTH 64U
|
||||
#define WEB_AUTH_LOGIN_BODY_MAX 512U
|
||||
#define WEB_AUTH_USERNAME_MAX 16U
|
||||
#define WEB_AUTH_PASSWORD_MAX 64U
|
||||
|
||||
typedef struct {
|
||||
size_t username_length;
|
||||
size_t password_length;
|
||||
uint8_t username[WEB_AUTH_USERNAME_MAX + 1U];
|
||||
uint8_t password[WEB_AUTH_PASSWORD_MAX + 1U];
|
||||
} web_auth_credentials_t;
|
||||
|
||||
/* Exact byte spans, not necessarily NUL-terminated. Inputs and output must not
|
||||
* alias. Failures clear output. Host supports ASCII DNS/IPv4 authorities only;
|
||||
* IPv6 literals are deliberately rejected until the device supports that route.
|
||||
* Only optional :443 is accepted. Origin is mandatory and must match Host.
|
||||
* HTTP callers must separately reject duplicate header lines, enforce methods,
|
||||
* body/content-type limits, Fetch Metadata and CSRF/session policy. */
|
||||
bool web_auth_parse_origin(const char *host, size_t host_length,
|
||||
const char *origin, size_t origin_length,
|
||||
char canonical[WEB_AUTH_ORIGIN_CAPACITY]);
|
||||
/* Extract exactly one named lowercase-hex token; malformed/duplicate or missing
|
||||
* selected cookie fails. Other cookies are syntax-checked but not retained.
|
||||
* This deliberately accepts only unquoted cookie values, including unrelated
|
||||
* cookies; quoted values fail closed. No whitespace inside a cookie pair.
|
||||
* name is a trusted, nonempty C string. Output is sensitive: wipe after use. */
|
||||
bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U]);
|
||||
/* As above, but a missing selected cookie is valid with present=false. This
|
||||
* lets HTTP policy distinguish absence from malformed/ambiguous cookies. */
|
||||
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present);
|
||||
/* Exactly username/password string fields, either order. JSON escapes and valid
|
||||
* UTF-8 accepted; unknown/duplicate fields, NUL and malformed Unicode rejected.
|
||||
* Database credential policy remains authoritative. Caller must wipe BOTH the
|
||||
* original request body and successful credentials using secure_wipe(). */
|
||||
bool web_auth_parse_login(const char *body, size_t length,
|
||||
web_auth_credentials_t *credentials);
|
||||
+42
-1
@@ -14,6 +14,9 @@
|
||||
#include "web_security.h"
|
||||
#include "web_serial_transport.h"
|
||||
#include "web_server.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_admin_transport.h"
|
||||
#include "web_admin_tickets.h"
|
||||
|
||||
static void print_usage(void)
|
||||
{
|
||||
@@ -34,6 +37,30 @@ static void print_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LENG
|
||||
}
|
||||
}
|
||||
|
||||
static void show_admin_transport(void)
|
||||
{
|
||||
web_admin_transport_snapshot_t admin;
|
||||
web_admin_tickets_snapshot_t tickets;
|
||||
web_admin_transport_get_snapshot(&admin);
|
||||
web_admin_tickets_get_snapshot(&tickets);
|
||||
printf("WebSocket admin: initialized=%s attached=%s active=%s/1 closing=%s init-error=%s\n",
|
||||
admin.initialized ? "yes" : "no", admin.attached ? "yes" : "no",
|
||||
admin.active ? "yes" : "no", admin.closing ? "yes" : "no", esp_err_to_name(admin.last_error));
|
||||
printf(" tickets=%" PRIu32 "/%u issued=%" PRIu32 " consumed=%" PRIu32
|
||||
" rejected=%" PRIu32 " capacity=%" PRIu32 "\n",
|
||||
tickets.active, WEB_ADMIN_TICKET_CAPACITY, tickets.issued, tickets.consumed,
|
||||
tickets.rejected, tickets.capacity_rejections);
|
||||
printf(" connected=%" PRIu32 " disconnected=%" PRIu32 " capacity=%" PRIu32
|
||||
" authorization=%" PRIu32 " protocol=%" PRIu32 " input-backpressure=%" PRIu32 "\n",
|
||||
admin.connections, admin.disconnections, admin.capacity_rejections,
|
||||
admin.authorization_rejections, admin.protocol_errors, admin.input_backpressure);
|
||||
printf(" rx-bytes=%" PRIu32 " tx-bytes=%" PRIu32 " send-failures=%" PRIu32
|
||||
" queue-failures=%" PRIu32 " static=%u ticket-storage=%u PSRAM-payload=%u bytes\n",
|
||||
admin.rx_bytes, admin.tx_bytes, admin.send_failures, admin.queue_failures,
|
||||
(unsigned)admin.static_bytes, (unsigned)tickets.storage_bytes, (unsigned)admin.payload_bytes);
|
||||
printf(" Admin counters are saturating lifetime counts (not reset by web clear-counters).\n");
|
||||
}
|
||||
|
||||
static int show_status(void)
|
||||
{
|
||||
web_server_snapshot_t snapshot;
|
||||
@@ -52,13 +79,26 @@ static int show_status(void)
|
||||
(unsigned int)snapshot.port,
|
||||
esp_err_to_name(snapshot.last_error));
|
||||
if (users_error == ESP_OK) {
|
||||
printf("Authentication: HTTP Basic over TLS via user database, users=%u admins=%u\n",
|
||||
printf("Authentication: HTTPS cookie sessions via user database, users=%u admins=%u\n",
|
||||
(unsigned int)users.user_count, (unsigned int)users.admin_count);
|
||||
} else {
|
||||
printf("Authentication database unavailable: %s; use 'user recover --force'.\n",
|
||||
esp_err_to_name(users_error));
|
||||
}
|
||||
printf("Endpoints: GET /, GET /api/status, POST /api/ws-ticket, WSS /ws/serial\n");
|
||||
printf("Authentication routes: GET /login, GET /api/login-challenge, POST /api/login, GET /api/session, POST /api/logout\n");
|
||||
printf("Admin-only backend: POST /api/admin/ws-ticket, WSS /ws/admin (no normal UI entry)\n");
|
||||
show_admin_transport();
|
||||
web_cookie_auth_snapshot_t auth;
|
||||
web_cookie_auth_get_snapshot(&auth);
|
||||
web_session_store_snapshot_t sessions;
|
||||
if (web_session_store_get_snapshot(&sessions) == ESP_OK)
|
||||
printf("Cookie authentication: ready=%s sessions=%" PRIu32 "/4 challenges=%" PRIu32 "/4\n",
|
||||
auth.ready ? "yes" : "no", sessions.active, auth.active_challenges);
|
||||
printf("Login attempts=%" PRIu32 " invalid-credentials=%" PRIu32 " throttled=%" PRIu32
|
||||
" auth-capacity-rejections=%" PRIu32 " CSRF/origin-rejections=%" PRIu32 " logouts=%" PRIu32 "\n",
|
||||
auth.login_attempts, auth.login_failures, auth.throttled, auth.capacity_rejections,
|
||||
auth.security_rejections, auth.logouts);
|
||||
|
||||
web_serial_transport_snapshot_t transport;
|
||||
esp_err_t transport_error = web_serial_transport_get_snapshot(&transport);
|
||||
@@ -110,6 +150,7 @@ static int show_counters(void)
|
||||
return 1;
|
||||
}
|
||||
|
||||
show_admin_transport();
|
||||
const web_server_counters_t *counter = &snapshot.counters;
|
||||
printf("Lifecycle: starts=%" PRIu64 " start-failures=%" PRIu64
|
||||
" stops=%" PRIu64 "\n",
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_cookie_auth.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "secure_random.h"
|
||||
#include "web_auth_parse.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "web_login_ui.h"
|
||||
#include "web_serial_transport.h"
|
||||
|
||||
#define SESSION_COOKIE "__Host-sak-session"
|
||||
#define PRELOGIN_COOKIE "__Host-sak-prelogin"
|
||||
#define COOKIE_FLAGS "; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age="
|
||||
#define CHALLENGE_US 120000000LL
|
||||
#define WINDOW_US 60000000LL
|
||||
|
||||
typedef struct {
|
||||
int64_t expiry;
|
||||
uint8_t token_digest[32];
|
||||
uint8_t origin_digest[32];
|
||||
char csrf[65];
|
||||
} challenge_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static challenge_t s_challenges[4];
|
||||
static bool s_ready;
|
||||
static uint64_t s_epoch;
|
||||
static int64_t s_window;
|
||||
static unsigned s_attempts;
|
||||
static web_cookie_auth_snapshot_t s_counts;
|
||||
|
||||
static bool equal(const void *a, const void *b, size_t size)
|
||||
{
|
||||
const uint8_t *x = a, *y = b;
|
||||
unsigned difference = 0;
|
||||
for (size_t i = 0; i < size; ++i) difference |= x[i] ^ y[i];
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
static bool header(httpd_req_t *r, const char *name, char *out, size_t size)
|
||||
{
|
||||
size_t length = httpd_req_get_hdr_value_len(r, name);
|
||||
out[0] = 0;
|
||||
return length < size && httpd_req_get_hdr_value_str(r, name, out, size) == ESP_OK;
|
||||
}
|
||||
|
||||
static bool origin(httpd_req_t *r, bool required, char canonical[129])
|
||||
{
|
||||
char host[129] = {0}, supplied[137] = {0}, site[16] = {0};
|
||||
if (!header(r, "Host", host, sizeof(host))) return false;
|
||||
if (header(r, "Sec-Fetch-Site", site, sizeof(site))) {
|
||||
if (strcmp(site, "same-origin") && strcmp(site, "none")) return false;
|
||||
} else if (httpd_req_get_hdr_value_len(r, "Sec-Fetch-Site")) return false;
|
||||
if (!header(r, "Origin", supplied, sizeof(supplied))) {
|
||||
if (required || httpd_req_get_hdr_value_len(r, "Origin")) return false;
|
||||
int length = snprintf(supplied, sizeof(supplied), "https://%s", host);
|
||||
if (length < 0 || (size_t)length >= sizeof(supplied)) return false;
|
||||
}
|
||||
return web_auth_parse_origin(host, strlen(host), supplied, strlen(supplied), canonical);
|
||||
}
|
||||
|
||||
static bool cookie(httpd_req_t *r, const char *name, char token[65])
|
||||
{
|
||||
char cookies[1025] = {0};
|
||||
bool valid = header(r, "Cookie", cookies, sizeof(cookies)) &&
|
||||
web_auth_parse_cookie(cookies, strlen(cookies), name, token);
|
||||
secure_wipe(cookies, sizeof(cookies));
|
||||
return valid;
|
||||
}
|
||||
|
||||
static bool cookies_valid(httpd_req_t *r)
|
||||
{
|
||||
char cookies[1025] = {0}, token[65] = {0};
|
||||
bool present;
|
||||
esp_err_t error = httpd_req_get_hdr_value_str(r, "Cookie", cookies, sizeof(cookies));
|
||||
bool valid = error == ESP_ERR_NOT_FOUND ||
|
||||
(error == ESP_OK && httpd_req_get_hdr_value_len(r, "Cookie") < sizeof(cookies) &&
|
||||
web_auth_parse_optional_cookie(cookies, strlen(cookies), SESSION_COOKIE, token, &present) &&
|
||||
web_auth_parse_optional_cookie(cookies, strlen(cookies), PRELOGIN_COOKIE, token, &present));
|
||||
secure_wipe(cookies, sizeof(cookies));
|
||||
secure_wipe(token, sizeof(token));
|
||||
return valid;
|
||||
}
|
||||
|
||||
static esp_err_t response(httpd_req_t *r, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(r, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(r, "application/json; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Frame-Options", "DENY");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(r, body);
|
||||
/* Never let HTTPD drain an attacker-controlled rejected request body. */
|
||||
return web_httpd_unread_body(r) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
static esp_err_t failure(httpd_req_t *r, const char *status, const char *code)
|
||||
{
|
||||
bool security = !strcmp(status, "403 Forbidden");
|
||||
bool credentials = !strcmp(code, "invalid_credentials");
|
||||
bool throttled = !strcmp(code, "throttled");
|
||||
bool full = !strcmp(code, "capacity");
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_counts.security_rejections += security;
|
||||
s_counts.login_failures += credentials;
|
||||
s_counts.throttled += throttled;
|
||||
s_counts.capacity_rejections += full;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
char body[80];
|
||||
snprintf(body, sizeof(body), "{\"error\":\"%s\"}", code);
|
||||
return response(r, status, body);
|
||||
}
|
||||
|
||||
static esp_err_t capacity(httpd_req_t *r)
|
||||
{
|
||||
if (httpd_resp_set_hdr(r, "Retry-After", "5") != ESP_OK) return ESP_FAIL;
|
||||
return failure(r, "503 Service Unavailable", "capacity");
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_start(void)
|
||||
{
|
||||
esp_err_t error = web_session_store_init();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (error == ESP_OK && s_epoch != UINT64_MAX) {
|
||||
++s_epoch;
|
||||
secure_wipe(s_challenges, sizeof(s_challenges));
|
||||
s_window = 0;
|
||||
s_attempts = 0;
|
||||
s_ready = true;
|
||||
} else {
|
||||
s_ready = false;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_cookie_auth_stop(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_ready = false;
|
||||
if (s_epoch != UINT64_MAX) ++s_epoch;
|
||||
secure_wipe(s_challenges, sizeof(s_challenges));
|
||||
s_window = 0;
|
||||
s_attempts = 0;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
web_session_store_stop();
|
||||
}
|
||||
|
||||
void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot) return;
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
*snapshot = s_counts;
|
||||
snapshot->ready = s_ready;
|
||||
snapshot->active_challenges = 0;
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
if (s_challenges[i].expiry <= now) secure_wipe(&s_challenges[i], sizeof(s_challenges[i]));
|
||||
else ++snapshot->active_challenges;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_cookie_auth_clear_counters(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
memset(&s_counts, 0, sizeof(s_counts));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
|
||||
web_session_view_t *view, bool *allowed)
|
||||
{
|
||||
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
|
||||
*allowed = false;
|
||||
memset(view, 0, sizeof(*view));
|
||||
if (!web_httpd_headers_valid(r) || !cookies_valid(r) || (!upgrade && strchr(r->uri, '?')) ||
|
||||
r->content_len || r->method != (mutation ? HTTP_POST : HTTP_GET))
|
||||
return failure(r, "400 Bad Request", "invalid_request");
|
||||
if (!origin(r, mutation || upgrade, canonical))
|
||||
return failure(r, "403 Forbidden", "origin");
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) return failure(r, "503 Service Unavailable", "unavailable");
|
||||
esp_err_t error = ESP_ERR_NOT_FOUND;
|
||||
if (cookie(r, SESSION_COOKIE, token))
|
||||
error = web_session_store_lookup(token, strlen(token), canonical, strlen(canonical), view);
|
||||
secure_wipe(token, sizeof(token));
|
||||
if (error != ESP_OK) {
|
||||
if (error != ESP_ERR_NOT_FOUND)
|
||||
return failure(r, "503 Service Unavailable", "unavailable");
|
||||
if (!strcmp(r->uri, "/")) {
|
||||
if (httpd_resp_set_hdr(r, "Location", "/login") != ESP_OK) return ESP_FAIL;
|
||||
return response(r, "303 See Other", "");
|
||||
}
|
||||
return failure(r, "401 Unauthorized", "authentication_required");
|
||||
}
|
||||
if (mutation && (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) ||
|
||||
strlen(csrf) != 64U || !equal(csrf, view->csrf, 64U))) {
|
||||
secure_wipe(csrf, sizeof(csrf));
|
||||
secure_wipe(view, sizeof(*view));
|
||||
return failure(r, "403 Forbidden", "csrf");
|
||||
}
|
||||
secure_wipe(csrf, sizeof(csrf));
|
||||
*allowed = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static bool secret(char out[65])
|
||||
{
|
||||
uint8_t bytes[32];
|
||||
bool ok = secure_random_fill(bytes, sizeof(bytes)) == ESP_OK;
|
||||
if (ok) {
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < sizeof(bytes); ++i) {
|
||||
out[2*i] = hex[bytes[i] >> 4];
|
||||
out[2*i+1] = hex[bytes[i] & 15];
|
||||
}
|
||||
out[64] = 0;
|
||||
}
|
||||
secure_wipe(bytes, sizeof(bytes));
|
||||
return ok;
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_handler(httpd_req_t *r)
|
||||
{
|
||||
bool login = !strcmp(r->uri, "/api/login");
|
||||
bool bootstrap = !strcmp(r->uri, "/api/login-challenge");
|
||||
bool logout = !strcmp(r->uri, "/api/logout");
|
||||
bool document = !strcmp(r->uri, "/login");
|
||||
web_session_view_t view = {0};
|
||||
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
|
||||
char set_cookie[180] = {0}, body[513] = {0};
|
||||
web_auth_credentials_t credentials = {0};
|
||||
challenge_t candidate = {0};
|
||||
uint8_t digest[32] = {0}, origin_digest[32] = {0};
|
||||
esp_err_t result = ESP_FAIL;
|
||||
const char *status = "400 Bad Request", *code = "invalid_request";
|
||||
bool consumed = false, allowed = false;
|
||||
bool challenge_published = false;
|
||||
uint64_t epoch;
|
||||
int selected = -1;
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_ready;
|
||||
epoch = s_epoch;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
|
||||
if (!web_httpd_headers_valid(r) || !cookies_valid(r) || strchr(r->uri, '?') ||
|
||||
r->method != ((login || logout) ? HTTP_POST : HTTP_GET) ||
|
||||
(!login && r->content_len)) goto deny;
|
||||
if (document) { result = web_login_ui_send_response(r); goto cleanup; }
|
||||
if (!login && !bootstrap) {
|
||||
result = web_cookie_auth_require(r, logout, false, &view, &allowed);
|
||||
if (!allowed) goto cleanup;
|
||||
if (logout) {
|
||||
web_serial_transport_revoke_web_session(view.id);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
++s_counts.logouts;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
result = httpd_resp_set_hdr(r, "Set-Cookie", SESSION_COOKIE "=" COOKIE_FLAGS "0");
|
||||
if (result != ESP_OK) goto cleanup;
|
||||
result = response(r, "204 No Content", "");
|
||||
} else {
|
||||
/* Database usernames are restricted ASCII; encode nevertheless. */
|
||||
char username[97] = {0};
|
||||
size_t used = 0;
|
||||
for (size_t i = 0; i < view.principal.username_length && i < 16; ++i)
|
||||
used += (size_t)snprintf(username + used, sizeof(username) - used,
|
||||
"\\u%04x", (unsigned char)view.principal.username[i]);
|
||||
int64_t remaining = (view.expires_at_us - esp_timer_get_time()) / 1000000LL;
|
||||
snprintf(body, sizeof(body), "{\"username\":\"%s\",\"role\":\"%s\",\"csrf\":\"%s\",\"expires_in\":%lld}",
|
||||
username, view.principal.role == USER_ROLE_ADMIN ? "admin" : "user", view.csrf,
|
||||
(long long)(remaining > 0 ? remaining : 0));
|
||||
result = response(r, "200 OK", body);
|
||||
}
|
||||
goto cleanup;
|
||||
}
|
||||
if (!origin(r, login, canonical)) { status = "403 Forbidden"; code = "origin"; goto deny; }
|
||||
if (mbedtls_sha256((const uint8_t *)canonical, strlen(canonical), origin_digest, 0)) goto deny;
|
||||
if (bootstrap) {
|
||||
char flag[2];
|
||||
if (!header(r, "X-Login-Bootstrap", flag, sizeof(flag)) || strcmp(flag, "1")) {
|
||||
status = "403 Forbidden"; code = "csrf"; goto deny;
|
||||
}
|
||||
} else {
|
||||
char type[40];
|
||||
if (!header(r, "Content-Type", type, sizeof(type)) ||
|
||||
(strcmp(type, "application/json") && strcmp(type, "application/json; charset=utf-8"))) {
|
||||
status = "415 Unsupported Media Type"; code = "content_type"; goto deny;
|
||||
}
|
||||
if (!r->content_len || r->content_len > 512U) {
|
||||
status = "413 Payload Too Large"; code = "body_size"; goto deny;
|
||||
}
|
||||
if (cookie(r, SESSION_COOKIE, token) &&
|
||||
web_session_store_lookup(token, 64, canonical, strlen(canonical), &view) == ESP_OK) {
|
||||
status = "409 Conflict"; code = "already_authenticated"; goto deny;
|
||||
}
|
||||
if (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) || strlen(csrf) != 64) {
|
||||
status = "403 Forbidden"; code = "csrf"; goto deny;
|
||||
}
|
||||
}
|
||||
bool has_cookie = cookie(r, PRELOGIN_COOKIE, token);
|
||||
if (has_cookie && mbedtls_sha256((const uint8_t *)token, 64, digest, 0)) goto deny;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
challenge_t *entry = &s_challenges[i];
|
||||
if (entry->expiry <= now) secure_wipe(entry, sizeof(*entry));
|
||||
if (s_ready && epoch == s_epoch && entry->expiry && has_cookie &&
|
||||
equal(entry->token_digest, digest, 32) && equal(entry->origin_digest, origin_digest, 32)) {
|
||||
if (bootstrap || equal(entry->csrf, csrf, 64)) {
|
||||
candidate = *entry;
|
||||
selected = i;
|
||||
if (login) { secure_wipe(entry, sizeof(*entry)); consumed = true; }
|
||||
}
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (bootstrap) {
|
||||
bool fresh = selected < 0;
|
||||
if (fresh) {
|
||||
if (!secret(token) || !secret(candidate.csrf) ||
|
||||
mbedtls_sha256((const uint8_t *)token, 64, candidate.token_digest, 0)) {
|
||||
status = "503 Service Unavailable"; code = "unavailable"; goto deny;
|
||||
}
|
||||
memcpy(candidate.origin_digest, origin_digest, 32);
|
||||
candidate.expiry = now + CHALLENGE_US;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_ready && epoch == s_epoch) for (int i = 0; i < 4; ++i) {
|
||||
if (!s_challenges[i].expiry) {
|
||||
s_challenges[i] = candidate; selected = i; challenge_published = true; break;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (selected < 0) { result = capacity(r); goto cleanup; }
|
||||
snprintf(set_cookie, sizeof(set_cookie), PRELOGIN_COOKIE "=%s" COOKIE_FLAGS "120", token);
|
||||
if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) goto cleanup;
|
||||
}
|
||||
snprintf(body, sizeof(body), "{\"csrf\":\"%s\",\"expires_in\":%lld}", candidate.csrf,
|
||||
(long long)((candidate.expiry - now) / 1000000LL));
|
||||
result = response(r, "200 OK", body);
|
||||
goto cleanup;
|
||||
}
|
||||
if (!consumed) { status = "403 Forbidden"; code = "challenge_expired"; goto deny; }
|
||||
if (httpd_resp_set_hdr(r, "Set-Cookie", PRELOGIN_COOKIE "=" COOKIE_FLAGS "0") != ESP_OK) goto cleanup;
|
||||
size_t received = 0;
|
||||
int64_t deadline = now + 3000000LL;
|
||||
while (received < r->content_len && esp_timer_get_time() < deadline) {
|
||||
int count = httpd_req_recv(r, body + received, r->content_len - received);
|
||||
if (count <= 0) goto deny;
|
||||
received += (size_t)count;
|
||||
}
|
||||
if (received != r->content_len || !web_auth_parse_login(body, received, &credentials)) goto deny;
|
||||
now = esp_timer_get_time();
|
||||
unsigned attempts;
|
||||
int64_t retry;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
ready = s_ready && epoch == s_epoch;
|
||||
if (now - s_window >= WINDOW_US) { s_window = now; s_attempts = 0; }
|
||||
attempts = s_attempts;
|
||||
if (ready && attempts < 5) { ++s_attempts; ++s_counts.login_attempts; }
|
||||
retry = (s_window + WINDOW_US - now + 999999LL) / 1000000LL;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
|
||||
if (attempts >= 5) {
|
||||
char seconds[16];
|
||||
snprintf(seconds, sizeof(seconds), "%lld", (long long)retry);
|
||||
if (httpd_resp_set_hdr(r, "Retry-After", seconds) != ESP_OK) goto cleanup;
|
||||
result = failure(r, "429 Too Many Requests", "throttled");
|
||||
goto cleanup;
|
||||
}
|
||||
bool authenticated = false;
|
||||
user_principal_t principal = {0};
|
||||
esp_err_t error = user_database_authenticate_password(credentials.username, credentials.username_length,
|
||||
credentials.password, credentials.password_length, &principal, &authenticated);
|
||||
secure_wipe(body, sizeof(body));
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
if (error == ESP_OK && authenticated)
|
||||
error = web_session_store_issue(&principal, canonical, strlen(canonical), token, &view);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
if (error == ESP_ERR_NO_MEM) { result = capacity(r); goto cleanup; }
|
||||
if (error != ESP_OK) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
|
||||
if (!authenticated) { status = "401 Unauthorized"; code = "invalid_credentials"; goto deny; }
|
||||
snprintf(set_cookie, sizeof(set_cookie), SESSION_COOKIE "=%s" COOKIE_FLAGS "3600", token);
|
||||
if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) {
|
||||
web_session_store_invalidate(view.id); goto cleanup;
|
||||
}
|
||||
result = response(r, "200 OK", "{\"authenticated\":true}");
|
||||
if (result != ESP_OK) web_session_store_invalidate(view.id);
|
||||
goto cleanup;
|
||||
deny:
|
||||
result = failure(r, status, code);
|
||||
cleanup:
|
||||
if (challenge_published && result != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (epoch == s_epoch && selected >= 0 &&
|
||||
equal(s_challenges[selected].token_digest, candidate.token_digest, 32))
|
||||
secure_wipe(&s_challenges[selected], sizeof(s_challenges[selected]));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
web_httpd_wipe_request(r, web_httpd_unread_body(r));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
secure_wipe(token, sizeof(token));
|
||||
secure_wipe(csrf, sizeof(csrf));
|
||||
secure_wipe(set_cookie, sizeof(set_cookie));
|
||||
secure_wipe(body, sizeof(body));
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
secure_wipe(digest, sizeof(digest));
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include "esp_http_server.h"
|
||||
#include "web_session_store.h"
|
||||
|
||||
esp_err_t web_cookie_auth_start(void);
|
||||
void web_cookie_auth_stop(void);
|
||||
typedef struct {
|
||||
uint32_t login_attempts, login_failures, throttled, capacity_rejections;
|
||||
uint32_t security_rejections, logouts, active_challenges;
|
||||
bool ready;
|
||||
} web_cookie_auth_snapshot_t;
|
||||
void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot);
|
||||
void web_cookie_auth_clear_counters(void);
|
||||
/* Sends an error on denial, with allowed=false. View is caller-wiped. */
|
||||
esp_err_t web_cookie_auth_require(httpd_req_t *request, bool mutation,
|
||||
bool upgrade, web_session_view_t *view,
|
||||
bool *allowed);
|
||||
esp_err_t web_cookie_auth_handler(httpd_req_t *request);
|
||||
@@ -0,0 +1,106 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Deliberately isolated dependency on the installed IDF HTTPD layout. */
|
||||
#include "web_httpd_adapter.h"
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include "esp_idf_version.h"
|
||||
#include "esp_httpd_priv.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
#if ESP_IDF_VERSION != ESP_IDF_VERSION_VAL(5, 5, 0)
|
||||
#error "Reaudit HTTPD header storage and pre-handler upgrade behavior for this IDF"
|
||||
#endif
|
||||
|
||||
bool web_httpd_headers_valid(httpd_req_t *request)
|
||||
{
|
||||
if (!request || !request->aux) return false;
|
||||
const struct httpd_req_aux *aux = request->aux;
|
||||
const char *start = aux->scratch;
|
||||
if (!start || aux->scratch_cur_size > 1024U) return false;
|
||||
const char *end = start + aux->scratch_cur_size;
|
||||
const char *line = start;
|
||||
for (unsigned i = 0; i < aux->req_hdrs_count; ++i) {
|
||||
if (line >= end) return false;
|
||||
while (line < end && !*line) ++line;
|
||||
const char *stop = memchr(line, 0, (size_t)(end - line));
|
||||
if (!stop) return false;
|
||||
const char *colon = memchr(line, ':', (size_t)(stop - line));
|
||||
if (!colon || colon == line) return false;
|
||||
size_t length = (size_t)(colon - line);
|
||||
for (const char *p = line; p < colon; ++p) {
|
||||
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') ||
|
||||
(*p >= '0' && *p <= '9') || strchr("!#$%&'*+-.^_`|~", *p))) return false;
|
||||
}
|
||||
for (const char *p = colon + 1; p < stop; ++p) {
|
||||
if ((unsigned char)*p < 32U || (unsigned char)*p == 127U) return false;
|
||||
}
|
||||
/* Reject transfer coding and Expect rather than draining an unbounded
|
||||
* body after an authentication failure. No application route uses them. */
|
||||
if ((length == 17U && !strncasecmp(line, "Transfer-Encoding", length)) ||
|
||||
(length == 6U && !strncasecmp(line, "Expect", length))) return false;
|
||||
const char *previous = start;
|
||||
for (unsigned j = 0; j < i; ++j) {
|
||||
while (previous < line && !*previous) ++previous;
|
||||
const char *previous_end = memchr(previous, 0, (size_t)(line - previous));
|
||||
if (!previous_end) return false;
|
||||
const char *previous_colon = memchr(previous, ':', (size_t)(previous_end - previous));
|
||||
if (!previous_colon) return false;
|
||||
if ((size_t)(previous_colon - previous) == length &&
|
||||
!strncasecmp(previous, line, length)) return false;
|
||||
previous = previous_end + 1;
|
||||
}
|
||||
line = stop + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_httpd_upgrade_requested(httpd_req_t *request)
|
||||
{
|
||||
const struct httpd_req_aux *aux = request->aux;
|
||||
if (!aux || !aux->sd || !aux->ws_handshake_detect || aux->sd->ws_handshake_done)
|
||||
return false;
|
||||
char version[3], key[25];
|
||||
if (httpd_req_get_hdr_value_len(request, "Sec-WebSocket-Version") != 2U ||
|
||||
httpd_req_get_hdr_value_str(request, "Sec-WebSocket-Version", version, sizeof(version)) != ESP_OK ||
|
||||
strcmp(version, "13") || httpd_req_get_hdr_value_len(request, "Sec-WebSocket-Key") != 24U ||
|
||||
httpd_req_get_hdr_value_str(request, "Sec-WebSocket-Key", key, sizeof(key)) != ESP_OK ||
|
||||
key[22] != '=' || key[23] != '=' || !strchr("AQgw", key[21])) return false;
|
||||
for (unsigned i = 0; i < 21; ++i)
|
||||
if (!strchr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", key[i])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_upgrade(httpd_req_t *request,
|
||||
esp_err_t (*handler)(httpd_req_t *))
|
||||
{
|
||||
if (!web_httpd_upgrade_requested(request)) return ESP_ERR_INVALID_STATE;
|
||||
esp_err_t error = httpd_ws_respond_server_handshake(request, NULL);
|
||||
if (error == ESP_OK) {
|
||||
struct httpd_req_aux *aux = request->aux;
|
||||
aux->sd->ws_handshake_done = true;
|
||||
aux->sd->ws_handler = handler;
|
||||
aux->sd->ws_control_frames = false;
|
||||
aux->sd->ws_user_ctx = NULL;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_httpd_wipe_request(httpd_req_t *request, bool closing)
|
||||
{
|
||||
struct httpd_req_aux *aux = request->aux;
|
||||
if (!aux) return;
|
||||
if (aux->scratch) secure_wipe(aux->scratch, aux->scratch_cur_size);
|
||||
aux->req_hdrs_count = 0;
|
||||
if (aux->sd) {
|
||||
size_t keep = closing ? 0 : aux->sd->pending_len;
|
||||
/* httpd_unrecv()/httpd_recv_pending() right-align unread bytes. */
|
||||
if (keep <= sizeof(aux->sd->pending_data))
|
||||
secure_wipe(aux->sd->pending_data, sizeof(aux->sd->pending_data) - keep);
|
||||
}
|
||||
}
|
||||
|
||||
bool web_httpd_unread_body(httpd_req_t *request)
|
||||
{
|
||||
const struct httpd_req_aux *aux = request->aux;
|
||||
return aux && aux->remaining_len != 0;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/* HTTPD-owner only, before body reads or any response. Reject duplicate lines,
|
||||
* including Cookie, rather than trusting first-match public getters. */
|
||||
bool web_httpd_headers_valid(httpd_req_t *request);
|
||||
bool web_httpd_upgrade_requested(httpd_req_t *request);
|
||||
bool web_httpd_unread_body(httpd_req_t *request);
|
||||
/* After the final response/lookup: preserve only unread pipelined data on a
|
||||
* keepalive connection. Closing requests may discard pending data entirely. */
|
||||
void web_httpd_wipe_request(httpd_req_t *request, bool closing);
|
||||
esp_err_t web_httpd_upgrade(httpd_req_t *request,
|
||||
esp_err_t (*handler)(httpd_req_t *));
|
||||
@@ -0,0 +1,161 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_login_ui.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* Authored standalone page; no dependency on protected or generated assets. */
|
||||
static const char s_login_html[] =
|
||||
"<!doctype html>\n<html lang=\"en\">\n<head>\n"
|
||||
"<meta charset=\"utf-8\">\n"
|
||||
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
|
||||
"<title>Sign in - ESP32 Serial Console</title>\n"
|
||||
"<style>\n"
|
||||
":root{color-scheme:dark;font:16px/1.5 system-ui,sans-serif;background:#090d14;color:#e8eef8}\n"
|
||||
"*{box-sizing:border-box}body{margin:0;padding:2rem 1rem}main{max-width:28rem;margin:3vh auto;"
|
||||
"padding:1.5rem;background:#111824;border:1px solid #29364a;border-radius:14px}\n"
|
||||
"h1{font-size:1.5rem}label{display:block;margin-top:1rem}input,button{font:inherit;"
|
||||
"width:100%;padding:.65rem;border:1px solid #91a0b5;border-radius:6px}"
|
||||
"input{background:#090d14;color:inherit}button{margin-top:1.25rem;background:#55c2ff;"
|
||||
"color:#090d14;cursor:pointer}button:disabled{opacity:.6;cursor:wait}"
|
||||
"a{color:#55c2ff}:focus-visible{outline:3px solid #ffc857;outline-offset:3px}"
|
||||
"#message{min-height:3em}small{display:block;color:#b6c2d4}\n"
|
||||
"</style>\n</head>\n<body>\n<main>\n"
|
||||
"<h1>ESP32 Serial Console</h1>\n"
|
||||
"<p>Sign in to access the serial terminal.</p>\n"
|
||||
"<form id=\"login\" method=\"post\" action=\"/api/login\">\n"
|
||||
"<label for=\"username\">Username</label>\n"
|
||||
"<input id=\"username\" name=\"username\" autocomplete=\"username\" "
|
||||
"autocapitalize=\"none\" spellcheck=\"false\" maxlength=\"16\" required>\n"
|
||||
"<label for=\"password\">Password</label>\n"
|
||||
"<input id=\"password\" name=\"password\" type=\"password\" "
|
||||
"autocomplete=\"current-password\" maxlength=\"64\" required>\n"
|
||||
"<button id=\"submit\" type=\"submit\" disabled>Sign in</button>\n"
|
||||
"</form>\n"
|
||||
"<p id=\"message\" role=\"status\" aria-live=\"polite\">Ready to sign in.</p>\n"
|
||||
"<noscript><p>JavaScript is required to sign in securely.</p></noscript>\n"
|
||||
"<p><a href=\"/\">Return to console</a></p>\n"
|
||||
"<small>Sessions expire after one hour, including active serial connections. "
|
||||
"To switch accounts, return to the console and sign out first.</small>\n"
|
||||
"</main>\n<script>\n"
|
||||
"(() => {\n"
|
||||
" 'use strict';\n"
|
||||
" const form = document.getElementById('login');\n"
|
||||
" const username = document.getElementById('username');\n"
|
||||
" const password = document.getElementById('password');\n"
|
||||
" const submit = document.getElementById('submit');\n"
|
||||
" const message = document.getElementById('message');\n"
|
||||
" const encoder = new TextEncoder();\n"
|
||||
" let busy = false, generation = 0, controller = null, retryAt = 0;\n"
|
||||
" function reset() {\n"
|
||||
" ++generation;\n"
|
||||
" if (controller) controller.abort();\n"
|
||||
" controller = null; busy = false; password.value = '';\n"
|
||||
" submit.disabled = false; username.disabled = false; password.disabled = false; form.setAttribute('aria-busy', 'false');\n"
|
||||
" }\n"
|
||||
" window.addEventListener('pagehide', reset);\n"
|
||||
" window.addEventListener('pageshow', event => {\n"
|
||||
" if (event.persisted) { reset(); message.textContent = 'Ready to sign in.'; }\n"
|
||||
" });\n"
|
||||
" async function readJSON(response) {\n"
|
||||
" if (!response.body) throw new Error('response');\n"
|
||||
" const reader = response.body.getReader();\n"
|
||||
" const bytes = new Uint8Array(512);\n"
|
||||
" let length = 0;\n"
|
||||
" try {\n"
|
||||
" for (;;) {\n"
|
||||
" const part = await reader.read();\n"
|
||||
" if (part.done) break;\n"
|
||||
" if (part.value.length > bytes.length - length) throw new Error('response');\n"
|
||||
" bytes.set(part.value, length); length += part.value.length;\n"
|
||||
" }\n"
|
||||
" return JSON.parse(new TextDecoder('utf-8', {fatal:true}).decode(bytes.subarray(0, length)));\n"
|
||||
" } finally { await reader.cancel(); reader.releaseLock(); }\n"
|
||||
" }\n"
|
||||
" function report(response, stage) {\n"
|
||||
" if (response.status === 429 || response.status === 503) {\n"
|
||||
" const raw = response.headers.get('Retry-After') || '';\n"
|
||||
" const seconds = /^[0-9]{1,3}$/.test(raw) ? Math.max(1, Math.min(120, Number(raw))) : 5;\n"
|
||||
" retryAt = Date.now() + seconds * 1000;\n"
|
||||
" message.textContent = (response.status === 429 ? 'Too many sign-in attempts.' : 'Sign-in capacity is busy.') +\n"
|
||||
" ' Wait ' + seconds + ' seconds, then try again.';\n"
|
||||
" } else if (response.status === 401 && stage === 'login') {\n"
|
||||
" message.textContent = 'Username or password is incorrect. Please try again.';\n"
|
||||
" } else if (response.status === 403) {\n"
|
||||
" message.textContent = 'The sign-in challenge expired or the request was rejected. Please try again.';\n"
|
||||
" } else if (response.status === 409) {\n"
|
||||
" message.textContent = 'Already signed in. Return to the console; sign out there to switch accounts.';\n"
|
||||
" } else if ([400, 413, 415].includes(response.status)) {\n"
|
||||
" message.textContent = 'The sign-in request was not accepted. Check your input and try again.';\n"
|
||||
" } else {\n"
|
||||
" message.textContent = 'The device could not complete sign-in. Please try again.';\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" form.addEventListener('submit', async event => {\n"
|
||||
" event.preventDefault();\n"
|
||||
" if (busy) { password.value = ''; return; }\n"
|
||||
" if (Date.now() < retryAt) {\n"
|
||||
" password.value = '';\n"
|
||||
" message.textContent = 'Please wait ' + Math.ceil((retryAt - Date.now()) / 1000) + ' seconds before retrying.';\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" let body = JSON.stringify({username:username.value, password:password.value});\n"
|
||||
" const valid = username.value.length && password.value.length &&\n"
|
||||
" encoder.encode(username.value).length <= 16 && encoder.encode(password.value).length <= 64 &&\n"
|
||||
" !username.value.includes('\\0') && !password.value.includes('\\0') && encoder.encode(body).length <= 512;\n"
|
||||
" password.value = '';\n"
|
||||
" if (!valid) { body = ''; message.textContent = 'Enter a username (up to 16 UTF-8 bytes) and password (up to 64 UTF-8 bytes).'; return; }\n"
|
||||
" busy = true; submit.disabled = true; username.disabled = true; password.disabled = true; form.setAttribute('aria-busy', 'true');\n"
|
||||
" message.textContent = 'Signing in...';\n"
|
||||
" const current = ++generation;\n"
|
||||
" const abort = new AbortController(); controller = abort;\n"
|
||||
" const timeout = setTimeout(() => abort.abort(), 15000);\n"
|
||||
" let csrf = '';\n"
|
||||
/* CORS mode preserves Origin under no-referrer; CSP still limits connections to self. */
|
||||
" const options = {credentials:'same-origin', mode:'cors', cache:'no-store', redirect:'error', signal:abort.signal};\n"
|
||||
" try {\n"
|
||||
" const challenge = await fetch('/api/login-challenge', {...options, headers:{'X-Login-Bootstrap':'1'}});\n"
|
||||
" if (current !== generation) return;\n"
|
||||
" if (challenge.status !== 200) { report(challenge, 'challenge'); return; }\n"
|
||||
" const data = await readJSON(challenge);\n"
|
||||
" if (current !== generation) return;\n"
|
||||
" if (!data || typeof data.csrf !== 'string' || !/^[0-9a-f]{64}$/.test(data.csrf) ||\n"
|
||||
" !Number.isInteger(data.expires_in) || data.expires_in < 1 || data.expires_in > 120) throw new Error('challenge');\n"
|
||||
" csrf = data.csrf;\n"
|
||||
" const response = await fetch('/api/login', {...options, method:'POST',\n"
|
||||
" headers:{'Content-Type':'application/json', 'X-CSRF-Token':csrf}, body});\n"
|
||||
" body = ''; csrf = '';\n"
|
||||
" if (current !== generation) return;\n"
|
||||
" if (response.status !== 200) { report(response, 'login'); return; }\n"
|
||||
" const result = await readJSON(response);\n"
|
||||
" if (current !== generation) return;\n"
|
||||
" if (!result || result.authenticated !== true) throw new Error('login');\n"
|
||||
" window.location.replace('/');\n"
|
||||
" } catch (_) {\n"
|
||||
" if (current === generation) message.textContent = 'Could not confirm sign-in. Check the connection, then return to the console or try again.';\n"
|
||||
" } finally {\n"
|
||||
" abort.abort(); clearTimeout(timeout); body = ''; csrf = '';\n"
|
||||
" if (current === generation) {\n"
|
||||
" controller = null; busy = false; password.value = '';\n"
|
||||
" submit.disabled = false; username.disabled = false; password.disabled = false; form.setAttribute('aria-busy', 'false');\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" });\n"
|
||||
" submit.disabled = false;\n"
|
||||
"})();\n"
|
||||
"</script>\n</body>\n</html>\n";
|
||||
|
||||
esp_err_t web_login_ui_send_response(httpd_req_t *request)
|
||||
{
|
||||
if (request == NULL) return ESP_ERR_INVALID_ARG;
|
||||
esp_err_t error = httpd_resp_set_type(request, "text/html; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Frame-Options", "DENY");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Content-Security-Policy",
|
||||
"default-src 'none'; script-src 'sha256-eZO4pMDQx6SIaa5AFlMnuf0CD5JdGSWyi8lNVmCNPBQ='; "
|
||||
"style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; "
|
||||
"form-action 'none'; frame-ancestors 'none'");
|
||||
if (error == ESP_OK) error = httpd_resp_send(request, s_login_html, sizeof(s_login_html) - 1U);
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/* Standalone public login document. Rendering only: authentication, route
|
||||
* registration and bounded challenge allocation belong to web_cookie_auth. */
|
||||
esp_err_t web_login_ui_send_response(httpd_req_t *request);
|
||||
+144
-31
@@ -14,6 +14,9 @@
|
||||
#include "sdkconfig.h"
|
||||
#include "secure_random.h"
|
||||
#include "serial_service.h"
|
||||
#include "web_auth_parse.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "web_admin_transport.h"
|
||||
|
||||
#if !defined(CONFIG_HTTPD_WS_SUPPORT) || !CONFIG_HTTPD_WS_SUPPORT
|
||||
#error "web_serial_transport requires CONFIG_HTTPD_WS_SUPPORT"
|
||||
@@ -49,6 +52,7 @@ typedef struct {
|
||||
uint8_t digest[WEB_SERIAL_SHA256_BYTES];
|
||||
int64_t expires_at_us;
|
||||
user_principal_t principal;
|
||||
web_session_id_t web_session_id;
|
||||
bool active;
|
||||
} web_serial_ticket_t;
|
||||
|
||||
@@ -68,6 +72,7 @@ typedef struct web_serial_slot {
|
||||
uint32_t generation;
|
||||
session_broker_client_id_t broker_client_id;
|
||||
user_principal_t principal;
|
||||
web_session_id_t web_session_id;
|
||||
int64_t next_currentness_check_us;
|
||||
bool writer;
|
||||
bool hello_pending;
|
||||
@@ -95,6 +100,15 @@ static web_serial_slot_t s_slots[WEB_SERIAL_TRANSPORT_MAX_SESSIONS];
|
||||
static web_serial_transport_counters_t s_counters;
|
||||
static uint32_t s_httpd_close_operations;
|
||||
static uint32_t s_inflight_handlers;
|
||||
/* Cancels ticket publication across revocation and server detach/re-attach.
|
||||
* Never wraps: exhaustion disables minting for the remainder of the boot. */
|
||||
static uint64_t s_ticket_epoch;
|
||||
|
||||
static esp_err_t identity_is_current(const user_principal_t *principal,
|
||||
web_session_id_t id, bool *current)
|
||||
{
|
||||
return web_session_store_check_principal(id, principal, current);
|
||||
}
|
||||
|
||||
static TickType_t milliseconds_to_ticks(uint32_t milliseconds)
|
||||
{
|
||||
@@ -146,6 +160,7 @@ static void clear_ticket_locked(web_serial_ticket_t *ticket)
|
||||
secure_wipe(ticket->digest, sizeof(ticket->digest));
|
||||
secure_wipe(&ticket->principal, sizeof(ticket->principal));
|
||||
ticket->expires_at_us = 0;
|
||||
ticket->web_session_id = 0U;
|
||||
ticket->active = false;
|
||||
}
|
||||
|
||||
@@ -257,9 +272,6 @@ static esp_err_t validate_origin(httpd_req_t *request)
|
||||
|
||||
esp_err_t result = httpd_req_get_hdr_value_str(
|
||||
request, "Origin", origin, sizeof(origin));
|
||||
if (result == ESP_ERR_NOT_FOUND) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (result != ESP_OK) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
@@ -271,9 +283,7 @@ static esp_err_t validate_origin(httpd_req_t *request)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
int written = snprintf(expected, sizeof(expected), "https://%s", host);
|
||||
bool matches = written > 0 && (size_t)written < sizeof(expected) &&
|
||||
strcmp(origin, expected) == 0;
|
||||
bool matches = web_auth_parse_origin(host, strlen(host), origin, strlen(origin), expected);
|
||||
secure_wipe(origin, sizeof(origin));
|
||||
secure_wipe(host, sizeof(host));
|
||||
secure_wipe(expected, sizeof(expected));
|
||||
@@ -281,6 +291,7 @@ static esp_err_t validate_origin(httpd_req_t *request)
|
||||
}
|
||||
|
||||
static esp_err_t consume_ticket(const char *ticket,
|
||||
web_session_id_t web_session_id,
|
||||
user_principal_t *principal, bool *consumed)
|
||||
{
|
||||
uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0};
|
||||
@@ -315,7 +326,8 @@ static esp_err_t consume_ticket(const char *ticket,
|
||||
if (matching_count == 1U &&
|
||||
matching_index < WEB_SERIAL_TRANSPORT_MAX_TICKETS) {
|
||||
web_serial_ticket_t *entry = &s_tickets[matching_index];
|
||||
if (entry->active && entry->expires_at_us > now_us) {
|
||||
if (entry->active && entry->expires_at_us > now_us &&
|
||||
entry->web_session_id == web_session_id) {
|
||||
candidate = entry->principal;
|
||||
clear_ticket_locked(entry);
|
||||
ticket_found = true;
|
||||
@@ -336,7 +348,7 @@ static esp_err_t consume_ticket(const char *ticket,
|
||||
|
||||
if (ticket_found) {
|
||||
bool current = false;
|
||||
result = user_database_principal_is_current(&candidate, ¤t);
|
||||
result = identity_is_current(&candidate, web_session_id, ¤t);
|
||||
if (result == ESP_OK && current) {
|
||||
*principal = candidate;
|
||||
*consumed = true;
|
||||
@@ -371,6 +383,7 @@ static web_serial_slot_t *reserve_slot(httpd_handle_t server, int socket_fd,
|
||||
slot->socket_fd = socket_fd;
|
||||
slot->broker_client_id = SESSION_BROKER_NO_CLIENT;
|
||||
secure_wipe(&slot->principal, sizeof(slot->principal));
|
||||
slot->web_session_id = 0U;
|
||||
slot->next_currentness_check_us = 0;
|
||||
slot->writer = false;
|
||||
slot->hello_pending = false;
|
||||
@@ -396,6 +409,7 @@ static void make_slot_free_locked(web_serial_slot_t *slot)
|
||||
slot->socket_fd = -1;
|
||||
slot->broker_client_id = SESSION_BROKER_NO_CLIENT;
|
||||
secure_wipe(&slot->principal, sizeof(slot->principal));
|
||||
slot->web_session_id = 0U;
|
||||
slot->next_currentness_check_us = 0;
|
||||
slot->writer = false;
|
||||
slot->hello_pending = false;
|
||||
@@ -512,7 +526,8 @@ static esp_err_t send_plain_bad_request(httpd_req_t *request)
|
||||
return result;
|
||||
}
|
||||
|
||||
static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
|
||||
static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd,
|
||||
web_session_id_t web_session_id)
|
||||
{
|
||||
char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY] = {0};
|
||||
uint32_t slot_generation = 0U;
|
||||
@@ -535,7 +550,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
result = consume_ticket(ticket, &principal, &consumed);
|
||||
result = consume_ticket(ticket, web_session_id, &principal, &consumed);
|
||||
if (result != ESP_OK || !consumed) {
|
||||
release_reserved_slot(slot, slot_generation);
|
||||
result = ESP_FAIL;
|
||||
@@ -547,6 +562,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
|
||||
!slot->close_requested;
|
||||
if (principal_staged) {
|
||||
slot->principal = principal;
|
||||
slot->web_session_id = web_session_id;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!principal_staged) {
|
||||
@@ -571,7 +587,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
|
||||
}
|
||||
|
||||
bool principal_current = false;
|
||||
result = user_database_principal_is_current(&principal, &principal_current);
|
||||
result = identity_is_current(&principal, web_session_id, &principal_current);
|
||||
if (result != ESP_OK || !principal_current) {
|
||||
release_reserved_slot(slot, slot_generation);
|
||||
result = ESP_FAIL;
|
||||
@@ -602,7 +618,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
|
||||
}
|
||||
|
||||
principal_current = false;
|
||||
result = user_database_principal_is_current(&principal, &principal_current);
|
||||
result = identity_is_current(&principal, web_session_id, &principal_current);
|
||||
if (result != ESP_OK || !principal_current) {
|
||||
close_unpublished_broker_session(slot, slot_generation, client_id);
|
||||
result = ESP_FAIL;
|
||||
@@ -625,7 +641,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
|
||||
}
|
||||
|
||||
principal_current = false;
|
||||
result = user_database_principal_is_current(&principal, &principal_current);
|
||||
result = identity_is_current(&principal, web_session_id, &principal_current);
|
||||
if (result != ESP_OK || !principal_current) {
|
||||
close_unpublished_broker_session(slot, slot_generation, client_id);
|
||||
result = ESP_FAIL;
|
||||
@@ -633,6 +649,12 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
|
||||
}
|
||||
|
||||
bool activated = false;
|
||||
/* Ticket and principal admission must succeed before HTTP 101. */
|
||||
result = web_httpd_upgrade(request, web_serial_transport_ws_handler);
|
||||
if (result != ESP_OK) {
|
||||
close_unpublished_broker_session(slot, slot_generation, client_id);
|
||||
goto cleanup;
|
||||
}
|
||||
int64_t next_currentness_check_us =
|
||||
monotonic_time_us() + WEB_SERIAL_CURRENTNESS_INTERVAL_US;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
@@ -672,7 +694,8 @@ cleanup:
|
||||
static bool capture_active_session(httpd_req_t *request, web_serial_slot_t **slot_out,
|
||||
uint32_t *generation,
|
||||
session_broker_client_id_t *client_id,
|
||||
user_principal_t *principal)
|
||||
user_principal_t *principal,
|
||||
web_session_id_t *web_session_id)
|
||||
{
|
||||
web_serial_slot_t *slot = request->sess_ctx;
|
||||
int socket_fd = httpd_req_to_sockfd(request);
|
||||
@@ -692,6 +715,7 @@ static bool capture_active_session(httpd_req_t *request, web_serial_slot_t **slo
|
||||
*generation = slot->generation;
|
||||
*client_id = slot->broker_client_id;
|
||||
*principal = slot->principal;
|
||||
*web_session_id = slot->web_session_id;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return valid;
|
||||
@@ -803,8 +827,9 @@ static esp_err_t process_websocket_frame(httpd_req_t *request)
|
||||
uint32_t generation = 0U;
|
||||
session_broker_client_id_t client_id = SESSION_BROKER_NO_CLIENT;
|
||||
user_principal_t principal = {0};
|
||||
web_session_id_t web_session_id = 0U;
|
||||
if (!capture_active_session(request, &slot, &generation, &client_id,
|
||||
&principal)) {
|
||||
&principal, &web_session_id)) {
|
||||
add_counter(&s_counters.protocol_errors, 1U);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
@@ -839,7 +864,7 @@ static esp_err_t process_websocket_frame(httpd_req_t *request)
|
||||
|
||||
bool current = false;
|
||||
esp_err_t currentness_result =
|
||||
user_database_principal_is_current(&principal, ¤t);
|
||||
identity_is_current(&principal, web_session_id, ¤t);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
if (currentness_result != ESP_OK || !current) {
|
||||
request_handler_close(slot, generation);
|
||||
@@ -1253,6 +1278,7 @@ static void process_broker_disconnect(web_serial_slot_t *slot)
|
||||
static void process_principal_currentness(web_serial_slot_t *slot)
|
||||
{
|
||||
user_principal_t principal = {0};
|
||||
web_session_id_t web_session_id = 0U;
|
||||
uint32_t generation = 0U;
|
||||
bool check = false;
|
||||
int64_t now_us = monotonic_time_us();
|
||||
@@ -1262,6 +1288,7 @@ static void process_principal_currentness(web_serial_slot_t *slot)
|
||||
slot->next_currentness_check_us <= now_us) {
|
||||
generation = slot->generation;
|
||||
principal = slot->principal;
|
||||
web_session_id = slot->web_session_id;
|
||||
slot->next_currentness_check_us =
|
||||
now_us + WEB_SERIAL_CURRENTNESS_INTERVAL_US;
|
||||
check = true;
|
||||
@@ -1272,7 +1299,7 @@ static void process_principal_currentness(web_serial_slot_t *slot)
|
||||
}
|
||||
|
||||
bool current = false;
|
||||
esp_err_t result = user_database_principal_is_current(&principal, ¤t);
|
||||
esp_err_t result = identity_is_current(&principal, web_session_id, ¤t);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
if (result == ESP_OK && current) {
|
||||
return;
|
||||
@@ -1417,6 +1444,9 @@ esp_err_t web_serial_transport_attach_server(httpd_handle_t server)
|
||||
} else {
|
||||
clear_all_tickets_locked();
|
||||
s_server = server;
|
||||
if (s_ticket_epoch != UINT64_MAX) {
|
||||
++s_ticket_epoch;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (result == ESP_OK) {
|
||||
@@ -1443,6 +1473,9 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server)
|
||||
* observes the cleared server and disconnects unpublished broker state.
|
||||
*/
|
||||
s_server = NULL;
|
||||
if (s_ticket_epoch != UINT64_MAX) {
|
||||
++s_ticket_epoch;
|
||||
}
|
||||
clear_all_tickets_locked();
|
||||
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS;
|
||||
++index) {
|
||||
@@ -1524,6 +1557,7 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server)
|
||||
}
|
||||
|
||||
esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
|
||||
web_session_id_t web_session_id,
|
||||
char *ticket, size_t capacity)
|
||||
{
|
||||
if (principal == NULL || ticket == NULL ||
|
||||
@@ -1532,8 +1566,12 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
|
||||
}
|
||||
ticket[0] = '\0';
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
uint64_t epoch = s_ticket_epoch;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
bool current = false;
|
||||
esp_err_t result = user_database_principal_is_current(principal, ¤t);
|
||||
esp_err_t result = identity_is_current(principal, web_session_id, ¤t);
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
@@ -1541,6 +1579,26 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* Reclaim stale identities without database calls under the transport lock.
|
||||
* A late result must not clear a ticket published into the same array slot. */
|
||||
for (size_t i = 0; i < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++i) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
web_serial_ticket_t candidate = s_tickets[i];
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
bool live = false;
|
||||
if (candidate.active &&
|
||||
(identity_is_current(&candidate.principal, candidate.web_session_id, &live) != ESP_OK || !live)) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
web_serial_ticket_t *entry = &s_tickets[i];
|
||||
if (entry->active && entry->web_session_id == candidate.web_session_id &&
|
||||
entry->expires_at_us == candidate.expires_at_us &&
|
||||
constant_time_equal(entry->digest, candidate.digest, sizeof(entry->digest)))
|
||||
clear_ticket_locked(entry);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
}
|
||||
|
||||
uint8_t random_bytes[WEB_SERIAL_RANDOM_BYTES] = {0};
|
||||
uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0};
|
||||
result = secure_random_fill(random_bytes, sizeof(random_bytes));
|
||||
@@ -1553,13 +1611,20 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
current = false;
|
||||
result = identity_is_current(principal, web_session_id, ¤t);
|
||||
if (result != ESP_OK || !current) {
|
||||
result = ESP_ERR_INVALID_STATE;
|
||||
ticket[0] = '\0';
|
||||
goto cleanup;
|
||||
}
|
||||
int64_t now_us = monotonic_time_us();
|
||||
bool stored = false;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_initialized && s_server != NULL) {
|
||||
if (s_initialized && s_server != NULL && epoch == s_ticket_epoch &&
|
||||
epoch != UINT64_MAX) {
|
||||
purge_tickets_locked(now_us);
|
||||
size_t selected = WEB_SERIAL_TRANSPORT_MAX_TICKETS;
|
||||
int64_t oldest_expiry = INT64_MAX;
|
||||
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS;
|
||||
++index) {
|
||||
web_serial_ticket_t *entry = &s_tickets[index];
|
||||
@@ -1567,10 +1632,6 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
|
||||
selected = index;
|
||||
break;
|
||||
}
|
||||
if (entry->expires_at_us < oldest_expiry) {
|
||||
oldest_expiry = entry->expires_at_us;
|
||||
selected = index;
|
||||
}
|
||||
}
|
||||
if (selected < WEB_SERIAL_TRANSPORT_MAX_TICKETS) {
|
||||
web_serial_ticket_t *entry = &s_tickets[selected];
|
||||
@@ -1580,6 +1641,7 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
|
||||
now_us + (int64_t)WEB_SERIAL_TRANSPORT_TICKET_LIFETIME_SECONDS *
|
||||
1000000LL;
|
||||
entry->principal = *principal;
|
||||
entry->web_session_id = web_session_id;
|
||||
entry->active = true;
|
||||
++s_counters.tickets_issued;
|
||||
stored = true;
|
||||
@@ -1599,7 +1661,8 @@ cleanup:
|
||||
}
|
||||
|
||||
esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
||||
httpd_req_t *request, const user_principal_t *principal)
|
||||
httpd_req_t *request, const user_principal_t *principal,
|
||||
web_session_id_t web_session_id)
|
||||
{
|
||||
if (request == NULL || principal == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
@@ -1620,7 +1683,7 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
||||
char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY] = {0};
|
||||
char response[WEB_SERIAL_TICKET_RESPONSE_CAPACITY];
|
||||
esp_err_t result = web_serial_transport_mint_ticket(
|
||||
principal, ticket, sizeof(ticket));
|
||||
principal, web_session_id, ticket, sizeof(ticket));
|
||||
if (result != ESP_OK) {
|
||||
secure_wipe(ticket, sizeof(ticket));
|
||||
return result;
|
||||
@@ -1643,6 +1706,9 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
||||
if (result == ESP_OK) {
|
||||
result = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = httpd_resp_send(request, response, written);
|
||||
}
|
||||
@@ -1652,6 +1718,12 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
||||
}
|
||||
|
||||
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request)
|
||||
{
|
||||
return web_serial_transport_session_ws_handler(request, 0U);
|
||||
}
|
||||
|
||||
esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request,
|
||||
web_session_id_t web_session_id)
|
||||
{
|
||||
if (request == NULL || request->handle == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
@@ -1664,12 +1736,14 @@ esp_err_t web_serial_transport_ws_handler(httpd_req_t *request)
|
||||
|
||||
httpd_ws_client_info_t info =
|
||||
httpd_ws_get_fd_info(request->handle, socket_fd);
|
||||
if (info == HTTPD_WS_CLIENT_HTTP) {
|
||||
bool opening = request->sess_ctx == NULL && web_session_id != 0U &&
|
||||
request->method == HTTP_GET && web_httpd_upgrade_requested(request);
|
||||
if (info == HTTPD_WS_CLIENT_HTTP && !opening) {
|
||||
(void)send_plain_bad_request(request);
|
||||
add_counter(&s_counters.protocol_errors, 1U);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
if (info != HTTPD_WS_CLIENT_WEBSOCKET) {
|
||||
if (info != HTTPD_WS_CLIENT_WEBSOCKET && !opening) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
@@ -1685,8 +1759,8 @@ esp_err_t web_serial_transport_ws_handler(httpd_req_t *request)
|
||||
|
||||
esp_err_t result;
|
||||
if (request->sess_ctx == NULL) {
|
||||
/* IDF has already sent 101; authentication failures must only close. */
|
||||
result = connect_websocket(request, socket_fd);
|
||||
/* The registered HTTP route defers 101 until admission. */
|
||||
result = connect_websocket(request, socket_fd, web_session_id);
|
||||
} else {
|
||||
result = process_websocket_frame(request);
|
||||
}
|
||||
@@ -1769,8 +1843,13 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
web_session_store_invalidate_username(username, username_length);
|
||||
web_admin_transport_revoke(0, username, username_length);
|
||||
bool notify = false;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_ticket_epoch != UINT64_MAX) {
|
||||
++s_ticket_epoch;
|
||||
}
|
||||
if (!s_initialized) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
@@ -1801,7 +1880,12 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
|
||||
|
||||
esp_err_t web_serial_transport_revoke_sessions(void)
|
||||
{
|
||||
web_session_store_invalidate_username(NULL, 0U);
|
||||
web_admin_transport_revoke(0, NULL, 0);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_ticket_epoch != UINT64_MAX) {
|
||||
++s_ticket_epoch;
|
||||
}
|
||||
if (!s_initialized) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
@@ -1810,7 +1894,36 @@ esp_err_t web_serial_transport_revoke_sessions(void)
|
||||
clear_all_tickets_locked();
|
||||
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
web_serial_slot_t *slot = &s_slots[index];
|
||||
if (slot->state == WEB_SERIAL_SLOT_ACTIVE) {
|
||||
if (slot->state == WEB_SERIAL_SLOT_ACTIVE ||
|
||||
slot->state == WEB_SERIAL_SLOT_RESERVED) {
|
||||
slot->close_requested = true;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
notify_transport_task();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id)
|
||||
{
|
||||
if (id == 0U) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
web_session_store_invalidate(id);
|
||||
web_admin_transport_revoke(id, NULL, 0);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_ticket_epoch != UINT64_MAX) {
|
||||
++s_ticket_epoch;
|
||||
}
|
||||
for (size_t i = 0U; i < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++i) {
|
||||
if (s_tickets[i].active && s_tickets[i].web_session_id == id) {
|
||||
clear_ticket_locked(&s_tickets[i]);
|
||||
}
|
||||
}
|
||||
for (size_t i = 0U; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) {
|
||||
web_serial_slot_t *slot = &s_slots[i];
|
||||
if ((slot->state == WEB_SERIAL_SLOT_RESERVED ||
|
||||
slot->state == WEB_SERIAL_SLOT_ACTIVE) && slot->web_session_id == id) {
|
||||
slot->close_requested = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "esp_http_server.h"
|
||||
#include "session_broker.h"
|
||||
#include "user_database.h"
|
||||
#include "web_session_store.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -98,27 +99,38 @@ esp_err_t web_serial_transport_attach_server(httpd_handle_t server);
|
||||
esp_err_t web_serial_transport_detach_server(httpd_handle_t server);
|
||||
|
||||
/*
|
||||
* Mint a one-time bearer ticket bound to a current authenticated principal. The
|
||||
* Mint a one-time bearer ticket bound to a current authenticated principal and
|
||||
* nonzero originating web-session ID. The
|
||||
* principal is copied; the output is exactly 32 Base64URL characters plus a
|
||||
* terminator and expires after 30 monotonic seconds. Never log or persist it.
|
||||
*/
|
||||
esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
|
||||
web_session_id_t web_session_id,
|
||||
char *ticket, size_t capacity);
|
||||
|
||||
/*
|
||||
* Convenience POST response helper for /api/ws-ticket. Authentication is
|
||||
* intentionally outside this module: pass the principal returned by successful
|
||||
* Basic authentication. Register it as HTTP_POST, not as a public handler.
|
||||
* authentication, and its session ID. Callers must also
|
||||
* enforce CSRF/Origin policy. Register as HTTP_POST, not as a public handler.
|
||||
*/
|
||||
esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
||||
httpd_req_t *request, const user_principal_t *principal);
|
||||
httpd_req_t *request, const user_principal_t *principal,
|
||||
web_session_id_t web_session_id);
|
||||
|
||||
/*
|
||||
* Handler for /ws/serial. Register as HTTP_GET with is_websocket=true and
|
||||
* handle_ws_control_frames=false. The initial upgraded GET authenticates the
|
||||
* ticket; later invocations process one complete data frame.
|
||||
* Frame callback installed by the HTTPD adapter after authorized admission.
|
||||
* Do not register directly: the initial HTTP GET must pass cookie/Origin policy
|
||||
* and call the session handler below before any 101 response.
|
||||
*/
|
||||
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request);
|
||||
/* Trusted cookie-authorized upgrade caller; validate cookie/Origin first.
|
||||
* Zero is invalid for initial admission; no Basic fallback exists. */
|
||||
esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request,
|
||||
web_session_id_t web_session_id);
|
||||
/* Invalidates the store first, then marks only matching tickets/slots for owner
|
||||
* cleanup. Safe to repeat after either store or transport slot reuse. */
|
||||
esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id);
|
||||
|
||||
esp_err_t web_serial_transport_get_snapshot(
|
||||
web_serial_transport_snapshot_t *snapshot);
|
||||
@@ -126,7 +138,9 @@ esp_err_t web_serial_transport_get_snapshot(
|
||||
/* Clearing counters does not alter tickets, sessions, ownership, or queued data. */
|
||||
esp_err_t web_serial_transport_clear_counters(void);
|
||||
|
||||
/* Invalidate tickets/sessions for one account, or all authenticated sessions. */
|
||||
/* Invalidate cookie records and tickets/sockets for one username (also after
|
||||
* deletion), or all accounts. Store invalidation occurs even if serial init
|
||||
* failed. Authoritative store/principal checks supplement notifications. */
|
||||
esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
|
||||
size_t username_length);
|
||||
esp_err_t web_serial_transport_revoke_sessions(void);
|
||||
|
||||
+125
-234
@@ -1,5 +1,5 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* TLS-only HTTP server with bounded Basic authentication and status output. */
|
||||
/* TLS-only HTTP server with bounded cookie authentication and status output. */
|
||||
|
||||
#include "web_server.h"
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_https_server.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif_ip_addr.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "mbedtls/base64.h"
|
||||
#include "mbedtls/md.h"
|
||||
#include "secure_random.h"
|
||||
#include "serial_config.h"
|
||||
#include "serial_service.h"
|
||||
@@ -24,28 +23,15 @@
|
||||
#include "user_database.h"
|
||||
#include "web_security.h"
|
||||
#include "web_serial_transport.h"
|
||||
#include "web_admin_transport.h"
|
||||
#include "web_session_store.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "web_ui.h"
|
||||
#include "wifi_manager.h"
|
||||
|
||||
#define WEB_SERVER_PORT 443U
|
||||
#define WEB_SERVER_MAX_BASIC_DECODED \
|
||||
(USER_DATABASE_USERNAME_CAPACITY + 1U + USER_DATABASE_PASSWORD_CAPACITY)
|
||||
#define WEB_SERVER_MAX_BASIC_ENCODED \
|
||||
(((WEB_SERVER_MAX_BASIC_DECODED + 2U) / 3U) * 4U)
|
||||
#define WEB_SERVER_MAX_AUTHORIZATION \
|
||||
((sizeof("Basic ") - 1U) + WEB_SERVER_MAX_BASIC_ENCODED + 1U)
|
||||
#define WEB_SERVER_STATUS_JSON_CAPACITY 3072U
|
||||
#define WEB_SERVER_AUTH_CACHE_ENTRIES 4U
|
||||
#define WEB_SERVER_AUTH_CACHE_KEY_LENGTH 32U
|
||||
#define WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH 32U
|
||||
#define WEB_SERVER_AUTH_CACHE_TTL_US 300000000LL
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
int64_t expires_at_us;
|
||||
uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH];
|
||||
user_principal_t principal;
|
||||
} web_server_auth_cache_entry_t;
|
||||
|
||||
static SemaphoreHandle_t s_server_mutex;
|
||||
static httpd_handle_t s_server;
|
||||
@@ -54,13 +40,11 @@ static bool s_transitioning;
|
||||
static bool s_serial_transport_init_attempted;
|
||||
static bool s_serial_transport_initialized;
|
||||
static bool s_serial_transport_attached;
|
||||
/* Retained across failed stop so queued admin work cannot outlive its server. */
|
||||
static bool s_admin_transport_owned;
|
||||
static esp_err_t s_last_error = ESP_ERR_INVALID_STATE;
|
||||
static esp_err_t s_serial_transport_error = ESP_ERR_INVALID_STATE;
|
||||
static web_server_counters_t s_counters;
|
||||
static bool s_auth_cache_ready;
|
||||
static uint8_t s_auth_cache_key[WEB_SERVER_AUTH_CACHE_KEY_LENGTH];
|
||||
static web_server_auth_cache_entry_t
|
||||
s_auth_cache[WEB_SERVER_AUTH_CACHE_ENTRIES];
|
||||
|
||||
static esp_err_t ensure_mutex(void)
|
||||
{
|
||||
@@ -110,188 +94,19 @@ static esp_err_t send_plain_error(httpd_req_t *request,
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t send_authentication_required(httpd_req_t *request)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_hdr(
|
||||
request, "WWW-Authenticate",
|
||||
"Basic realm=\"ESP32-SAK\", charset=\"UTF-8\"");
|
||||
if (error != ESP_OK) {
|
||||
increment_counter(&s_counters.response_errors);
|
||||
return error;
|
||||
}
|
||||
return send_plain_error(request, "401 Unauthorized", "Authentication required.\n");
|
||||
}
|
||||
|
||||
static bool constant_time_equal(const uint8_t *left, const uint8_t *right,
|
||||
size_t length)
|
||||
{
|
||||
uint8_t difference = 0U;
|
||||
for (size_t index = 0U; index < length; ++index) {
|
||||
difference |= left[index] ^ right[index];
|
||||
}
|
||||
return difference == 0U;
|
||||
}
|
||||
|
||||
static esp_err_t calculate_auth_cache_digest(
|
||||
const char *authorization, size_t authorization_length,
|
||||
uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH])
|
||||
{
|
||||
if (!s_auth_cache_ready) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
const mbedtls_md_info_t *info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
||||
if (info == NULL ||
|
||||
mbedtls_md_hmac(info, s_auth_cache_key, sizeof(s_auth_cache_key),
|
||||
(const uint8_t *)authorization, authorization_length,
|
||||
digest) != 0) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static bool authenticate_from_cache(
|
||||
const uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH],
|
||||
user_principal_t *principal)
|
||||
{
|
||||
int64_t now = esp_timer_get_time();
|
||||
for (size_t index = 0U; index < WEB_SERVER_AUTH_CACHE_ENTRIES; ++index) {
|
||||
web_server_auth_cache_entry_t *entry = &s_auth_cache[index];
|
||||
if (!entry->active || entry->expires_at_us <= now ||
|
||||
!constant_time_equal(entry->digest, digest, sizeof(entry->digest))) {
|
||||
if (entry->active && entry->expires_at_us <= now) {
|
||||
secure_wipe(entry, sizeof(*entry));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
bool current = false;
|
||||
if (user_database_principal_is_current(&entry->principal, ¤t) == ESP_OK &&
|
||||
current) {
|
||||
*principal = entry->principal;
|
||||
entry->expires_at_us = now + WEB_SERVER_AUTH_CACHE_TTL_US;
|
||||
return true;
|
||||
}
|
||||
secure_wipe(entry, sizeof(*entry));
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void store_authenticated_request(
|
||||
const uint8_t digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH],
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
int64_t now = esp_timer_get_time();
|
||||
size_t selected = 0U;
|
||||
int64_t earliest_expiry = INT64_MAX;
|
||||
for (size_t index = 0U; index < WEB_SERVER_AUTH_CACHE_ENTRIES; ++index) {
|
||||
web_server_auth_cache_entry_t *entry = &s_auth_cache[index];
|
||||
if (entry->active &&
|
||||
constant_time_equal(entry->digest, digest, sizeof(entry->digest))) {
|
||||
selected = index;
|
||||
break;
|
||||
}
|
||||
if (!entry->active || entry->expires_at_us <= now) {
|
||||
selected = index;
|
||||
earliest_expiry = INT64_MIN;
|
||||
} else if (earliest_expiry != INT64_MIN &&
|
||||
entry->expires_at_us < earliest_expiry) {
|
||||
selected = index;
|
||||
earliest_expiry = entry->expires_at_us;
|
||||
}
|
||||
}
|
||||
|
||||
web_server_auth_cache_entry_t *entry = &s_auth_cache[selected];
|
||||
secure_wipe(entry, sizeof(*entry));
|
||||
entry->active = true;
|
||||
entry->expires_at_us = now + WEB_SERVER_AUTH_CACHE_TTL_US;
|
||||
memcpy(entry->digest, digest, sizeof(entry->digest));
|
||||
entry->principal = *principal;
|
||||
}
|
||||
|
||||
static esp_err_t authenticate_request(httpd_req_t *request,
|
||||
user_principal_t *principal,
|
||||
bool *authenticated)
|
||||
{
|
||||
char authorization[WEB_SERVER_MAX_AUTHORIZATION] = {0};
|
||||
uint8_t decoded[WEB_SERVER_MAX_BASIC_DECODED] = {0};
|
||||
size_t decoded_length = 0U;
|
||||
uint8_t cache_digest[WEB_SERVER_AUTH_CACHE_DIGEST_LENGTH] = {0};
|
||||
bool cache_digest_valid = false;
|
||||
esp_err_t result = ESP_OK;
|
||||
memset(principal, 0, sizeof(*principal));
|
||||
*authenticated = false;
|
||||
|
||||
increment_counter(&s_counters.requests);
|
||||
size_t header_length = httpd_req_get_hdr_value_len(request, "Authorization");
|
||||
if (header_length == 0U || header_length >= sizeof(authorization)) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (httpd_req_get_hdr_value_str(request, "Authorization",
|
||||
authorization, sizeof(authorization)) != ESP_OK ||
|
||||
header_length < 7U || strncasecmp(authorization, "Basic ", 6U) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
result = calculate_auth_cache_digest(authorization, header_length, cache_digest);
|
||||
if (result != ESP_OK) {
|
||||
goto cleanup;
|
||||
}
|
||||
cache_digest_valid = true;
|
||||
if (authenticate_from_cache(cache_digest, principal)) {
|
||||
*authenticated = true;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
int decode_result = mbedtls_base64_decode(
|
||||
decoded, sizeof(decoded), &decoded_length,
|
||||
(const unsigned char *)authorization + 6U, header_length - 6U);
|
||||
if (decode_result != 0 || decoded_length == 0U) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
uint8_t *separator = memchr(decoded, ':', decoded_length);
|
||||
if (separator == NULL) {
|
||||
goto cleanup;
|
||||
}
|
||||
size_t username_length = (size_t)(separator - decoded);
|
||||
size_t password_length = decoded_length - username_length - 1U;
|
||||
result = user_database_authenticate_password(
|
||||
decoded, username_length, separator + 1U, password_length,
|
||||
principal, authenticated);
|
||||
if (result == ESP_OK && *authenticated && cache_digest_valid) {
|
||||
store_authenticated_request(cache_digest, principal);
|
||||
}
|
||||
|
||||
cleanup:
|
||||
secure_wipe(authorization, sizeof(authorization));
|
||||
secure_wipe(decoded, sizeof(decoded));
|
||||
secure_wipe(cache_digest, sizeof(cache_digest));
|
||||
if (result != ESP_OK) {
|
||||
memset(principal, 0, sizeof(*principal));
|
||||
return result;
|
||||
}
|
||||
if (*authenticated) {
|
||||
increment_counter(&s_counters.authenticated_requests);
|
||||
} else {
|
||||
memset(principal, 0, sizeof(*principal));
|
||||
increment_counter(&s_counters.authentication_failures);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t authorize_or_respond(httpd_req_t *request,
|
||||
user_principal_t *principal,
|
||||
bool *authorized)
|
||||
{
|
||||
*authorized = false;
|
||||
esp_err_t error = authenticate_request(request, principal, authorized);
|
||||
if (error != ESP_OK) {
|
||||
*authorized = false;
|
||||
return send_plain_error(request, "503 Service Unavailable",
|
||||
"Authentication service unavailable.\n");
|
||||
}
|
||||
return *authorized ? ESP_OK : send_authentication_required(request);
|
||||
web_session_view_t view = {0};
|
||||
increment_counter(&s_counters.requests);
|
||||
esp_err_t error = web_cookie_auth_require(request, false, false, &view, authorized);
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
*principal = view.principal;
|
||||
secure_wipe(&view, sizeof(view));
|
||||
increment_counter(*authorized ? &s_counters.authenticated_requests :
|
||||
&s_counters.authentication_failures);
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t send_authenticated_ui(httpd_req_t *request,
|
||||
@@ -329,23 +144,21 @@ static esp_err_t asset_handler(httpd_req_t *request)
|
||||
|
||||
static esp_err_t ticket_handler(httpd_req_t *request)
|
||||
{
|
||||
user_principal_t principal = {0};
|
||||
web_session_view_t view = {0};
|
||||
bool authorized = false;
|
||||
esp_err_t error = authorize_or_respond(request, &principal, &authorized);
|
||||
increment_counter(&s_counters.requests);
|
||||
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &authorized);
|
||||
increment_counter(authorized ? &s_counters.authenticated_requests : &s_counters.authentication_failures);
|
||||
if (error != ESP_OK || !authorized) {
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
increment_counter(&s_counters.ticket_requests);
|
||||
if (request->content_len != 0U) {
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
return send_plain_error(request, "400 Bad Request",
|
||||
"Ticket requests must have an empty body.\n");
|
||||
}
|
||||
|
||||
error = web_serial_transport_handle_authenticated_ticket_request(
|
||||
request, &principal);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
request, &view.principal, view.id);
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
if (error == ESP_OK) {
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -353,9 +166,15 @@ static esp_err_t ticket_handler(httpd_req_t *request)
|
||||
return send_plain_error(request, "400 Bad Request",
|
||||
"Invalid web-terminal ticket request.\n");
|
||||
}
|
||||
if (error == ESP_ERR_NO_MEM &&
|
||||
httpd_resp_set_hdr(request, "Retry-After", "5") == ESP_OK) {
|
||||
return send_plain_error(request, "503 Service Unavailable", "{\"error\":\"capacity\"}");
|
||||
}
|
||||
increment_counter(&s_counters.response_errors);
|
||||
return send_plain_error(request, "503 Service Unavailable",
|
||||
"Web terminal transport unavailable.\n");
|
||||
if (error == ESP_ERR_INVALID_STATE)
|
||||
return send_plain_error(request, "503 Service Unavailable",
|
||||
"Web terminal transport unavailable.\n");
|
||||
return error; /* A failed/partial send must close, not send a second response. */
|
||||
}
|
||||
|
||||
static const char *safe_string(const char *value)
|
||||
@@ -531,15 +350,40 @@ static const httpd_uri_t s_ticket_uri = {
|
||||
.user_ctx = NULL,
|
||||
};
|
||||
|
||||
static esp_err_t websocket_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, false, true, &view, &allowed);
|
||||
if (allowed) error = web_serial_transport_session_ws_handler(request, view.id);
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
static const httpd_uri_t s_websocket_uri = {
|
||||
.uri = WEB_SERIAL_TRANSPORT_WS_URI,
|
||||
.method = HTTP_GET,
|
||||
.handler = web_serial_transport_ws_handler,
|
||||
.handler = websocket_handler,
|
||||
.user_ctx = NULL,
|
||||
.is_websocket = true,
|
||||
/* Authorize and admit before the adapter sends 101, not IDF's pre-handler path. */
|
||||
.is_websocket = false,
|
||||
.handle_ws_control_frames = false,
|
||||
};
|
||||
|
||||
static const httpd_uri_t s_admin_ticket_uri = {
|
||||
.uri = WEB_ADMIN_TICKET_URI,
|
||||
.method = HTTP_POST,
|
||||
.handler = web_admin_transport_ticket_handler,
|
||||
};
|
||||
|
||||
static const httpd_uri_t s_admin_websocket_uri = {
|
||||
.uri = WEB_ADMIN_WS_URI,
|
||||
.method = HTTP_GET,
|
||||
.handler = web_admin_transport_upgrade_handler,
|
||||
.is_websocket = false, /* Cookie/Origin/ticket/console admission precedes 101. */
|
||||
};
|
||||
|
||||
static const httpd_uri_t s_xterm_js_uri = {
|
||||
.uri = "/assets/xterm.js",
|
||||
.method = HTTP_GET,
|
||||
@@ -587,6 +431,22 @@ static const httpd_uri_t *const s_uri_handlers[] = {
|
||||
&s_logo_uri,
|
||||
};
|
||||
|
||||
static const httpd_uri_t s_auth_uris[] = {
|
||||
{.uri = "/login", .method = HTTP_GET, .handler = web_cookie_auth_handler},
|
||||
{.uri = "/api/login-challenge", .method = HTTP_GET, .handler = web_cookie_auth_handler},
|
||||
{.uri = "/api/login", .method = HTTP_POST, .handler = web_cookie_auth_handler},
|
||||
{.uri = "/api/session", .method = HTTP_GET, .handler = web_cookie_auth_handler},
|
||||
{.uri = "/api/logout", .method = HTTP_POST, .handler = web_cookie_auth_handler},
|
||||
};
|
||||
|
||||
static esp_err_t route_error_handler(httpd_req_t *request, httpd_err_code_t code)
|
||||
{
|
||||
(void)send_plain_error(request,
|
||||
code == HTTPD_405_METHOD_NOT_ALLOWED ? "405 Method Not Allowed" : "404 Not Found",
|
||||
"Unsupported route or method.\n");
|
||||
return ESP_FAIL; /* Do not drain a rejected request body on keepalive. */
|
||||
}
|
||||
|
||||
esp_err_t web_server_init(void)
|
||||
{
|
||||
esp_err_t error = ensure_mutex();
|
||||
@@ -594,19 +454,6 @@ esp_err_t web_server_init(void)
|
||||
return error;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
if (!s_auth_cache_ready) {
|
||||
error = secure_random_fill(s_auth_cache_key, sizeof(s_auth_cache_key));
|
||||
if (error == ESP_OK) {
|
||||
secure_wipe(s_auth_cache, sizeof(s_auth_cache));
|
||||
s_auth_cache_ready = true;
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
bool initialize_serial_transport = false;
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
if (!s_serial_transport_init_attempted) {
|
||||
@@ -652,22 +499,27 @@ esp_err_t web_server_start(void)
|
||||
serial_transport_ready = s_serial_transport_initialized;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
|
||||
/* Initialize only after lifecycle admission; failure gates all HTTPS auth. */
|
||||
error = web_cookie_auth_start();
|
||||
|
||||
uint8_t certificate[WEB_SECURITY_CERTIFICATE_DER_CAPACITY] = {0};
|
||||
uint8_t private_key[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY] = {0};
|
||||
size_t certificate_length = 0U;
|
||||
size_t private_key_length = 0U;
|
||||
httpd_handle_t server = NULL;
|
||||
|
||||
error = web_security_copy_tls_material(
|
||||
if (error == ESP_OK) error = web_security_copy_tls_material(
|
||||
certificate, sizeof(certificate), &certificate_length,
|
||||
private_key, sizeof(private_key), &private_key_length);
|
||||
if (error == ESP_OK) {
|
||||
httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT();
|
||||
/* Two browser terminals retain room for parallel assets and status fetches. */
|
||||
/* Two serial + one admin socket leave three slots for HTTPS requests. */
|
||||
config.httpd.max_open_sockets = 6;
|
||||
config.httpd.max_uri_handlers =
|
||||
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]);
|
||||
config.httpd.lru_purge_enable = true;
|
||||
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
|
||||
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 2U;
|
||||
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
|
||||
config.httpd.lru_purge_enable = false;
|
||||
config.httpd.recv_wait_timeout = 1;
|
||||
config.httpd.send_wait_timeout = 1;
|
||||
config.servercert = certificate;
|
||||
@@ -689,11 +541,32 @@ esp_err_t web_server_start(void)
|
||||
}
|
||||
|
||||
bool serial_transport_attached = false;
|
||||
for (size_t i = 0; error == ESP_OK && i < sizeof(s_auth_uris) / sizeof(s_auth_uris[0]); ++i)
|
||||
error = httpd_register_uri_handler(server, &s_auth_uris[i]);
|
||||
if (error == ESP_OK)
|
||||
error = httpd_register_err_handler(server, HTTPD_404_NOT_FOUND, route_error_handler);
|
||||
if (error == ESP_OK)
|
||||
error = httpd_register_err_handler(server, HTTPD_405_METHOD_NOT_ALLOWED, route_error_handler);
|
||||
esp_err_t attach_error = s_serial_transport_error;
|
||||
if (error == ESP_OK && serial_transport_ready) {
|
||||
attach_error = web_serial_transport_attach_server(server);
|
||||
serial_transport_attached = attach_error == ESP_OK;
|
||||
}
|
||||
bool admin_transport_owned = false;
|
||||
if (error == ESP_OK) {
|
||||
/* Even optional route allocation failure must leave M1 available. */
|
||||
esp_err_t admin_error = httpd_register_uri_handler(server, &s_admin_ticket_uri);
|
||||
bool ticket_registered = admin_error == ESP_OK;
|
||||
if (admin_error == ESP_OK)
|
||||
admin_error = httpd_register_uri_handler(server, &s_admin_websocket_uri);
|
||||
if (admin_error != ESP_OK && ticket_registered)
|
||||
(void)httpd_unregister_uri_handler(server, WEB_ADMIN_TICKET_URI, HTTP_POST);
|
||||
if (admin_error == ESP_OK && web_admin_transport_init() == ESP_OK)
|
||||
admin_transport_owned = web_admin_transport_attach(server) == ESP_OK;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
web_cookie_auth_stop();
|
||||
}
|
||||
if (error != ESP_OK && server != NULL) {
|
||||
esp_err_t cleanup_error = httpd_ssl_stop(server);
|
||||
if (cleanup_error == ESP_OK) {
|
||||
@@ -709,6 +582,7 @@ esp_err_t web_server_start(void)
|
||||
s_last_error = error;
|
||||
s_serial_transport_error = attach_error;
|
||||
s_serial_transport_attached = serial_transport_attached;
|
||||
s_admin_transport_owned = admin_transport_owned;
|
||||
if (error == ESP_OK) {
|
||||
s_server = server;
|
||||
++s_counters.starts;
|
||||
@@ -734,10 +608,24 @@ esp_err_t web_server_stop(void)
|
||||
}
|
||||
httpd_handle_t server = s_server;
|
||||
bool serial_transport_attached = s_serial_transport_attached;
|
||||
bool admin_transport_owned = s_admin_transport_owned;
|
||||
esp_err_t serial_transport_error = s_serial_transport_error;
|
||||
s_transitioning = true;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
|
||||
web_cookie_auth_stop();
|
||||
if (admin_transport_owned) {
|
||||
esp_err_t detach_error = web_admin_transport_detach(server);
|
||||
if (detach_error != ESP_OK) {
|
||||
/* Unlike serial's broker timeout, an admin submission timeout must
|
||||
* retain HTTPD until detach can fence all queue submitters. */
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
s_transitioning = false;
|
||||
s_last_error = detach_error;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return detach_error;
|
||||
}
|
||||
}
|
||||
if (serial_transport_attached) {
|
||||
esp_err_t detach_error = web_serial_transport_detach_server(server);
|
||||
if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) {
|
||||
@@ -752,6 +640,7 @@ esp_err_t web_server_stop(void)
|
||||
}
|
||||
|
||||
esp_err_t error = httpd_ssl_stop(server);
|
||||
if (error == ESP_OK && admin_transport_owned) web_admin_transport_stopped(server);
|
||||
if (error != ESP_OK && serial_transport_attached) {
|
||||
/* Stay detached: old HTTPD work may still be reading static TX storage. */
|
||||
serial_transport_error = ESP_ERR_INVALID_STATE;
|
||||
@@ -764,6 +653,7 @@ esp_err_t web_server_stop(void)
|
||||
s_serial_transport_attached = false;
|
||||
if (error == ESP_OK) {
|
||||
s_server = NULL;
|
||||
s_admin_transport_owned = false;
|
||||
++s_counters.stops;
|
||||
}
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
@@ -800,5 +690,6 @@ esp_err_t web_server_clear_counters(void)
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
memset(&s_counters, 0, sizeof(s_counters));
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
web_cookie_auth_clear_counters();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_session_store.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <string.h>
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
typedef struct {
|
||||
web_session_id_t id;
|
||||
int64_t expires_at_us;
|
||||
user_principal_t principal;
|
||||
uint8_t token_digest[WEB_SESSION_STORE_SECRET_BYTES];
|
||||
uint8_t origin_digest[WEB_SESSION_STORE_SECRET_BYTES];
|
||||
uint8_t csrf[WEB_SESSION_STORE_SECRET_BYTES];
|
||||
} session_entry_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static struct {
|
||||
session_entry_t entries[WEB_SESSION_STORE_CAPACITY];
|
||||
uint64_t next_id;
|
||||
uint64_t epoch;
|
||||
bool ready;
|
||||
bool initializing;
|
||||
uint32_t issued;
|
||||
uint32_t capacity_rejections;
|
||||
uint32_t expired;
|
||||
uint32_t invalidated;
|
||||
uint32_t lookup_rejections;
|
||||
uint32_t init_failures;
|
||||
} s_state;
|
||||
|
||||
static bool equal_bytes(const uint8_t *a, const uint8_t *b, size_t length)
|
||||
{
|
||||
uint8_t difference = 0U;
|
||||
for (size_t i = 0U; i < length; ++i) {
|
||||
difference |= a[i] ^ b[i];
|
||||
}
|
||||
return difference == 0U;
|
||||
}
|
||||
|
||||
static void encode_hex(const uint8_t *bytes, char *text)
|
||||
{
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_SECRET_BYTES; ++i) {
|
||||
text[2U * i] = hex[bytes[i] >> 4U];
|
||||
text[2U * i + 1U] = hex[bytes[i] & 15U];
|
||||
}
|
||||
text[WEB_SESSION_STORE_TOKEN_LENGTH] = '\0';
|
||||
}
|
||||
|
||||
static esp_err_t digest(const void *input, size_t length, uint8_t *output)
|
||||
{
|
||||
return mbedtls_sha256(input, length, output, 0) == 0 ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
|
||||
static esp_err_t origin_digest(const char *origin, size_t length, uint8_t *output)
|
||||
{
|
||||
if (origin == NULL || length <= 8U ||
|
||||
length > WEB_SESSION_STORE_ORIGIN_MAX_LENGTH ||
|
||||
memcmp(origin, "https://", 8U) != 0 || memchr(origin, '\0', length) != NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
return digest(origin, length, output);
|
||||
}
|
||||
|
||||
static session_entry_t *find_locked(web_session_id_t id)
|
||||
{
|
||||
if (id != 0U) {
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
if (s_state.entries[i].id == id) {
|
||||
return &s_state.entries[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void retire_locked(session_entry_t *entry, bool expired)
|
||||
{
|
||||
if (entry->id != 0U) {
|
||||
if (expired) {
|
||||
++s_state.expired;
|
||||
} else {
|
||||
++s_state.invalidated;
|
||||
}
|
||||
secure_wipe(entry, sizeof(*entry));
|
||||
}
|
||||
}
|
||||
|
||||
static void expire_locked(int64_t now)
|
||||
{
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
session_entry_t *entry = &s_state.entries[i];
|
||||
if (entry->id != 0U && entry->expires_at_us <= now) {
|
||||
retire_locked(entry, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Also cancels issuance already outside the lock, even if no record matched.
|
||||
* Exhaustion is fail-closed rather than allowing an epoch/identity ABA. */
|
||||
static void advance_epoch_locked(void)
|
||||
{
|
||||
if (s_state.epoch != UINT64_MAX) {
|
||||
++s_state.epoch;
|
||||
} else {
|
||||
s_state.ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
static void export_view(const session_entry_t *entry, web_session_view_t *view)
|
||||
{
|
||||
view->id = entry->id;
|
||||
view->expires_at_us = entry->expires_at_us;
|
||||
view->principal = entry->principal;
|
||||
encode_hex(entry->csrf, view->csrf);
|
||||
}
|
||||
|
||||
esp_err_t web_session_store_init(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_state.ready) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (s_state.initializing || s_state.epoch == UINT64_MAX) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_state.initializing = true;
|
||||
uint64_t epoch = s_state.epoch;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
uint8_t probe[WEB_SESSION_STORE_SECRET_BYTES] = {0};
|
||||
esp_err_t error = secure_random_fill(probe, sizeof(probe));
|
||||
secure_wipe(probe, sizeof(probe));
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (epoch != s_state.epoch) {
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_state.initializing = false;
|
||||
s_state.ready = error == ESP_OK;
|
||||
if (error != ESP_OK) {
|
||||
++s_state.init_failures;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_session_store_stop(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
s_state.ready = false;
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
retire_locked(&s_state.entries[i], false);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
/* Never enter the database while holding our lock. On return, re-find the
|
||||
* non-reused ID and deadline: logout/stop/slot reuse may have raced the call. */
|
||||
static esp_err_t resolve(web_session_id_t id, web_session_view_t *view,
|
||||
const user_principal_t *expected)
|
||||
{
|
||||
session_entry_t candidate = {0};
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
session_entry_t *entry = find_locked(id);
|
||||
esp_err_t error = s_state.ready ? ESP_ERR_NOT_FOUND : ESP_ERR_INVALID_STATE;
|
||||
if (s_state.ready && entry != NULL) {
|
||||
candidate = *entry;
|
||||
error = ESP_OK;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
if (error == ESP_OK && expected != NULL &&
|
||||
(candidate.principal.user_id != expected->user_id ||
|
||||
candidate.principal.auth_generation != expected->auth_generation ||
|
||||
candidate.principal.role != expected->role ||
|
||||
candidate.principal.method != expected->method ||
|
||||
candidate.principal.username_length != expected->username_length ||
|
||||
memcmp(candidate.principal.username, expected->username,
|
||||
candidate.principal.username_length) != 0)) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
bool current = false;
|
||||
error = user_database_principal_is_current(&candidate.principal, ¤t);
|
||||
now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
entry = find_locked(id);
|
||||
if (!s_state.ready || entry == NULL) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
} else if (error != ESP_OK || !current) {
|
||||
retire_locked(entry, false);
|
||||
if (error == ESP_OK) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
if (error == ESP_OK && view != NULL) {
|
||||
export_view(&candidate, view);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
++s_state.lookup_rejections;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_session_store_prune(void)
|
||||
{
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
web_session_id_t id = s_state.entries[i].id;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (id != 0U) {
|
||||
(void)resolve(id, NULL, NULL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t web_session_store_issue(
|
||||
const user_principal_t *principal, const char *origin, size_t origin_length,
|
||||
char token[WEB_SESSION_STORE_TOKEN_LENGTH + 1U], web_session_view_t *view)
|
||||
{
|
||||
if (token != NULL) {
|
||||
secure_wipe(token, WEB_SESSION_STORE_TOKEN_LENGTH + 1U);
|
||||
}
|
||||
if (view != NULL) {
|
||||
secure_wipe(view, sizeof(*view));
|
||||
}
|
||||
if (principal == NULL || token == NULL || view == NULL ||
|
||||
principal->user_id == 0U || principal->auth_generation == 0U ||
|
||||
principal->method != USER_AUTH_METHOD_PASSWORD ||
|
||||
(principal->role != USER_ROLE_USER && principal->role != USER_ROLE_ADMIN) ||
|
||||
principal->username_length == 0U ||
|
||||
principal->username_length > USER_DATABASE_USERNAME_CAPACITY ||
|
||||
principal->username[principal->username_length] != '\0') {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_state.ready;
|
||||
uint64_t epoch = s_state.epoch;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
web_session_store_prune();
|
||||
|
||||
session_entry_t candidate = {0};
|
||||
uint8_t random[2U * WEB_SESSION_STORE_SECRET_BYTES] = {0};
|
||||
esp_err_t error = origin_digest(origin, origin_length, candidate.origin_digest);
|
||||
if (error == ESP_OK) {
|
||||
error = secure_random_fill(random, sizeof(random));
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
encode_hex(random, token);
|
||||
memcpy(candidate.csrf, random + WEB_SESSION_STORE_SECRET_BYTES,
|
||||
sizeof(candidate.csrf));
|
||||
error = digest(token, WEB_SESSION_STORE_TOKEN_LENGTH, candidate.token_digest);
|
||||
}
|
||||
bool current = false;
|
||||
if (error == ESP_OK) {
|
||||
candidate.principal = *principal;
|
||||
error = user_database_principal_is_current(&candidate.principal, ¤t);
|
||||
if (error == ESP_OK && !current) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
session_entry_t *available = NULL;
|
||||
bool duplicate = false;
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
session_entry_t *entry = &s_state.entries[i];
|
||||
if (entry->id == 0U) {
|
||||
if (available == NULL) {
|
||||
available = entry;
|
||||
}
|
||||
} else if (equal_bytes(entry->token_digest, candidate.token_digest,
|
||||
sizeof(entry->token_digest))) {
|
||||
duplicate = true;
|
||||
}
|
||||
}
|
||||
if (!s_state.ready || epoch != s_state.epoch ||
|
||||
s_state.next_id == UINT64_MAX || now < 0 ||
|
||||
now > INT64_MAX - WEB_SESSION_STORE_LIFETIME_US) {
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
} else if (duplicate) {
|
||||
error = ESP_FAIL;
|
||||
} else if (available == NULL) {
|
||||
++s_state.capacity_rejections;
|
||||
error = ESP_ERR_NO_MEM;
|
||||
} else {
|
||||
candidate.id = ++s_state.next_id;
|
||||
candidate.expires_at_us = now + WEB_SESSION_STORE_LIFETIME_US;
|
||||
*available = candidate;
|
||||
++s_state.issued;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
export_view(&candidate, view);
|
||||
} else {
|
||||
secure_wipe(token, WEB_SESSION_STORE_TOKEN_LENGTH + 1U);
|
||||
}
|
||||
secure_wipe(random, sizeof(random));
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_session_store_lookup(
|
||||
const char *token, size_t token_length, const char *origin,
|
||||
size_t origin_length, web_session_view_t *view)
|
||||
{
|
||||
if (view == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
secure_wipe(view, sizeof(*view));
|
||||
if (token == NULL || token_length != WEB_SESSION_STORE_TOKEN_LENGTH) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
for (size_t i = 0U; i < token_length; ++i) {
|
||||
if (!((token[i] >= '0' && token[i] <= '9') ||
|
||||
(token[i] >= 'a' && token[i] <= 'f'))) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
}
|
||||
uint8_t token_hash[WEB_SESSION_STORE_SECRET_BYTES] = {0};
|
||||
uint8_t origin_hash[WEB_SESSION_STORE_SECRET_BYTES] = {0};
|
||||
esp_err_t error = origin_digest(origin, origin_length, origin_hash);
|
||||
if (error == ESP_OK) {
|
||||
error = digest(token, token_length, token_hash);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
web_session_id_t id = 0U;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
const session_entry_t *entry = &s_state.entries[i];
|
||||
bool matches = equal_bytes(entry->token_digest, token_hash, sizeof(token_hash));
|
||||
matches &= equal_bytes(entry->origin_digest, origin_hash, sizeof(origin_hash));
|
||||
if (entry->id != 0U && matches) {
|
||||
id = entry->id;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
error = resolve(id, view, NULL);
|
||||
}
|
||||
secure_wipe(token_hash, sizeof(token_hash));
|
||||
secure_wipe(origin_hash, sizeof(origin_hash));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_session_store_is_current(web_session_id_t id, bool *current)
|
||||
{
|
||||
if (current == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
*current = false;
|
||||
esp_err_t error = resolve(id, NULL, NULL);
|
||||
if (error == ESP_OK) {
|
||||
*current = true;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_session_store_check_principal(web_session_id_t id,
|
||||
const user_principal_t *principal,
|
||||
bool *current)
|
||||
{
|
||||
if (current == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
*current = false;
|
||||
if (principal == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
esp_err_t error = resolve(id, NULL, principal);
|
||||
*current = error == ESP_OK;
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_session_store_invalidate_username(const uint8_t *username, size_t length)
|
||||
{
|
||||
if ((username == NULL && length != 0U) ||
|
||||
(username != NULL && !user_database_username_valid(username, length))) {
|
||||
return;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
session_entry_t *entry = &s_state.entries[i];
|
||||
if (username == NULL ||
|
||||
(entry->principal.username_length == length &&
|
||||
memcmp(entry->principal.username, username, length) == 0)) {
|
||||
retire_locked(entry, false);
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_session_store_invalidate(web_session_id_t id)
|
||||
{
|
||||
if (id == 0U) {
|
||||
return;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
session_entry_t *entry = find_locked(id);
|
||||
if (entry != NULL) {
|
||||
retire_locked(entry, false);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_session_store_invalidate_user(uint32_t user_id)
|
||||
{
|
||||
if (user_id == 0U) {
|
||||
return;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
if (s_state.entries[i].principal.user_id == user_id) {
|
||||
retire_locked(&s_state.entries[i], false);
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
esp_err_t web_session_store_get_snapshot(web_session_store_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
snapshot->initialized = s_state.ready;
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
snapshot->active += s_state.entries[i].id != 0U;
|
||||
}
|
||||
snapshot->issued = s_state.issued;
|
||||
snapshot->capacity_rejections = s_state.capacity_rejections;
|
||||
snapshot->expired = s_state.expired;
|
||||
snapshot->invalidated = s_state.invalidated;
|
||||
snapshot->lookup_rejections = s_state.lookup_rejections;
|
||||
snapshot->init_failures = s_state.init_failures;
|
||||
snapshot->storage_bytes = sizeof(s_state) + sizeof(s_lock);
|
||||
snapshot->slot_bytes = sizeof(session_entry_t);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Internal session primitives; no HTTP authorization is enabled by this module. */
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
#include "user_database.h"
|
||||
|
||||
#define WEB_SESSION_STORE_CAPACITY 4U
|
||||
#define WEB_SESSION_STORE_SECRET_BYTES 32U
|
||||
#define WEB_SESSION_STORE_TOKEN_LENGTH 64U
|
||||
#define WEB_SESSION_STORE_ORIGIN_MAX_LENGTH 128U
|
||||
#define WEB_SESSION_STORE_LIFETIME_US 3600000000LL
|
||||
|
||||
typedef uint64_t web_session_id_t;
|
||||
|
||||
/* Sensitive request-local result, NOT a routine snapshot. Wipe after use. */
|
||||
typedef struct {
|
||||
web_session_id_t id;
|
||||
int64_t expires_at_us;
|
||||
user_principal_t principal;
|
||||
char csrf[WEB_SESSION_STORE_TOKEN_LENGTH + 1U];
|
||||
} web_session_view_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
uint32_t active;
|
||||
uint32_t issued;
|
||||
uint32_t capacity_rejections;
|
||||
uint32_t expired;
|
||||
uint32_t invalidated;
|
||||
uint32_t lookup_rejections;
|
||||
uint32_t init_failures;
|
||||
size_t storage_bytes;
|
||||
size_t slot_bytes;
|
||||
} web_session_store_snapshot_t;
|
||||
|
||||
/* Idempotent; probes the already-seeded shared RNG, never seeds it here.
|
||||
* Stop cancels in-flight initialization/issuance and wipes all records. IDs and
|
||||
* invalidation epochs never reset within a boot, even across stop/init. */
|
||||
esp_err_t web_session_store_init(void);
|
||||
void web_session_store_stop(void);
|
||||
|
||||
/* Trusted callers only. principal must be a current password-authenticated
|
||||
* principal. origin is the canonical, already HTTP-policy-validated HTTPS
|
||||
* origin, not an unchecked Host header; this module only binds its digest.
|
||||
* No live eviction. ESP_ERR_NO_MEM means fixed session capacity exhausted.
|
||||
* Raw token is returned only by issue; both outputs must be wiped by caller.
|
||||
* Output buffers must not alias inputs or each other. */
|
||||
esp_err_t web_session_store_issue(
|
||||
const user_principal_t *principal, const char *origin, size_t origin_length,
|
||||
char token[WEB_SESSION_STORE_TOKEN_LENGTH + 1U], web_session_view_t *view);
|
||||
esp_err_t web_session_store_lookup(
|
||||
const char *token, size_t token_length, const char *origin,
|
||||
size_t origin_length, web_session_view_t *view);
|
||||
|
||||
/* Trusted transport identity check, not a replacement for HTTP cookie/origin
|
||||
* authorization. Every successful lookup/check revalidates the principal.
|
||||
* No API result is a lease: recheck at later sensitive boundaries. */
|
||||
esp_err_t web_session_store_is_current(web_session_id_t id, bool *current);
|
||||
/* Also verifies that the transport's copied principal belongs to this ID. */
|
||||
esp_err_t web_session_store_check_principal(web_session_id_t id,
|
||||
const user_principal_t *principal,
|
||||
bool *current);
|
||||
void web_session_store_invalidate(web_session_id_t id);
|
||||
void web_session_store_invalidate_user(uint32_t user_id);
|
||||
/* Command notifications use names, including after account deletion. NULL/0
|
||||
* invalidates all records without disabling the store. */
|
||||
void web_session_store_invalidate_username(const uint8_t *username, size_t length);
|
||||
void web_session_store_prune(void);
|
||||
|
||||
/* Counts only: never token/digest/CSRF/principal material. Expired records are
|
||||
* reclaimed here; stale principals are reclaimed by prune or lookup/check. */
|
||||
esp_err_t web_session_store_get_snapshot(web_session_store_snapshot_t *snapshot);
|
||||
+407
-80
@@ -9,7 +9,7 @@
|
||||
|
||||
#include "web_assets_data.h"
|
||||
|
||||
#define WEB_UI_DOCUMENT_CACHE_CONTROL "private, max-age=300"
|
||||
#define WEB_UI_DOCUMENT_CACHE_CONTROL "no-store"
|
||||
#define WEB_UI_ASSET_CACHE_CONTROL "private, max-age=604800"
|
||||
|
||||
static const char s_index_html[] =
|
||||
@@ -25,6 +25,7 @@ static const char s_index_html[] =
|
||||
"--line:#29364a;--text:#e8eef8;--muted:#91a0b5;--accent:#55c2ff;"
|
||||
"--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n"
|
||||
"*{box-sizing:border-box}\n"
|
||||
"[hidden]{display:none!important}\n"
|
||||
"html,body{height:100%;margin:0;overflow:hidden}\n"
|
||||
"body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);"
|
||||
"color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"
|
||||
@@ -65,6 +66,7 @@ static const char s_index_html[] =
|
||||
"transition:background .15s,border-color .15s,transform .15s}\n"
|
||||
".button:hover:not(:disabled){background:#203652;border-color:#4b789f;transform:translateY(-1px)}\n"
|
||||
".button.primary{background:#126390;border-color:#278abd}\n"
|
||||
".button[aria-pressed=true]{background:#126390;border-color:#278abd}\n"
|
||||
".button.danger{background:#512631;border-color:#81404e}\n"
|
||||
".button:disabled{cursor:not-allowed;opacity:.42}\n"
|
||||
".input-state{margin:0;color:var(--warn);font-size:13px}\n"
|
||||
@@ -74,11 +76,13 @@ static const char s_index_html[] =
|
||||
".terminal-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;"
|
||||
"padding:1px 5px 9px;color:var(--muted);font-size:12px}\n"
|
||||
".terminal-title{color:var(--text);font-weight:750;letter-spacing:.02em}\n"
|
||||
"#terminal{flex:1;min-width:0;min-height:0;border-radius:9px;overflow:hidden;"
|
||||
".terminal-host{flex:1;min-width:0;min-height:0;border-radius:9px;overflow:hidden;"
|
||||
"background:#080c12;padding:8px}\n"
|
||||
"#terminal .xterm{width:100%;height:100%}\n"
|
||||
"#terminal .xterm-viewport{border-radius:7px}\n"
|
||||
"@media(max-width:850px){.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
|
||||
".terminal-host .xterm{width:100%;height:100%}\n"
|
||||
".terminal-host .xterm-viewport{border-radius:7px}\n"
|
||||
".terminal-toolbar{flex-wrap:wrap}.terminal-toolbar .button{min-height:32px;padding:4px 10px}\n"
|
||||
"@media(max-width:850px){html,body{overflow:auto}.page{height:auto;min-height:100dvh;grid-template-rows:auto auto minmax(280px,1fr)}"
|
||||
".terminal-panel{min-height:280px}.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
|
||||
".status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}\n"
|
||||
"@media(max-width:480px){.page{padding:10px;gap:10px}.topbar{align-items:flex-start}"
|
||||
".logo{width:36px;height:36px}.status-grid{padding:10px;gap:8px}"
|
||||
@@ -87,6 +91,34 @@ static const char s_index_html[] =
|
||||
".button:last-child{grid-column:1/-1}.page{grid-template-rows:auto auto minmax(0,1fr)}}\n"
|
||||
"</style>\n"
|
||||
"<link rel=\"icon\" href=\"data:,\">\n"
|
||||
"<script>(() => {\n"
|
||||
"'use strict';\n"
|
||||
"let failed = false, generation = 0, controller = null, timer = null;\n"
|
||||
"window.addEventListener('error', (event) => {\n"
|
||||
" if (!event.target || !['SCRIPT', 'LINK', 'IMG'].includes(event.target.tagName) || failed) return;\n"
|
||||
" failed = true;\n"
|
||||
" const current = generation;\n"
|
||||
" controller = new AbortController();\n"
|
||||
" timer = window.setTimeout(() => controller.abort(), 15000);\n"
|
||||
" fetch('/api/session', {credentials: 'same-origin', mode: 'same-origin', cache: 'no-store', redirect: 'error', signal: controller.signal})\n"
|
||||
" .then((response) => {\n"
|
||||
" if (current !== generation) return;\n"
|
||||
" if (response.status === 401) {\n"
|
||||
" ++generation; controller.abort(); window.clearTimeout(timer);\n"
|
||||
" if (window.sakSessionExpired) window.sakSessionExpired();\n"
|
||||
" else if (!window.sakLoginNavigating) { window.sakLoginNavigating = true; window.location.replace('/login'); }\n"
|
||||
" }\n"
|
||||
" }).catch(() => {}).finally(() => {\n"
|
||||
" if (current === generation) { controller.abort(); window.clearTimeout(timer); }\n"
|
||||
" });\n"
|
||||
"}, true);\n"
|
||||
"window.addEventListener('pagehide', () => {\n"
|
||||
" ++generation;\n"
|
||||
" if (controller) controller.abort();\n"
|
||||
" window.clearTimeout(timer);\n"
|
||||
"});\n"
|
||||
"})();\n"
|
||||
"</script>\n"
|
||||
"<link rel=\"stylesheet\" href=\"/assets/xterm.css\">\n"
|
||||
"<script defer src=\"/assets/xterm.js\"></script>\n"
|
||||
"<script defer src=\"/assets/addon-fit.js\"></script>\n"
|
||||
@@ -120,17 +152,24 @@ static const char s_index_html[] =
|
||||
"<button id=\"request-control\" class=\"button primary\" type=\"button\" disabled>Request control</button>\n"
|
||||
"<button id=\"release-control\" class=\"button danger\" type=\"button\" disabled>Release control</button>\n"
|
||||
"<button id=\"connection-toggle\" class=\"button danger\" type=\"button\">Disconnect</button>\n"
|
||||
"<button id=\"sign-out\" class=\"button\" type=\"button\">Sign out</button>\n"
|
||||
"</div>\n"
|
||||
"<p id=\"session-info\" class=\"connection-detail\" aria-live=\"polite\">Session lasts one hour from sign-in (absolute expiry).</p>\n"
|
||||
"<p id=\"input-state\" class=\"input-state\" data-enabled=\"false\" aria-live=\"polite\">"
|
||||
"Observer mode — terminal input is disabled.</p>\n"
|
||||
"<p id=\"connection-detail\" class=\"connection-detail\" aria-live=\"polite\">"
|
||||
"Requesting a one-time connection ticket…</p>\n"
|
||||
"<p id=\"connection-detail\" class=\"connection-detail\" aria-live=\"polite\">Loading application… If loading fails, <a href=\"/login\">open login</a> or reload this page.</p>\n"
|
||||
"</div>\n"
|
||||
"</section>\n"
|
||||
"<section class=\"panel terminal-panel\" aria-label=\"Serial terminal\">\n"
|
||||
"<div class=\"terminal-toolbar\"><span class=\"terminal-title\">Live serial stream</span>"
|
||||
"<span>Binary, unmodified device output</span></div>\n"
|
||||
"<div id=\"terminal\"></div>\n"
|
||||
"<section class=\"panel terminal-panel\" aria-label=\"Terminal workspace\">\n"
|
||||
"<div class=\"terminal-toolbar\"><span id=\"terminal-title\" class=\"terminal-title\">Live serial stream</span>"
|
||||
"<button id=\"admin-toggle\" class=\"button\" type=\"button\" hidden>Open admin</button>"
|
||||
"<div id=\"terminal-selector\" hidden role=\"group\" aria-label=\"Selected terminal\">"
|
||||
"<button id=\"select-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial</button> "
|
||||
"<button id=\"select-admin\" class=\"button\" type=\"button\" aria-pressed=\"false\">Admin</button></div></div>\n"
|
||||
"<p id=\"admin-detail\" class=\"connection-detail\" aria-live=\"polite\" hidden>Admin closed. Serial stays connected.</p>\n"
|
||||
"<p id=\"output-detail\" class=\"connection-detail\" aria-live=\"polite\">Scrollback: 5000 lines per terminal; oldest lines expire.</p>\n"
|
||||
"<div id=\"terminal\" class=\"terminal-host\"></div>\n"
|
||||
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
|
||||
"</section>\n"
|
||||
"</main>\n"
|
||||
"</body>\n"
|
||||
@@ -152,7 +191,15 @@ static const char s_app_js[] =
|
||||
"const requestControl = element('request-control');\n"
|
||||
"const releaseControl = element('release-control');\n"
|
||||
"const connectionToggle = element('connection-toggle');\n"
|
||||
"const signOut = element('sign-out');\n"
|
||||
"const sessionInfo = element('session-info');\n"
|
||||
"const terminalHost = element('terminal');\n"
|
||||
"const adminHost = element('admin-terminal');\n"
|
||||
"const adminToggle = element('admin-toggle');\n"
|
||||
"const adminDetail = element('admin-detail');\n"
|
||||
"let accountRole = 'user', selected = 'serial';\n"
|
||||
"let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n"
|
||||
"let adminGeneration = 0, adminTimer = null;\n"
|
||||
"const terminal = new Terminal({\n"
|
||||
" allowProposedApi: false, convertEol: false, cursorBlink: true, disableStdin: true,\n"
|
||||
" fontFamily: '\"SFMono-Regular\",Consolas,\"Liberation Mono\",monospace',\n"
|
||||
@@ -167,7 +214,93 @@ static const char s_app_js[] =
|
||||
"terminal.loadAddon(fitAddon);\n"
|
||||
"terminal.open(terminalHost);\n"
|
||||
"const encoder = new TextEncoder();\n"
|
||||
"// Bound xterm's asynchronous write backlog separately from its scrollback.\n"
|
||||
"const output = {serial: {pending: 0, dropped: 0}, admin: {pending: 0, dropped: 0}};\n"
|
||||
"function writeOutput(name, target, data) {\n"
|
||||
" const state = output[name];\n"
|
||||
" if (data.byteLength > 65536 - state.pending) {\n"
|
||||
" state.dropped = Math.min(Number.MAX_SAFE_INTEGER, state.dropped + data.byteLength);\n"
|
||||
" element('output-detail').textContent = `Browser output dropped: serial ${output.serial.dropped} B, admin ${output.admin.dropped} B. Scrollback: 5000 lines each; oldest lines expire.`;\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" state.pending += data.byteLength;\n"
|
||||
" target.write(new Uint8Array(data), () => { state.pending -= data.byteLength; });\n"
|
||||
"}\n"
|
||||
"function closeAdmin(detail = 'Admin closed. Serial connection and lease are unchanged.') {\n"
|
||||
" ++adminGeneration;\n"
|
||||
" if (adminAbort) adminAbort.abort();\n"
|
||||
" adminAbort = null;\n"
|
||||
" window.clearTimeout(adminTimer); adminTimer = null;\n"
|
||||
" if (adminSocket) {\n"
|
||||
" const previous = adminSocket; adminSocket = null;\n"
|
||||
" previous.onopen = previous.onmessage = previous.onerror = previous.onclose = null;\n"
|
||||
" previous.close();\n"
|
||||
" }\n"
|
||||
" adminDetail.textContent = detail;\n"
|
||||
" updateControls();\n"
|
||||
"}\n"
|
||||
"function selectTerminal(mode) {\n"
|
||||
" if (unloading || navigating || loggingOut || !sessionVerified || (mode === 'admin' && accountRole !== 'admin')) return;\n"
|
||||
" selected = mode;\n"
|
||||
" if (mode === 'admin' && !adminTerminal) {\n"
|
||||
" adminTerminal = new Terminal({...terminal.options, disableStdin: true, scrollback: 5000});\n"
|
||||
" adminFit = new FitAddon.FitAddon(); adminTerminal.loadAddon(adminFit); adminTerminal.open(adminHost);\n"
|
||||
" adminTerminal.onData((data) => {\n"
|
||||
" if (selected !== 'admin' || !adminSocket || adminSocket.readyState !== WebSocket.OPEN || unloading || navigating || suspended) return;\n"
|
||||
" const bytes = encoder.encode(data);\n"
|
||||
" if (bytes.length > 4096 || adminSocket.bufferedAmount + bytes.length > 4096) {\n"
|
||||
" closeAdmin('Admin input exceeded the browser buffer limit; shell closed to discard partial input. Open admin to retry.'); return;\n"
|
||||
" }\n"
|
||||
" for (let offset = 0; offset < bytes.length; offset += 512) adminSocket.send(bytes.subarray(offset, offset + 512));\n"
|
||||
" });\n"
|
||||
" }\n"
|
||||
" terminalHost.hidden = mode !== 'serial'; adminHost.hidden = mode !== 'admin';\n"
|
||||
" adminToggle.hidden = adminDetail.hidden = mode !== 'admin';\n"
|
||||
" element('select-serial').setAttribute('aria-pressed', String(mode === 'serial'));\n"
|
||||
" element('select-admin').setAttribute('aria-pressed', String(mode === 'admin'));\n"
|
||||
" element('terminal-title').textContent = mode === 'serial' ? 'Live serial stream' : 'Administration shell';\n"
|
||||
" lastFitWidth = lastFitHeight = 0; updateControls(); scheduleFit();\n"
|
||||
" (mode === 'serial' ? terminal : adminTerminal).focus();\n"
|
||||
"}\n"
|
||||
"async function openAdmin() {\n"
|
||||
" if (accountRole !== 'admin' || selected !== 'admin' || unloading || navigating || loggingOut || suspended || !csrf || adminSocket || adminAbort) return;\n"
|
||||
" const generation = ++adminGeneration, work = workGeneration;\n"
|
||||
" const controller = new AbortController(); adminAbort = controller;\n"
|
||||
" const current = () => generation === adminGeneration;\n"
|
||||
" adminDetail.textContent = 'Opening admin shell...'; updateControls();\n"
|
||||
" try {\n"
|
||||
" const {payload} = await api('/api/admin/ws-ticket', work, {method: 'POST', signal: controller.signal, current});\n"
|
||||
" if (!payload || !/^[0-9a-f]{64}$/.test(payload.ticket)) throw new Error('Invalid ticket');\n"
|
||||
" if (!live(work) || !current()) return;\n"
|
||||
" const url = new URL('/ws/admin', window.location.origin); url.protocol = 'wss:';\n"
|
||||
" url.searchParams.set('ticket', payload.ticket); payload.ticket = '';\n"
|
||||
" const next = new WebSocket(url.toString()); url.search = '';\n"
|
||||
" adminSocket = next; adminAbort = null; next.binaryType = 'arraybuffer';\n"
|
||||
" const active = () => live(work) && current() && adminSocket === next;\n"
|
||||
" adminTimer = window.setTimeout(() => { if (active()) closeAdmin('Admin connection timed out. Open admin to retry.'); }, 15000);\n"
|
||||
" next.onopen = () => {\n"
|
||||
" if (!active()) return;\n"
|
||||
" window.clearTimeout(adminTimer); adminTimer = null;\n"
|
||||
" adminDetail.textContent = 'Admin connected. Input goes only to the selected terminal. Existing web-shell command restrictions apply.'; updateControls();\n"
|
||||
" };\n"
|
||||
" next.onmessage = (event) => {\n"
|
||||
" if (!active()) return;\n"
|
||||
" if (!(event.data instanceof ArrayBuffer)) { closeAdmin('Invalid admin output. Open admin to retry.'); return; }\n"
|
||||
" writeOutput('admin', adminTerminal, event.data);\n"
|
||||
" };\n"
|
||||
" next.onerror = () => { if (active()) closeAdmin('Admin connection failed or capacity unavailable. Open admin to retry.'); };\n"
|
||||
" next.onclose = () => { if (active()) closeAdmin('Admin shell ended. Serial is unchanged. Open admin to reconnect.'); };\n"
|
||||
" } catch (error) {\n"
|
||||
" if (live(work) && current()) closeAdmin(error.status ? error.message + ' Open admin to retry explicitly.' : 'Admin connection failed. Open admin to retry.');\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"let socket = null;\n"
|
||||
"function closeSerial() {\n"
|
||||
" if (!socket) return;\n"
|
||||
" const previous = socket; socket = null;\n"
|
||||
" previous.onopen = previous.onmessage = previous.onerror = previous.onclose = null;\n"
|
||||
" previous.close();\n"
|
||||
"}\n"
|
||||
"let ticketAbort = null;\n"
|
||||
"let reconnectTimer = null;\n"
|
||||
"let reconnectDelay = 1000;\n"
|
||||
@@ -178,10 +311,163 @@ static const char s_app_js[] =
|
||||
"let writerId = 0;\n"
|
||||
"let unloading = false;\n"
|
||||
"let fitFrame = 0;\n"
|
||||
"let fitRetries = 0;\n"
|
||||
"let lastFitWidth = 0;\n"
|
||||
"let lastFitHeight = 0;\n"
|
||||
"let statusInFlight = false;\n"
|
||||
"let statusTimer = null;\n"
|
||||
"let csrf = '';\n"
|
||||
"let sessionIdentity = null, sessionVerified = false;\n"
|
||||
"let sessionDeadline = 0;\n"
|
||||
"let expiryTimer = null;\n"
|
||||
"let navigating = false;\n"
|
||||
"let loggingOut = false;\n"
|
||||
"let suspended = false;\n"
|
||||
"let workGeneration = 0;\n"
|
||||
"let sessionGeneration = 0;\n"
|
||||
"const requests = new Set();\n"
|
||||
"const live = (generation) => generation === workGeneration && !unloading && !navigating;\n"
|
||||
"const cancelWork = () => {\n"
|
||||
" ++workGeneration;\n"
|
||||
" ++connectionGeneration;\n"
|
||||
" if (fitFrame) window.cancelAnimationFrame(fitFrame);\n"
|
||||
" fitFrame = 0; fitRetries = 0;\n"
|
||||
" closeAdmin();\n"
|
||||
" clearReconnectTimer();\n"
|
||||
" for (const controller of requests) controller.abort();\n"
|
||||
" requests.clear();\n"
|
||||
" if (ticketAbort) ticketAbort.abort();\n"
|
||||
" ticketAbort = null;\n"
|
||||
" if (statusTimer !== null) window.clearInterval(statusTimer);\n"
|
||||
" statusTimer = null;\n"
|
||||
" statusInFlight = false;\n"
|
||||
" window.clearTimeout(expiryTimer);\n"
|
||||
" expiryTimer = null;\n"
|
||||
" closeSerial();\n"
|
||||
" clientId = null;\n"
|
||||
" clientIdField.textContent = '—';\n"
|
||||
" setRole('observer');\n"
|
||||
"};\n"
|
||||
"const login = (path = '/login') => {\n"
|
||||
" if (navigating) return;\n"
|
||||
" navigating = true;\n"
|
||||
" csrf = '';\n"
|
||||
" cancelWork();\n"
|
||||
" reconnectEnabled = false;\n"
|
||||
" if (fitFrame) window.cancelAnimationFrame(fitFrame);\n"
|
||||
" if (resizeObserver !== null) resizeObserver.disconnect();\n"
|
||||
" window.removeEventListener('resize', scheduleFit);\n"
|
||||
" updateControls();\n"
|
||||
" if (!window.sakLoginNavigating) { window.sakLoginNavigating = true; window.location.replace(path); }\n"
|
||||
"};\n"
|
||||
"window.sakSessionExpired = login;\n"
|
||||
"if (window.sakLoginNavigating) { navigating = true; reconnectEnabled = false; }\n"
|
||||
"// Keep authentication bodies bounded even if the peer sends chunked data.\n"
|
||||
"async function readJson(response, limit) {\n"
|
||||
" const reader = response.body.getReader();\n"
|
||||
" const bytes = new Uint8Array(limit);\n"
|
||||
" let length = 0;\n"
|
||||
" try {\n"
|
||||
" for (;;) {\n"
|
||||
" const {done, value} = await reader.read();\n"
|
||||
" if (done) break;\n"
|
||||
" if (length + value.length > limit) throw new Error('Invalid device response.');\n"
|
||||
" bytes.set(value, length); length += value.length;\n"
|
||||
" }\n"
|
||||
" return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes.subarray(0, length)));\n"
|
||||
" } finally { await reader.cancel().catch(() => {}); }\n"
|
||||
"}\n"
|
||||
"async function api(path, generation, {method = 'GET', signal, limit = 512, current = () => true} = {}) {\n"
|
||||
" const controller = new AbortController();\n"
|
||||
" const abort = () => controller.abort();\n"
|
||||
" if (signal) { signal.addEventListener('abort', abort, {once: true}); if (signal.aborted) abort(); }\n"
|
||||
" requests.add(controller);\n"
|
||||
" const timeout = window.setTimeout(abort, 15000);\n"
|
||||
" try {\n"
|
||||
/* Non-CORS POST with no-referrer serializes Origin as null in browsers. */
|
||||
" const response = await fetch(path, {method, credentials: 'same-origin', mode: method === 'POST' ? 'cors' : 'same-origin',\n"
|
||||
" cache: 'no-store', redirect: 'error', signal: controller.signal,\n"
|
||||
" ...(method === 'POST' ? {headers: {'X-CSRF-Token': csrf}, body: ''} : {})});\n"
|
||||
" if (!live(generation) || controller.signal.aborted || !current()) throw new Error('Cancelled');\n"
|
||||
" if (response.status === 401) { login(); throw new Error('Session ended.'); }\n"
|
||||
" if (!response.ok) {\n"
|
||||
" const error = new Error(response.status === 403 ? 'Session security check failed. Reload the session and retry explicitly.' :\n"
|
||||
" response.status === 429 || response.status === 503 ? 'Device capacity or backoff limit. Try again later.' : 'Device request failed.');\n"
|
||||
" error.status = response.status;\n"
|
||||
" const retry = response.headers.get('Retry-After');\n"
|
||||
" error.retry = /^[0-9]{1,4}$/.test(retry || '') ? Math.min(3600, Math.max(1, Number(retry))) : 5;\n"
|
||||
" if (response.status === 429 || response.status === 503) error.message += ` Wait ${error.retry} second(s).`;\n"
|
||||
" throw error;\n"
|
||||
" }\n"
|
||||
" const payload = response.status === 204 ? null : await readJson(response, limit);\n"
|
||||
" if (!live(generation) || controller.signal.aborted || !current()) throw new Error('Cancelled');\n"
|
||||
" return {status: response.status, payload};\n"
|
||||
" } finally {\n"
|
||||
" controller.abort(); requests.delete(controller); window.clearTimeout(timeout);\n"
|
||||
" if (signal) signal.removeEventListener('abort', abort);\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"async function loadSession(generation, signal) {\n"
|
||||
" const sequence = ++sessionGeneration;\n"
|
||||
" const current = () => sequence === sessionGeneration;\n"
|
||||
" const {payload} = await api('/api/session', generation, {signal, current});\n"
|
||||
" if (!live(generation) || !current() || (signal && signal.aborted)) return false;\n"
|
||||
" if (!payload || typeof payload.username !== 'string' || !payload.username.length || encoder.encode(payload.username).length > 16 ||\n"
|
||||
" !['user', 'admin'].includes(payload.role) || !/^[0-9a-f]{64}$/.test(payload.csrf) ||\n"
|
||||
" !Number.isInteger(payload.expires_in) || payload.expires_in < 0 || payload.expires_in > 3600) {\n"
|
||||
" throw new Error('Invalid session response. Reload to retry.');\n"
|
||||
" }\n"
|
||||
" // CSRF is stable for a session; never attach a new cookie identity to old scrollback.\n"
|
||||
" if (sessionIdentity && (sessionIdentity.username !== payload.username ||\n"
|
||||
" sessionIdentity.role !== payload.role || sessionIdentity.csrf !== payload.csrf)) {\n"
|
||||
" payload.csrf = ''; sessionIdentity = null; sessionVerified = false;\n"
|
||||
" terminalHost.hidden = adminHost.hidden = true;\n"
|
||||
" login('/'); return false;\n"
|
||||
" }\n"
|
||||
" sessionIdentity = {username: payload.username, role: payload.role, csrf: payload.csrf};\n"
|
||||
" sessionVerified = true;\n"
|
||||
" csrf = payload.csrf; payload.csrf = '';\n"
|
||||
" accountRole = payload.role;\n"
|
||||
" element('terminal-selector').hidden = accountRole !== 'admin';\n"
|
||||
" if (accountRole !== 'admin') { closeAdmin(); selectTerminal('serial'); }\n"
|
||||
" terminalHost.hidden = selected !== 'serial'; adminHost.hidden = selected !== 'admin';\n"
|
||||
" scheduleFit();\n"
|
||||
" const deadline = Date.now() + payload.expires_in * 1000;\n"
|
||||
" sessionDeadline = sessionDeadline ? Math.min(sessionDeadline, deadline) : deadline;\n"
|
||||
" sessionInfo.textContent = `${payload.username} · Session expires at ${new Date(sessionDeadline).toLocaleTimeString()} (one hour absolute; traffic does not extend it).`;\n"
|
||||
" window.clearTimeout(expiryTimer);\n"
|
||||
" if (!loggingOut) expiryTimer = window.setTimeout(() => { if (live(generation)) login(); }, Math.max(0, sessionDeadline - Date.now()));\n"
|
||||
" return true;\n"
|
||||
"}\n"
|
||||
"function startPolling() {\n"
|
||||
" if (statusTimer === null) { pollStatus(); statusTimer = window.setInterval(pollStatus, 5000); }\n"
|
||||
"}\n"
|
||||
"async function logout() {\n"
|
||||
" if (loggingOut || unloading || navigating) return;\n"
|
||||
" loggingOut = true; suspended = true; reconnectEnabled = false;\n"
|
||||
" cancelWork(); updateControls();\n"
|
||||
" const generation = workGeneration;\n"
|
||||
" setConnection('Signing out', 'warn', 'Serial disconnected. Waiting for logout confirmation…');\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation)) return;\n"
|
||||
" const result = await api('/api/logout', generation, {method: 'POST'});\n"
|
||||
" if (live(generation) && result.status === 204) { login(); return; }\n"
|
||||
" throw new Error('Logout was not confirmed.');\n"
|
||||
" } catch (error) {\n"
|
||||
" if (!live(generation)) return;\n"
|
||||
" // Only a lost/network response needs confirmation; never repeat the mutation.\n"
|
||||
" if (!error.status) {\n"
|
||||
" try { await loadSession(generation); } catch (_) {}\n"
|
||||
" }\n"
|
||||
" if (!live(generation)) return;\n"
|
||||
" csrf = '';\n"
|
||||
" setConnection('Sign out not confirmed', 'bad', error.status ? error.message :\n"
|
||||
" 'Network failure: sign out is not confirmed. Retry Sign out, or select Connect to check the session and resume.');\n"
|
||||
" } finally {\n"
|
||||
" if (live(generation)) { loggingOut = false; updateControls(); }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"signOut.addEventListener('click', logout);\n"
|
||||
"const setBadge = (target, text, tone) => {\n"
|
||||
" target.textContent = text;\n"
|
||||
" target.dataset.tone = tone;\n"
|
||||
@@ -191,17 +477,23 @@ static const char s_app_js[] =
|
||||
"const socketOpen = () => socket !== null && socket.readyState === WebSocket.OPEN;\n"
|
||||
"const updateControls = () => {\n"
|
||||
" const writer = role === 'writer';\n"
|
||||
" terminal.options.disableStdin = !writer;\n"
|
||||
" terminal.options.disableStdin = !writer || selected !== 'serial' || !socketOpen();\n"
|
||||
" const adminOpen = adminSocket !== null && adminSocket.readyState === WebSocket.OPEN;\n"
|
||||
" if (adminTerminal) adminTerminal.options.disableStdin = selected !== 'admin' || !adminOpen || suspended || unloading || navigating;\n"
|
||||
" adminToggle.textContent = adminSocket || adminAbort ? 'Close admin' : 'Open admin';\n"
|
||||
" adminToggle.disabled = accountRole !== 'admin' || unloading || navigating || loggingOut || suspended || !csrf;\n"
|
||||
" requestControl.disabled = !socketOpen() || writer;\n"
|
||||
" releaseControl.disabled = !socketOpen() || !writer;\n"
|
||||
" const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n"
|
||||
" connectionToggle.disabled = unloading;\n"
|
||||
" connectionToggle.disabled = unloading || navigating || loggingOut;\n"
|
||||
" signOut.disabled = unloading || navigating || loggingOut;\n"
|
||||
" connectionToggle.textContent = connectionActive ? 'Disconnect' : 'Connect';\n"
|
||||
" connectionToggle.classList.toggle('danger', connectionActive);\n"
|
||||
" inputState.dataset.enabled = writer ? 'true' : 'false';\n"
|
||||
" inputState.textContent = writer\n"
|
||||
" ? 'Writer mode — terminal input is enabled.'\n"
|
||||
" : 'Observer mode — terminal input is disabled.';\n"
|
||||
" if (selected === 'admin') inputState.textContent = `Serial ${writer ? 'writer lease retained' : 'observer'}; serial input disabled while hidden. Admin input ${adminOpen ? 'enabled' : 'disabled'}.`;\n"
|
||||
" setBadge(roleStatus, writer ? 'Writer' : 'Observer', writer ? 'good' : 'warn');\n"
|
||||
"};\n"
|
||||
"const setRole = (nextRole) => {\n"
|
||||
@@ -220,7 +512,7 @@ static const char s_app_js[] =
|
||||
" }\n"
|
||||
"};\n"
|
||||
"const scheduleReconnect = () => {\n"
|
||||
" if (unloading || !reconnectEnabled || reconnectTimer !== null) return;\n"
|
||||
" if (unloading || navigating || suspended || !reconnectEnabled || reconnectTimer !== null) return;\n"
|
||||
" const delay = reconnectDelay;\n"
|
||||
" reconnectDelay = Math.min(reconnectDelay * 2, 10000);\n"
|
||||
" setConnection('Disconnected', 'bad', `Reconnecting in ${Math.ceil(delay / 1000)} second(s)…`);\n"
|
||||
@@ -254,40 +546,33 @@ static const char s_app_js[] =
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" if (event.data instanceof ArrayBuffer) {\n"
|
||||
" terminal.write(new Uint8Array(event.data));\n"
|
||||
" writeOutput('serial', terminal, event.data);\n"
|
||||
" }\n"
|
||||
"};\n"
|
||||
"async function requestTicket(signal) {\n"
|
||||
" const response = await fetch('/api/ws-ticket', {\n"
|
||||
" method: 'POST', credentials: 'same-origin', cache: 'no-store', signal\n"
|
||||
" });\n"
|
||||
" if (!response.ok) throw new Error('ticket request failed');\n"
|
||||
" const payload = await response.json();\n"
|
||||
" if (payload === null || typeof payload !== 'object' ||\n"
|
||||
" typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n"
|
||||
" throw new Error('invalid ticket response');\n"
|
||||
"async function requestTicket(signal, generation) {\n"
|
||||
" const {payload} = await api('/api/ws-ticket', generation, {method: 'POST', signal});\n"
|
||||
" if (!payload || typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n"
|
||||
" throw new Error('Invalid ticket response.');\n"
|
||||
" }\n"
|
||||
" const ticket = payload.ticket;\n"
|
||||
" payload.ticket = '';\n"
|
||||
" return ticket;\n"
|
||||
" const ticket = payload.ticket; payload.ticket = ''; return ticket;\n"
|
||||
"}\n"
|
||||
"async function connect() {\n"
|
||||
" if (unloading || !reconnectEnabled) return;\n"
|
||||
" if (unloading || navigating || loggingOut || suspended || !reconnectEnabled) return;\n"
|
||||
" clearReconnectTimer();\n"
|
||||
" const generation = ++connectionGeneration;\n"
|
||||
" const work = workGeneration;\n"
|
||||
" if (ticketAbort !== null) ticketAbort.abort();\n"
|
||||
" ticketAbort = new AbortController();\n"
|
||||
" if (socket !== null) {\n"
|
||||
" const previous = socket;\n"
|
||||
" socket = null;\n"
|
||||
" previous.close();\n"
|
||||
" }\n"
|
||||
" closeSerial();\n"
|
||||
" clientId = null;\n"
|
||||
" clientIdField.textContent = '—';\n"
|
||||
" setRole('observer');\n"
|
||||
" setConnection('Connecting', 'warn', 'Requesting a one-time connection ticket…');\n"
|
||||
" try {\n"
|
||||
" const ticket = await requestTicket(ticketAbort.signal);\n"
|
||||
" const signal = ticketAbort.signal;\n"
|
||||
" if (!await loadSession(work, signal) || generation !== connectionGeneration) return;\n"
|
||||
" startPolling();\n"
|
||||
" const ticket = await requestTicket(signal, work);\n"
|
||||
" if (unloading || generation !== connectionGeneration) return;\n"
|
||||
" ticketAbort = null;\n"
|
||||
" const url = new URL('/ws/serial', window.location.origin);\n"
|
||||
@@ -297,34 +582,42 @@ static const char s_app_js[] =
|
||||
" url.search = '';\n"
|
||||
" nextSocket.binaryType = 'arraybuffer';\n"
|
||||
" socket = nextSocket;\n"
|
||||
" nextSocket.addEventListener('open', () => {\n"
|
||||
" if (socket !== nextSocket) return;\n"
|
||||
" nextSocket.onopen = () => {\n"
|
||||
" if (!live(work) || generation !== connectionGeneration || socket !== nextSocket) return;\n"
|
||||
" setConnection('Connected', 'good', 'Connected; waiting for broker role information.');\n"
|
||||
" });\n"
|
||||
" nextSocket.addEventListener('message', (event) => {\n"
|
||||
" if (socket === nextSocket) handleSocketMessage(event);\n"
|
||||
" });\n"
|
||||
" nextSocket.addEventListener('error', () => {\n"
|
||||
" if (socket === nextSocket) {\n"
|
||||
" };\n"
|
||||
" nextSocket.onmessage = (event) => {\n"
|
||||
" if (live(work) && generation === connectionGeneration && socket === nextSocket) handleSocketMessage(event);\n"
|
||||
" };\n"
|
||||
" nextSocket.onerror = () => {\n"
|
||||
" if (live(work) && generation === connectionGeneration && socket === nextSocket) {\n"
|
||||
" setConnection('Connection error', 'bad', 'The WebSocket connection failed.');\n"
|
||||
" }\n"
|
||||
" });\n"
|
||||
" nextSocket.addEventListener('close', () => {\n"
|
||||
" if (socket !== nextSocket) return;\n"
|
||||
" socket = null;\n"
|
||||
" };\n"
|
||||
" nextSocket.onclose = () => {\n"
|
||||
" if (!live(work) || generation !== connectionGeneration || socket !== nextSocket) return;\n"
|
||||
" closeSerial();\n"
|
||||
" clientId = null;\n"
|
||||
" clientIdField.textContent = '—';\n"
|
||||
" setRole('observer');\n"
|
||||
" scheduleReconnect();\n"
|
||||
" });\n"
|
||||
" };\n"
|
||||
" } catch (error) {\n"
|
||||
" if (generation !== connectionGeneration || unloading || error.name === 'AbortError') return;\n"
|
||||
" ticketAbort = null;\n"
|
||||
" if (!live(work)) return;\n"
|
||||
" if (error.status === 403 || (error.status && error.status !== 429 && error.status !== 503)) {\n"
|
||||
" reconnectEnabled = false;\n"
|
||||
" setConnection('Request failed', 'bad', error.message + ' Select Connect to reload the session.');\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" if (error.status === 429 || error.status === 503) reconnectDelay = error.retry * 1000;\n"
|
||||
" scheduleReconnect();\n"
|
||||
" if (error.status) connectionDetail.textContent = error.message + ` Retrying in ${error.retry} second(s).`;\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"terminal.onData((data) => {\n"
|
||||
" if (role !== 'writer' || !socketOpen()) return;\n"
|
||||
" if (selected !== 'serial' || role !== 'writer' || !socketOpen() || unloading || navigating || suspended) return;\n"
|
||||
" const bytes = encoder.encode(data);\n"
|
||||
" for (let offset = 0; offset < bytes.length; offset += 1024) {\n"
|
||||
" socket.send(bytes.subarray(offset, Math.min(offset + 1024, bytes.length)));\n"
|
||||
@@ -337,6 +630,8 @@ static const char s_app_js[] =
|
||||
" if (role === 'writer' && socketOpen()) socket.send('release-writer');\n"
|
||||
"});\n"
|
||||
"connectionToggle.addEventListener('click', () => {\n"
|
||||
" if (unloading || navigating || loggingOut) return;\n"
|
||||
" suspended = false;\n"
|
||||
" const connectionActive = reconnectEnabled || socket !== null || ticketAbort !== null || reconnectTimer !== null;\n"
|
||||
" if (!connectionActive) {\n"
|
||||
" reconnectEnabled = true;\n"
|
||||
@@ -348,33 +643,47 @@ static const char s_app_js[] =
|
||||
" ++connectionGeneration;\n"
|
||||
" clearReconnectTimer();\n"
|
||||
" if (ticketAbort !== null) { ticketAbort.abort(); ticketAbort = null; }\n"
|
||||
" if (socket !== null) { const previous = socket; socket = null; previous.close(); }\n"
|
||||
" closeSerial();\n"
|
||||
" clientId = null;\n"
|
||||
" clientIdField.textContent = '—';\n"
|
||||
" setRole('observer');\n"
|
||||
" setConnection('Disconnected', 'warn', 'Disconnected by user. Automatic reconnect is paused.');\n"
|
||||
"});\n"
|
||||
"element('select-serial').addEventListener('click', () => selectTerminal('serial'));\n"
|
||||
"element('select-admin').addEventListener('click', () => selectTerminal('admin'));\n"
|
||||
"adminToggle.addEventListener('click', () => { if (adminSocket || adminAbort) closeAdmin(); else openAdmin(); });\n"
|
||||
"const fitTerminal = () => {\n"
|
||||
" fitFrame = 0;\n"
|
||||
" const bounds = terminalHost.getBoundingClientRect();\n"
|
||||
" if (unloading || navigating || loggingOut || !sessionVerified) return;\n"
|
||||
" const target = selected === 'admin' ? adminTerminal : terminal;\n"
|
||||
" const addon = selected === 'admin' ? adminFit : fitAddon;\n"
|
||||
" const bounds = (selected === 'admin' ? adminHost : terminalHost).getBoundingClientRect();\n"
|
||||
" const width = Math.floor(bounds.width);\n"
|
||||
" const height = Math.floor(bounds.height);\n"
|
||||
" if (width < 1 || height < 1 || (width === lastFitWidth && height === lastFitHeight)) return;\n"
|
||||
" lastFitWidth = width;\n"
|
||||
" lastFitHeight = height;\n"
|
||||
" if (width > 0 && height > 0 && width === lastFitWidth && height === lastFitHeight) return;\n"
|
||||
" try {\n"
|
||||
" const dimensions = fitAddon.proposeDimensions();\n"
|
||||
" if (dimensions && dimensions.cols > 0 && dimensions.rows > 0 &&\n"
|
||||
" (dimensions.cols !== terminal.cols || dimensions.rows !== terminal.rows)) {\n"
|
||||
" terminal.resize(dimensions.cols, dimensions.rows);\n"
|
||||
" const dimensions = width > 0 && height > 0 ? addon.proposeDimensions() : null;\n"
|
||||
" if (dimensions && Number.isInteger(dimensions.cols) && dimensions.cols > 0 &&\n"
|
||||
" Number.isInteger(dimensions.rows) && dimensions.rows > 0) {\n"
|
||||
" if (dimensions.cols !== target.cols || dimensions.rows !== target.rows) target.resize(dimensions.cols, dimensions.rows);\n"
|
||||
" lastFitWidth = width; lastFitHeight = height; return;\n"
|
||||
" }\n"
|
||||
" } catch (_) {}\n"
|
||||
" if (fitRetries > 0) {\n"
|
||||
" --fitRetries;\n"
|
||||
" const generation = workGeneration;\n"
|
||||
" fitFrame = window.requestAnimationFrame(() => { if (live(generation)) fitTerminal(); });\n"
|
||||
" }\n"
|
||||
"};\n"
|
||||
"const scheduleFit = () => {\n"
|
||||
" if (fitFrame === 0) fitFrame = window.requestAnimationFrame(fitTerminal);\n"
|
||||
" if (unloading || navigating || loggingOut) return;\n"
|
||||
" fitRetries = 3;\n"
|
||||
" const generation = workGeneration;\n"
|
||||
" if (fitFrame === 0) fitFrame = window.requestAnimationFrame(() => { if (live(generation)) fitTerminal(); });\n"
|
||||
"};\n"
|
||||
"const resizeObserver = 'ResizeObserver' in window ? new ResizeObserver(scheduleFit) : null;\n"
|
||||
"if (resizeObserver !== null) resizeObserver.observe(terminalHost);\n"
|
||||
"if (resizeObserver !== null) resizeObserver.observe(adminHost);\n"
|
||||
"window.addEventListener('resize', scheduleFit);\n"
|
||||
"const textValue = (value, fallback) => typeof value === 'string' && value.length > 0 ? value : fallback;\n"
|
||||
"const updateStatus = (status) => {\n"
|
||||
@@ -415,38 +724,56 @@ static const char s_app_js[] =
|
||||
" }\n"
|
||||
"};\n"
|
||||
"async function pollStatus() {\n"
|
||||
" if (unloading || statusInFlight) return;\n"
|
||||
" if (unloading || navigating || suspended || !csrf || statusInFlight) return;\n"
|
||||
" const generation = workGeneration;\n"
|
||||
" statusInFlight = true;\n"
|
||||
" try {\n"
|
||||
" const response = await fetch('/api/status', {credentials: 'same-origin', cache: 'no-store'});\n"
|
||||
" if (!response.ok) throw new Error('status request failed');\n"
|
||||
" updateStatus(await response.json());\n"
|
||||
" } catch (_) {\n"
|
||||
" wifiSummary.textContent = 'Unavailable';\n"
|
||||
" serialSummary.textContent = 'Unavailable';\n"
|
||||
" const {payload} = await api('/api/status', generation, {limit: 3072});\n"
|
||||
" if (live(generation)) updateStatus(payload);\n"
|
||||
" } catch (error) {\n"
|
||||
" if (!live(generation)) return;\n"
|
||||
" wifiSummary.textContent = 'Unavailable'; serialSummary.textContent = 'Unavailable';\n"
|
||||
" brokerClientsField.textContent = '—';\n"
|
||||
" } finally {\n"
|
||||
" statusInFlight = false;\n"
|
||||
" }\n"
|
||||
" if (error.status === 403) {\n"
|
||||
" cancelWork(); reconnectEnabled = false; csrf = '';\n"
|
||||
" setConnection('Request failed', 'bad', error.message + ' Select Connect to reload the session.');\n"
|
||||
" } else if (error.status) connectionDetail.textContent = error.message;\n"
|
||||
" } finally { if (live(generation)) statusInFlight = false; }\n"
|
||||
"}\n"
|
||||
"const shutdown = () => {\n"
|
||||
" if (unloading) return;\n"
|
||||
" unloading = true;\n"
|
||||
" ++connectionGeneration;\n"
|
||||
" clearReconnectTimer();\n"
|
||||
" if (statusTimer !== null) window.clearInterval(statusTimer);\n"
|
||||
" cancelWork();\n"
|
||||
" unloading = true; csrf = '';\n"
|
||||
" sessionVerified = false; terminalHost.hidden = adminHost.hidden = true;\n"
|
||||
" if (fitFrame !== 0) window.cancelAnimationFrame(fitFrame);\n"
|
||||
" fitFrame = 0;\n"
|
||||
" if (resizeObserver !== null) resizeObserver.disconnect();\n"
|
||||
" window.removeEventListener('resize', scheduleFit);\n"
|
||||
" if (ticketAbort !== null) ticketAbort.abort();\n"
|
||||
" if (socket !== null) socket.close();\n"
|
||||
" socket = null;\n"
|
||||
" updateControls();\n"
|
||||
"};\n"
|
||||
"window.addEventListener('pagehide', shutdown, {once: true});\n"
|
||||
"window.addEventListener('pagehide', shutdown);\n"
|
||||
"window.addEventListener('pageshow', (event) => {\n"
|
||||
" if (!event.persisted || navigating) return;\n"
|
||||
" unloading = false; loggingOut = false;\n"
|
||||
" if (resizeObserver !== null) resizeObserver.observe(terminalHost);\n"
|
||||
" if (resizeObserver !== null) resizeObserver.observe(adminHost);\n"
|
||||
" window.addEventListener('resize', scheduleFit);\n"
|
||||
" lastFitWidth = lastFitHeight = 0;\n"
|
||||
" scheduleFit(); updateControls();\n"
|
||||
" if (reconnectEnabled && !suspended) connect();\n"
|
||||
" else {\n"
|
||||
" const generation = workGeneration;\n"
|
||||
" const pending = loadSession(generation);\n"
|
||||
" const sequence = sessionGeneration;\n"
|
||||
" pending.then((valid) => {\n"
|
||||
" if (valid && live(generation) && sequence === sessionGeneration && !suspended) startPolling();\n"
|
||||
" }).catch(() => {\n"
|
||||
" if (live(generation) && sequence === sessionGeneration) setConnection('Session check failed', 'bad', 'Reload or select Connect to retry.');\n"
|
||||
" });\n"
|
||||
" }\n"
|
||||
"});\n"
|
||||
"updateControls();\n"
|
||||
"scheduleFit();\n"
|
||||
"pollStatus();\n"
|
||||
"statusTimer = window.setInterval(pollStatus, 5000);\n"
|
||||
"connect();\n"
|
||||
"})();\n";
|
||||
|
||||
@@ -530,8 +857,8 @@ static esp_err_t set_response_headers(httpd_req_t *request,
|
||||
if (result == ESP_OK && response->content_security_policy) {
|
||||
result = httpd_resp_set_hdr(
|
||||
request, "Content-Security-Policy",
|
||||
"default-src 'none'; script-src 'self' 'sha256-5ukY3vEyRwFowsj4k3O4ilN8ezlXoZu1w90PPHg0YVE='; "
|
||||
"script-src-elem 'self' 'sha256-5ukY3vEyRwFowsj4k3O4ilN8ezlXoZu1w90PPHg0YVE='; "
|
||||
"default-src 'none'; script-src 'self' 'sha256-o6St1XqFiWgZZKDDKYP8Y1ROJxvOnf96z55w4i/dC20='; "
|
||||
"script-src-elem 'self' 'sha256-o6St1XqFiWgZZKDDKYP8Y1ROJxvOnf96z55w4i/dC20='; "
|
||||
"style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; "
|
||||
"base-uri 'none'; form-action 'none'; "
|
||||
"frame-ancestors 'none'");
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
|
||||
#define SSH_TRANSPORT_MAX_SESSIONS 2U
|
||||
enum { SSH_TRANSPORT_SESSION_FREE=0, SSH_TRANSPORT_SESSION_ACTIVE=2,
|
||||
SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE=2 };
|
||||
enum { USER_AUTH_METHOD_PASSWORD=0 };
|
||||
typedef struct {
|
||||
uint32_t session_id, generation, broker_client_id;
|
||||
int state, route, socket_fd;
|
||||
uint8_t console_slot_index;
|
||||
bool authenticated, principal_valid, writer, close_requested;
|
||||
size_t rx_length, rx_offset, tx_length, tx_offset;
|
||||
user_principal_t principal;
|
||||
char peer[48];
|
||||
} ssh_slot_t;
|
||||
typedef struct {
|
||||
bool active, tx_pending, rx_pending, authenticated, principal_valid, close_requested;
|
||||
bool writer, admin_command_pending;
|
||||
uint32_t session_id, generation, broker_client_id, admin_output_pending;
|
||||
int state, route, socket_fd, user_role, auth_method;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY+1U], peer[48];
|
||||
} ssh_transport_session_snapshot_t;
|
||||
static ssh_transport_session_snapshot_t s_session_snapshots[2];
|
||||
static user_principal_t s_console_principals[2];
|
||||
static uint8_t s_console_slot_indices[2];
|
||||
static uint32_t s_external_close_id[2];
|
||||
static unsigned stopped, disconnected, rotated, reset, restarted;
|
||||
static esp_err_t ssh_transport_stop(void) { ++stopped; return ESP_OK; }
|
||||
static esp_err_t ssh_transport_disconnect(uint32_t id) { disconnected=id; return ESP_OK; }
|
||||
static esp_err_t ssh_transport_replace_host_key(bool r) { if(r) ++reset; else ++rotated; return ESP_OK; }
|
||||
static void esp_restart(void) { ++restarted; }
|
||||
static void publish_slot(const ssh_slot_t *, size_t);
|
||||
static bool admin_console_drained(const admin_ssh_console_token_t *);
|
||||
static bool admin_console_is_current(const admin_ssh_console_token_t *, const user_principal_t *);
|
||||
static bool consume_external_close(const ssh_slot_t *, size_t);
|
||||
static esp_err_t admin_console_perform(const admin_ssh_console_token_t *, admin_ssh_deferred_action_type_t, uint32_t);
|
||||
static void test_adapter(void)
|
||||
{
|
||||
admin_ssh_console_token_t token={ .slot_index=0, .session_id=7, .slot_generation=3 };
|
||||
user_principal_t admin={ .role=USER_ROLE_ADMIN, .user_id=11, .auth_generation=2,
|
||||
.username_length=5, .username="admin" };
|
||||
assert(admin_ssh_console_init()==ESP_OK);
|
||||
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
|
||||
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
|
||||
assert(!admin_console_drained(&token));
|
||||
s_session_snapshots[0]=(ssh_transport_session_snapshot_t){ .active=true, .session_id=7, .generation=3 };
|
||||
assert(!admin_console_drained(&token)); /* No published console binding. */
|
||||
assert(!admin_console_is_current(&token,&admin));
|
||||
ssh_slot_t active={ .session_id=7, .generation=3, .authenticated=true,
|
||||
.principal_valid=true, .state=SSH_TRANSPORT_SESSION_ACTIVE,
|
||||
.route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE, .principal=admin };
|
||||
publish_slot(&active,0);
|
||||
assert(admin_console_is_current(&token,&admin));
|
||||
/* Production publication carries both console output and principal binding. */
|
||||
assert(s_session_snapshots[0].tx_pending && s_session_snapshots[0].admin_output_pending);
|
||||
assert(!strcmp(s_session_snapshots[0].username,"admin"));
|
||||
uint8_t output[4096]; size_t n;
|
||||
assert(admin_ssh_console_read_output(&token,output,sizeof(output),&n)==ESP_OK && n);
|
||||
publish_slot(&active,0);
|
||||
assert(admin_console_drained(&token));
|
||||
active.state=SSH_TRANSPORT_SESSION_FREE; active.principal_valid=false;
|
||||
publish_slot(&active,0);
|
||||
user_principal_t empty={0};
|
||||
assert(!memcmp(&s_console_principals[0],&empty,sizeof(empty)));
|
||||
assert(!admin_console_is_current(&token,&admin));
|
||||
active.state=SSH_TRANSPORT_SESSION_ACTIVE; active.principal_valid=true;
|
||||
publish_slot(&active,0);
|
||||
assert(admin_console_is_current(&token,&admin));
|
||||
admin.username[0]='A'; assert(!admin_console_is_current(&token,&admin)); admin.username[0]='a';
|
||||
active.route=0; publish_slot(&active,0); assert(!admin_console_is_current(&token,&admin));
|
||||
active.route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
|
||||
active.authenticated=false; publish_slot(&active,0); assert(!admin_console_is_current(&token,&admin));
|
||||
active.authenticated=true; publish_slot(&active,0);
|
||||
s_external_close_id[0]=7; assert(!admin_console_is_current(&token,&admin));
|
||||
ssh_slot_t closing={.session_id=7, .state=SSH_TRANSPORT_SESSION_ACTIVE};
|
||||
assert(consume_external_close(&closing,0) && !s_external_close_id[0]);
|
||||
assert(!admin_console_is_current(&token,&admin));
|
||||
s_session_snapshots[0].close_requested=true;
|
||||
assert(!admin_console_is_current(&token,&admin)); s_session_snapshots[0].close_requested=false;
|
||||
++admin.auth_generation; assert(!admin_console_is_current(&token,&admin)); --admin.auth_generation;
|
||||
++admin.user_id; assert(!admin_console_is_current(&token,&admin)); --admin.user_id;
|
||||
++admin.method; assert(!admin_console_is_current(&token,&admin)); --admin.method;
|
||||
admin.username_length=1; assert(!admin_console_is_current(&token,&admin)); admin.username_length=5;
|
||||
token.transport=1; assert(!admin_console_drained(&token));
|
||||
assert(!admin_console_is_current(&token,&admin));
|
||||
assert(admin_ssh_console_open(&token,&admin)==ESP_ERR_INVALID_ARG);
|
||||
token.transport=0; token.slot_generation=4; assert(!admin_console_drained(&token));
|
||||
assert(!admin_console_is_current(&token,&admin));
|
||||
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND && stopped==0);
|
||||
token.slot_generation=3;
|
||||
/* Same physical session can be assigned the other console slot. */
|
||||
admin_ssh_console_close(&token);
|
||||
token.slot_index=1;
|
||||
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
|
||||
active.console_slot_index=1; publish_slot(&active,0);
|
||||
assert(admin_console_is_current(&token,&admin));
|
||||
assert(s_console_slot_indices[0]==1);
|
||||
admin_ssh_console_token_t wrong=token; wrong.slot_index=0;
|
||||
assert(!admin_console_is_current(&wrong,&admin) && !admin_console_drained(&wrong));
|
||||
assert(admin_console_perform(&wrong,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND);
|
||||
/* A colliding published ID with a stale generation cannot steal the lookup. */
|
||||
s_session_snapshots[1]=s_session_snapshots[0];
|
||||
++s_session_snapshots[1].generation; s_console_slot_indices[1]=0;
|
||||
assert(admin_console_is_current(&token,&admin));
|
||||
assert(admin_ssh_console_read_output(&token,output,sizeof(output),&n)==ESP_OK && n);
|
||||
publish_slot(&active,0);
|
||||
s_session_snapshots[0].tx_pending=true;
|
||||
assert(!admin_console_drained(&token)); s_session_snapshots[0].tx_pending=false;
|
||||
assert(admin_console_perform(&token,ADMIN_CONSOLE_DEFER_SELF_CLOSE,99)==ESP_OK && disconnected==7);
|
||||
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_DISCONNECT,99)==ESP_OK && disconnected==99);
|
||||
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_OK && stopped==1);
|
||||
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_ROTATE,0)==ESP_OK && rotated==1);
|
||||
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_RESET,0)==ESP_OK && reset==1);
|
||||
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_REBOOT,0)==ESP_OK && restarted==1);
|
||||
puts("PASS: actual SSH snapshot/principal publication and wiping, adapter identity/drain checks, legacy admission and lifecycle action routing");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
#include <assert.h>
|
||||
#include <errno.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <setjmp.h>
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
|
||||
ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND };
|
||||
enum { USER_ROLE_USER, USER_ROLE_ADMIN };
|
||||
#define USER_DATABASE_USERNAME_CAPACITY 16U
|
||||
typedef struct {
|
||||
uint32_t user_id, auth_generation;
|
||||
int role, method;
|
||||
size_t username_length;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
||||
} user_principal_t;
|
||||
typedef unsigned TickType_t;
|
||||
typedef void *TaskHandle_t;
|
||||
typedef int portMUX_TYPE;
|
||||
typedef struct { size_t size; unsigned count; unsigned char bytes[2048]; } StaticQueue_t;
|
||||
typedef StaticQueue_t *QueueHandle_t;
|
||||
typedef int StaticSemaphore_t;
|
||||
typedef int *SemaphoreHandle_t;
|
||||
#define portMUX_INITIALIZER_UNLOCKED 0
|
||||
#define pdTRUE 1
|
||||
#define pdPASS 1
|
||||
#define portMAX_DELAY UINT32_MAX
|
||||
#define pdMS_TO_TICKS(x) (x)
|
||||
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
|
||||
static unsigned lock_depth, ticks, runs, actions;
|
||||
static bool principal_current = true, queue_full, owner_drained = true;
|
||||
static TaskHandle_t current_task = (void *)1;
|
||||
static jmp_buf loop_done;
|
||||
static void (*delay_hook)(void), (*prompt_hook)(void), (*completion_hook)(void);
|
||||
static void (*command_hook)(void);
|
||||
#define taskENTER_CRITICAL(p) ((void)(p), ++lock_depth)
|
||||
#define taskEXIT_CRITICAL(p) ((void)(p), --lock_depth)
|
||||
static void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
|
||||
static size_t strlcpy(char *d, const char *s, size_t n) {
|
||||
size_t len = strlen(s); if (n) { size_t k = len < n-1 ? len : n-1;
|
||||
memcpy(d, s, k); d[k] = 0; } return len;
|
||||
}
|
||||
static esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *c)
|
||||
{ (void)p; assert(!lock_depth); *c = principal_current; return ESP_OK; }
|
||||
static const char *esp_err_to_name(int e) { (void)e; return "fake"; }
|
||||
static TaskHandle_t xTaskGetCurrentTaskHandle(void) { return current_task; }
|
||||
static unsigned xTaskGetTickCount(void) { return ticks; }
|
||||
static void vTaskDelay(unsigned n) { assert(!lock_depth); ticks += n; if (delay_hook) delay_hook(); }
|
||||
static int xTaskCreate(void (*f)(void *), const char *n, unsigned s, void *c,
|
||||
unsigned p, TaskHandle_t *t)
|
||||
{ (void)f; (void)n; (void)s; (void)c; (void)p; *t = (void *)1; return pdPASS; }
|
||||
static void vTaskDelete(TaskHandle_t t) { (void)t; }
|
||||
static void xTaskNotifyGive(TaskHandle_t t) { (void)t; }
|
||||
static unsigned ulTaskNotifyTake(int b, unsigned t) { (void)b; (void)t; return 1; }
|
||||
static QueueHandle_t xQueueCreateStatic(unsigned n, size_t s, uint8_t *b, StaticQueue_t *q)
|
||||
{ (void)n; (void)b; q->size = s; return q; }
|
||||
static int xQueueSend(QueueHandle_t q, const void *p, unsigned t)
|
||||
{ (void)t; if (queue_full) return 0; assert(!q->count); memcpy(q->bytes,p,q->size); q->count=1; return 1; }
|
||||
static int xQueueReceive(QueueHandle_t q, void *p, unsigned t)
|
||||
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size); q->count=0; return 1; }
|
||||
static SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *s) { return s; }
|
||||
static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t)
|
||||
{ assert(!lock_depth); if (t && !*s) { ticks+=t; if (prompt_hook) prompt_hook(); }
|
||||
int r=*s; *s=0; return r; }
|
||||
static int xSemaphoreGive(SemaphoreHandle_t s) { *s=1; return 1; }
|
||||
static void linenoiseSetMaxLineLen(unsigned n) { (void)n; }
|
||||
static char *linenoise(const char *p) { (void)p; return NULL; }
|
||||
static int linenoiseHistoryAdd(const char *p) { (void)p; return 1; }
|
||||
static void linenoiseFree(char *p) { (void)p; }
|
||||
static bool console_completion_expand(const char *s, char *d, size_t n)
|
||||
{ (void)s; (void)d; (void)n; if (completion_hook) completion_hook(); return false; }
|
||||
static bool console_completion_format_matches(const char *s, char *d, size_t n, size_t *len)
|
||||
{ (void)s; *len=strlcpy(d,"help\r\n",n); return true; }
|
||||
size_t esp_console_split_argv(char *s, char **v, size_t n);
|
||||
static esp_err_t esp_console_run(const char *s, int *r)
|
||||
{ (void)s; ++runs; if (command_hook) command_hook(); *r=0; return ESP_OK; }
|
||||
typedef struct { const char *command, *help, *hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
|
||||
static int esp_console_cmd_register(const esp_console_cmd_t *c) { (void)c; return 0; }
|
||||
static FILE *funopen(void *c, void *r, int (*w)(void *,const char *,int), void *s, void *f)
|
||||
{ (void)c; (void)r; (void)w; (void)s; (void)f; return tmpfile(); }
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile actual console implementation with deterministic host RTOS/IO fakes.
|
||||
|
||||
No target scheduler, socket library, or hardware execution is claimed.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
|
||||
parser = str(IDF / "components/console/split_argv.c")
|
||||
source = (ROOT / "src/admin_ssh_console.c").read_text()
|
||||
header = (ROOT / "src/admin_ssh_console.h").read_text()
|
||||
def strip_includes(text):
|
||||
return "\n".join(line for line in text.splitlines()
|
||||
if not line.startswith(("#include", "#pragma once")))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="admin-console-boundary-") as directory:
|
||||
path = Path(directory)
|
||||
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
|
||||
+ strip_includes(header) + "\n" + strip_includes(source)
|
||||
+ (ROOT / "tests/admin_console_boundary/test.c").read_text())
|
||||
(path / "test.c").write_text(unit)
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
"-g", str(path / "test.c"), parser,
|
||||
"-o", str(path / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(path / "test")], check=True, timeout=10)
|
||||
ssh = (ROOT / "src/ssh_transport.c").read_text()
|
||||
adapter = ssh[ssh.index("static admin_ssh_console_token_t admin_console_token("):
|
||||
ssh.index("static void *ssh_malloc(")]
|
||||
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
|
||||
+ strip_includes(header) + "\n" + strip_includes(source)
|
||||
+ (ROOT / "tests/admin_console_boundary/adapter.c").read_text()
|
||||
+ adapter + "\nint main(void) { test_adapter(); }\n")
|
||||
(path / "adapter.c").write_text(unit)
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-unused-variable", str(path / "adapter.c"), parser,
|
||||
"-o", str(path / "adapter")], check=True, timeout=30)
|
||||
subprocess.run([str(path / "adapter")], check=True, timeout=10)
|
||||
@@ -0,0 +1,272 @@
|
||||
|
||||
static admin_ssh_console_token_t a = { .slot_index=0, .session_id=7, .slot_generation=1 };
|
||||
static admin_ssh_console_token_t b = { .slot_index=1, .session_id=7, .slot_generation=1, .transport=1 };
|
||||
static user_principal_t admin = { .role=USER_ROLE_ADMIN };
|
||||
static bool live[2] = {true, true};
|
||||
static void (*current_hook)(void);
|
||||
static bool is_current(const admin_ssh_console_token_t *t, const user_principal_t *p)
|
||||
{
|
||||
assert(!lock_depth && p->role==USER_ROLE_ADMIN);
|
||||
if (current_hook) current_hook();
|
||||
return live[t->slot_index];
|
||||
}
|
||||
static bool drained(const admin_ssh_console_token_t *t)
|
||||
{ assert(!lock_depth); assert(t->session_id==7); return owner_drained; }
|
||||
static esp_err_t perform(const admin_ssh_console_token_t *t,
|
||||
admin_ssh_deferred_action_type_t action, uint32_t arg)
|
||||
{ (void)t; (void)arg; assert(!lock_depth); assert(action==ADMIN_CONSOLE_DEFER_SELF_CLOSE); ++actions; return ESP_OK; }
|
||||
static const admin_console_owner_t owner = {
|
||||
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE, .drained=drained, .perform=perform,
|
||||
.is_current=is_current,
|
||||
};
|
||||
static void pump(void (*task)(void *)) { if (!setjmp(loop_done)) task(NULL); }
|
||||
static void feed(const admin_ssh_console_token_t *t, const char *s)
|
||||
{ size_t n=0; assert(admin_ssh_console_feed_input(t,(const uint8_t *)s,strlen(s),&n)); assert(n==strlen(s)); }
|
||||
static void clear_output(const admin_ssh_console_token_t *t)
|
||||
{ uint8_t out[4096]; size_t n; assert(admin_ssh_console_read_output(t,out,sizeof(out),&n)==ESP_OK); }
|
||||
static void competing_completion(void)
|
||||
{
|
||||
size_t n=99;
|
||||
assert(!admin_ssh_console_feed_input(&b,(const uint8_t *)"\t",1,&n));
|
||||
assert(n==0 && s_completion_busy);
|
||||
}
|
||||
static void reopen_during_completion(void)
|
||||
{
|
||||
assert(!lock_depth && s_completion_busy);
|
||||
admin_ssh_console_close(&a);
|
||||
++a.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
clear_output(&a);
|
||||
}
|
||||
static void hidden_reply(void) { feed(&a,"secret\r"); }
|
||||
static void cancel_reply(void) { feed(&a,"secret\x03"); }
|
||||
static void close_prompt(void) { admin_ssh_console_close(&a); }
|
||||
static void close_during_delay(void) { if (ticks>=200) admin_ssh_console_close(&a); }
|
||||
static void close_during_command(void)
|
||||
{
|
||||
assert(s_sessions[0].executing);
|
||||
admin_ssh_console_close(&a);
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
}
|
||||
static void setup_dispatch(void)
|
||||
{ s_dispatch_remote=true; s_dispatch_token=a; s_dispatch_principal=admin;
|
||||
s_sessions[0].executing=true; s_sessions[0].command_pending=true; }
|
||||
static void reopen_during_current(void)
|
||||
{
|
||||
current_hook=NULL;
|
||||
admin_ssh_console_close(&a);
|
||||
++a.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
live[0]=false; /* Failed old validation must not close the replacement. */
|
||||
}
|
||||
static void revoked_reply(void) { hidden_reply(); live[0]=false; }
|
||||
static unsigned checks;
|
||||
static void stale_at_execution(void) { if (++checks==2) live[0]=false; }
|
||||
static void account_revoked_reply(void) { hidden_reply(); principal_current=false; }
|
||||
static void closed_reply(void) { hidden_reply(); close_prompt(); }
|
||||
static unsigned waits;
|
||||
static void unanswered(void)
|
||||
{
|
||||
++waits;
|
||||
if (waits==1) xSemaphoreGive(s_prompt_done); /* Stale wake while still waiting. */
|
||||
if (waits==3) live[0]=false; /* No close notification. */
|
||||
}
|
||||
static void prompt_command(void)
|
||||
{
|
||||
uint8_t answer[32]; size_t n=99;
|
||||
assert(admin_ssh_console_dispatch_read_input("Password: ",answer,sizeof(answer),true,&n)==ESP_ERR_NOT_FOUND);
|
||||
assert(n==0);
|
||||
for (size_t i=0;i<sizeof(answer);++i) assert(!answer[i]);
|
||||
assert(!s_sessions[0].active && !s_sessions[0].prompt_length);
|
||||
for (size_t i=0;i<sizeof(s_sessions[0].prompt_input);++i) assert(!s_sessions[0].prompt_input[i]);
|
||||
}
|
||||
static void test_currentness(void)
|
||||
{
|
||||
++a.slot_generation;
|
||||
admin_console_owner_t missing=owner; missing.is_current=NULL;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&missing)==ESP_ERR_INVALID_ARG);
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
unsigned before=runs;
|
||||
feed(&a,"owner stale\r"); live[0]=false; pump(worker_task);
|
||||
assert(runs==before && !s_sessions[0].active && principal_current);
|
||||
feed(&b,"isolated\r"); pump(worker_task); assert(runs==++before);
|
||||
live[0]=true; ++a.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
feed(&a,"reuse\r"); current_hook=reopen_during_current; pump(worker_task);
|
||||
assert(runs==before && token_matches(&s_sessions[0],&a));
|
||||
live[0]=true;
|
||||
feed(&a,"last check\r"); checks=0; current_hook=stale_at_execution;
|
||||
pump(worker_task); current_hook=NULL;
|
||||
assert(checks==2 && runs==before && !s_sessions[0].active);
|
||||
live[0]=true; ++a.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
void (*hooks[])(void)={revoked_reply,account_revoked_reply,closed_reply,unanswered};
|
||||
for (size_t i=0;i<sizeof(hooks)/sizeof(hooks[0]);++i) {
|
||||
clear_output(&a); ticks=0; waits=0;
|
||||
feed(&a,"prompt\r"); prompt_hook=hooks[i]; command_hook=prompt_command;
|
||||
pump(worker_task); prompt_hook=NULL; command_hook=NULL;
|
||||
assert(runs==++before);
|
||||
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
|
||||
if (i==3) assert(waits==3 && ticks==750);
|
||||
/* Dispatcher recovered, so trusted UART0 work still runs. */
|
||||
admin_request_t uart={.origin=ADMIN_REQUEST_UART0};
|
||||
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==++before);
|
||||
live[0]=true; principal_current=true; ++a.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
}
|
||||
setup_dispatch(); clear_output(&a); live[0]=false;
|
||||
uint8_t answer[32]; size_t n=99;
|
||||
assert(admin_ssh_console_dispatch_read_input("Not published",answer,sizeof(answer),true,&n)==ESP_ERR_NOT_FOUND);
|
||||
assert(!n && !s_sessions[0].output_length);
|
||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); live[0]=true;
|
||||
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
clear_output(&a);
|
||||
s_sessions[0].output_start=4094;
|
||||
assert(worker_write(&a,"abcdef"));
|
||||
uint8_t out[8];
|
||||
assert(admin_ssh_console_read_output(&a,out,3,&n)==ESP_OK && n==3 && !memcmp(out,"abc",3));
|
||||
assert(!s_sessions[0].output[4094] && !s_sessions[0].output[4095] && !s_sessions[0].output[0]);
|
||||
assert(!memcmp(s_sessions[0].output+1,"def",3));
|
||||
assert(admin_ssh_console_read_output(&a,out,sizeof(out),&n)==ESP_OK && n==3 && !memcmp(out,"def",3));
|
||||
for (size_t i=0;i<sizeof(s_sessions[0].output);++i) assert(!s_sessions[0].output[i]);
|
||||
admin_ssh_console_close(&a);
|
||||
puts("PASS: owner stale/account current isolation, callback close/reuse, revoked submitted prompts, periodic unanswered invalidation/stale wake, UART recovery, consumed output wiping");
|
||||
}
|
||||
static void test_shared_admission(void)
|
||||
{
|
||||
admin_ssh_console_token_t web={.slot_index=255, .session_id=7,
|
||||
.slot_generation=42, .transport=ADMIN_CONSOLE_TRANSPORT_WEB};
|
||||
admin_ssh_console_token_t ssh=web; ssh.transport=ADMIN_CONSOLE_TRANSPORT_SSH;
|
||||
static const admin_console_owner_t second_owner={
|
||||
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE,
|
||||
.drained=drained, .perform=perform, .is_current=is_current,
|
||||
};
|
||||
assert(admin_ssh_console_open_available(&web,&admin,&owner)==ESP_OK);
|
||||
assert(web.slot_index==0 && web.session_id==7 && web.slot_generation==42 &&
|
||||
web.transport==ADMIN_CONSOLE_TRANSPORT_WEB);
|
||||
assert(admin_ssh_console_open_available(&ssh,&admin,&second_owner)==ESP_OK);
|
||||
assert(ssh.slot_index==1 && ssh.session_id==7 && ssh.slot_generation==42 && !ssh.transport);
|
||||
assert(s_sessions[0].owner==&owner && s_sessions[1].owner==&second_owner);
|
||||
unsigned before=runs;
|
||||
clear_output(&web);
|
||||
feed(&web,"\"web\" \"stop\"\r"); pump(worker_task);
|
||||
assert(runs==before && !s_control_queue->count);
|
||||
uint8_t diagnostic[512]={0}; size_t received=0;
|
||||
assert(admin_ssh_console_read_output(&web,diagnostic,sizeof(diagnostic)-1,&received)==ESP_OK);
|
||||
assert(strstr((char *)diagnostic,"unavailable from the web console"));
|
||||
feed(&web,"\"user\" \"password\" admin --generate\r"); pump(worker_task);
|
||||
assert(runs==before && !s_control_queue->count);
|
||||
feed(&web,"\"web\" \"status\"\r"); pump(worker_task); assert(runs==before+1);
|
||||
/* UART0 bypasses remote policy and remains the recovery path. */
|
||||
admin_request_t uart={.origin=ADMIN_REQUEST_UART0, .line="user recover --force"};
|
||||
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==before+2);
|
||||
runs=before;
|
||||
admin_ssh_console_token_t full=web; full.slot_index=99;
|
||||
assert(admin_ssh_console_open_available(&full,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
assert(full.slot_index==99);
|
||||
admin_ssh_console_token_t stale=web;
|
||||
s_sessions[0].executing=true;
|
||||
admin_ssh_console_close(&web);
|
||||
assert(admin_ssh_console_open_available(&full,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
assert(full.slot_index==99); /* Inactive executing slots still consume capacity. */
|
||||
s_sessions[0].executing=false;
|
||||
++web.slot_generation;
|
||||
assert(admin_ssh_console_open_available(&web,&admin,&owner)==ESP_OK);
|
||||
admin_ssh_console_close(&stale);
|
||||
assert(!admin_ssh_console_accepts_input(&stale) && admin_ssh_console_accepts_input(&web));
|
||||
assert(admin_ssh_console_accepts_input(&ssh));
|
||||
admin_ssh_console_close(&web); admin_ssh_console_close(&ssh);
|
||||
puts("PASS: two-owner shared admission, colliding preferred indices/IDs, full capacity, executing reservation and stale tokens");
|
||||
}
|
||||
int main(void)
|
||||
{
|
||||
assert(admin_ssh_console_init()==ESP_OK);
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
|
||||
test_shared_admission();
|
||||
principal_current=false;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
principal_current=true;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
assert(admin_ssh_console_open_owned(&b,&admin,&owner)==ESP_OK);
|
||||
admin_ssh_console_token_t other=a; other.transport=1;
|
||||
assert(admin_ssh_console_open_owned(&other,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
admin_ssh_console_close(&other);
|
||||
assert(!admin_ssh_console_accepts_input(&other));
|
||||
other=a; ++other.slot_generation; admin_ssh_console_close(&other);
|
||||
assert(admin_ssh_console_accepts_input(&a));
|
||||
clear_output(&a); clear_output(&b);
|
||||
completion_hook=competing_completion; feed(&a,"\t"); completion_hook=NULL;
|
||||
assert(!s_completion_busy && s_sessions[0].output_length && !s_sessions[1].output_length);
|
||||
/* Keep the in-flight token unchanged; only the reopened session advances. */
|
||||
admin_ssh_console_token_t completing=a;
|
||||
completion_hook=reopen_during_completion; feed(&completing,"\t"); completion_hook=NULL;
|
||||
assert(a.slot_generation==completing.slot_generation+1);
|
||||
assert(!s_completion_busy && !s_sessions[0].output_length && !s_sessions[0].input_length);
|
||||
assert(!admin_ssh_console_accepts_input(&completing));
|
||||
feed(&a,"\t"); assert(s_sessions[0].output_length); clear_output(&a);
|
||||
feed(&a,"help\r"); assert(runs==0); pump(worker_task); assert(runs==1);
|
||||
feed(&a,"\x1b[A"); assert(!strcmp((char *)s_sessions[0].input,"help"));
|
||||
feed(&a,"\x03");
|
||||
feed(&a,"stale\r"); admin_ssh_console_close(&a);
|
||||
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
pump(worker_task); assert(runs==1);
|
||||
feed(&a,"revoked\r"); principal_current=false; pump(worker_task); assert(runs==1); principal_current=true;
|
||||
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
admin_request_t uart={ .origin=ADMIN_REQUEST_UART0 };
|
||||
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==2);
|
||||
feed(&a,"close\r"); command_hook=close_during_command; pump(worker_task); command_hook=NULL;
|
||||
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
|
||||
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
setup_dispatch(); clear_output(&a); prompt_hook=hidden_reply;
|
||||
uint8_t secret[32]; size_t n;
|
||||
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_OK);
|
||||
assert(n==6 && !memcmp(secret,"secret",6));
|
||||
assert(s_sessions[0].output_length==strlen("Password: \r\n"));
|
||||
clear_output(&a);
|
||||
assert(admin_ssh_console_dispatch_read_input("Visible: ",secret,sizeof(secret),false,&n)==ESP_OK);
|
||||
assert(s_sessions[0].output_length==strlen("Visible: secret\r\n"));
|
||||
prompt_hook=cancel_reply;
|
||||
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_ERR_INVALID_STATE);
|
||||
assert(n==0 && secret[0]==0 && s_sessions[0].prompt_input[0]==0);
|
||||
prompt_hook=close_prompt;
|
||||
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_ERR_NOT_FOUND);
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); prompt_hook=NULL;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
setup_dispatch(); clear_output(&a);
|
||||
assert(admin_ssh_console_dispatch_defer(ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_SUPPORTED);
|
||||
assert(!s_sessions[0].deferred_action_pending);
|
||||
queue_full=true;
|
||||
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_ERR_TIMEOUT);
|
||||
assert(!s_sessions[0].deferred_action_pending); queue_full=false;
|
||||
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
|
||||
assert(!admin_ssh_console_feed_input(&a,(const uint8_t *)"x",1,&n) && n==0);
|
||||
s_sessions[0].command_pending=false; owner_drained=false; ticks=0;
|
||||
pump(control_task); assert(ticks==10000 && actions==0);
|
||||
clear_output(&a); owner_drained=true;
|
||||
/* The fake esp_console_run does not invoke registered command callbacks. */
|
||||
assert(command_exit(1,NULL)==0);
|
||||
assert(s_control_queue->count==1);
|
||||
admin_control_request_t exit_request;
|
||||
memcpy(&exit_request,s_control_queue->bytes,sizeof(exit_request));
|
||||
assert(exit_request.action==ADMIN_CONSOLE_DEFER_SELF_CLOSE);
|
||||
assert(token_matches(&s_sessions[0],&exit_request.token));
|
||||
assert(exit_request.owner==&owner && exit_request.argument==a.session_id);
|
||||
ticks=0; pump(control_task); assert(ticks==200 && actions==1);
|
||||
clear_output(&a);
|
||||
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
|
||||
ticks=0; delay_hook=close_during_delay; pump(control_task); delay_hook=NULL; assert(actions==1);
|
||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0]));
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
s_sessions[0].output_length=4096; ticks=0;
|
||||
assert(ssh_output_write(&a,"x",1)==-1 && errno==EAGAIN && ticks==5000);
|
||||
clear_output(&a); s_dispatch_output_previous_cr=false;
|
||||
assert(ssh_output_write(&a,"a\nb\r\n",6)==6);
|
||||
assert(s_sessions[0].output_length==7);
|
||||
admin_ssh_console_close(&a);
|
||||
assert(ssh_output_write(&a,"x",1)==-1 && errno==EPIPE);
|
||||
assert(!lock_depth);
|
||||
test_currentness();
|
||||
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Focused policy test: actual project helper plus installed IDF argv parser.
|
||||
|
||||
Requires Python 3, cc and IDF_PATH (defaults to PlatformIO's installed SDK).
|
||||
Does not run FreeRTOS dispatch, SSH I/O or target hardware.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
|
||||
source = (ROOT / "src/admin_ssh_console.c").read_text()
|
||||
start = source.index("static bool remote_command_allowed(")
|
||||
helper = source[start:source.index("\n}", start) + 2]
|
||||
prelude = r'''
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||
#define ADMIN_SSH_CONSOLE_MAX_ARGUMENTS 10U
|
||||
#define ADMIN_CONSOLE_TRANSPORT_WEB 1U
|
||||
typedef struct {
|
||||
struct { uint8_t transport; } token;
|
||||
char line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
} admin_request_t;
|
||||
size_t esp_console_split_argv(char *, char **, size_t);
|
||||
static void secure_wipe(void *p, size_t n) {
|
||||
volatile unsigned char *bytes = p;
|
||||
while (n--) *bytes++ = 0;
|
||||
}
|
||||
'''
|
||||
cases = r'''
|
||||
int main(void) {
|
||||
const struct { const char *line; bool allowed; } cases[] = {
|
||||
{"", true}, {" ", true}, {" ", true},
|
||||
{"memory", true}, {"user", true}, {"user list", true},
|
||||
{"user show bootstrap", true}, {"exit", true},
|
||||
{"user bootstrap", false}, {"user bootstrap extra", false},
|
||||
{"user recover", false}, {"user recover --force", false},
|
||||
{" user recover --force ", false},
|
||||
{"\"user\" \"bootstrap\"", false},
|
||||
{"\"user\" \"recover\" --force", false},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); ++i) {
|
||||
admin_request_t request = {0};
|
||||
strcpy(request.line, cases[i].line);
|
||||
assert(remote_command_allowed(&request) == cases[i].allowed);
|
||||
assert(!strcmp(request.line, cases[i].line));
|
||||
}
|
||||
const char *web_allowed[] = {
|
||||
"", " ", "help", "memory", "exit", "user", "user status", "user list",
|
||||
"user show admin", "\"user\" \"show\" \"bootstrap\"",
|
||||
"web status", "wifi status", "mdns status", "\"web\" \"status\"",
|
||||
"ssh status", "ssh sessions", "ssh counters", "ssh host-key info", "ssh start",
|
||||
};
|
||||
const char *web_denied[] = {
|
||||
"web", "web help", "web start", "web stop", "web counters", "web clear-counters",
|
||||
"web credentials show", "web credentials rotate --force", "web certificate info",
|
||||
"web certificate rotate --force", "web reset --force", "web status extra",
|
||||
"wifi", "wifi profiles", "wifi scan", "wifi start", "wifi stop", "wifi save",
|
||||
"wifi load", "wifi defaults", "wifi reset", "wifi ping example.org",
|
||||
"mdns", "mdns suffix test", "mdns save", "mdns load", "mdns defaults", "mdns reset",
|
||||
"reboot", "reboot --force", "user bootstrap", "user recover --force",
|
||||
"user add other admin --generate", "user delete other --force",
|
||||
"user role other user --force", "user password admin --generate",
|
||||
"user password other", "user key add admin", "user key clear admin --force",
|
||||
"user key delete admin 0 --force", "user list extra", "user show admin extra",
|
||||
"ssh stop", "ssh disconnect 7", "ssh host-key rotate --force", "ssh reset --force",
|
||||
" \"user\" \"password\" \"admin\" \"--generate\"",
|
||||
"\"web\" \"credentials\" \"show\"", "\"wifi\" \"stop\"",
|
||||
"\"mdns\" \"reset\"", "\"reboot\"", "\"ssh\" \"stop\"",
|
||||
"\"ssh\" \"host-key\" \"rotate\" --force", "\"user\" \"recover\" --force",
|
||||
};
|
||||
for (size_t i=0; i<sizeof(web_allowed)/sizeof(web_allowed[0]); ++i) {
|
||||
admin_request_t request={.token.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
|
||||
strcpy(request.line,web_allowed[i]);
|
||||
assert(remote_command_allowed(&request));
|
||||
assert(!strcmp(request.line,web_allowed[i]));
|
||||
}
|
||||
for (size_t i=0; i<sizeof(web_denied)/sizeof(web_denied[0]); ++i) {
|
||||
admin_request_t request={.token.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
|
||||
strcpy(request.line,web_denied[i]);
|
||||
if (remote_command_allowed(&request)) fprintf(stderr,"Unexpected allow: %s\n",request.line);
|
||||
assert(!remote_command_allowed(&request));
|
||||
assert(!strcmp(request.line,web_denied[i]));
|
||||
request.token.transport=0;
|
||||
/* SSH retains only the global bootstrap/recover dispatcher restriction. */
|
||||
assert(remote_command_allowed(&request) ==
|
||||
(strstr(request.line,"bootstrap")==NULL && strstr(request.line,"recover")==NULL));
|
||||
}
|
||||
puts("PASS: SSH policy unchanged; web read-only exceptions, mutations/lifecycle and quoted forms checked with actual IDF parser");
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix="admin-ssh-policy-") as directory:
|
||||
path = Path(directory)
|
||||
(path / "test.c").write_text(prelude + helper + cases)
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
|
||||
"-o", str(path / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(path / "test")], check=True, timeout=10)
|
||||
@@ -0,0 +1,87 @@
|
||||
# Admin ticket store host checks
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
python3 tests/web_admin_tickets/run.py
|
||||
python3 tests/web_admin_tickets/run.py --sanitize
|
||||
```
|
||||
|
||||
Requires a C11 `cc`, Python 3, OpenSSL development headers/libcrypto, and (for
|
||||
`--sanitize`) ASan/UBSan runtimes. No firmware build, network, generated assets,
|
||||
or persistent build output. The runner reuses the session-store harness's tiny
|
||||
platform header fakes. `test.c` includes the **unmodified production C**, using
|
||||
real project principal/session declarations and OpenSSL SHA-256. Inclusion gives
|
||||
white-box access for wipe, saturation and exhaustion assertions without adding
|
||||
production test hooks. RNG, time and session validation are deterministic fakes;
|
||||
all external calls assert that the ticket critical section is not held.
|
||||
|
||||
## Exact test groups
|
||||
|
||||
1. Stopped/start/idempotent-start lifecycle; 64 hex output; SHA-256 digest-only
|
||||
storage; success, replay denial and full record wipe.
|
||||
2. Two-ticket capacity and no live eviction; exact counters; nested competing
|
||||
issuance takes the last slot and the losing output is wiped.
|
||||
3. Issue rejects user role, public-key method, mismatched generation, zero ID,
|
||||
NULL principal/output, stale sessions and session-check errors.
|
||||
4. Consume burns matches before denying wrong session, user role, public-key
|
||||
method, generation, stale/check-error, zero ID or NULL principal; also rejects
|
||||
a different session with the *same* account principal.
|
||||
5. Empty/NULL/short/long/nonhex input; uppercase hex consumes the same secret.
|
||||
6. Success one microsecond before expiry; rejection at expiry; stale reclaim on
|
||||
issue/snapshot; snapshot expiry cleanup; signed deadline overflow rejection.
|
||||
7. Revocation ID precedence, exact username length/name and global scope;
|
||||
revocation never invalidates the fake sessions.
|
||||
8. RNG/SHA failures, failed output wipe, consume SHA failure leaves the
|
||||
unidentifiable ticket intact, duplicate live RNG/digest rejection.
|
||||
9. Issuance RNG/SHA hooks exercise stop/restart, global and nonmatching revoke,
|
||||
and session invalidation; currentness hook exercises stop/restart.
|
||||
10. Consume SHA/postcheck hooks exercise stop/restart, global/nonmatching revoke,
|
||||
stale sessions and expiry; nested competing consumes admit exactly once.
|
||||
11. Prune check races replacement with the same ID, digest and deadline; the
|
||||
non-reused record generation protects the replacement from stale cleanup.
|
||||
12. Nonwrapping epoch and record generation exhaustion, permanent lifecycle
|
||||
failure at exhaustion, saturated counters, NULL/count-only snapshots and
|
||||
host structure sizes.
|
||||
|
||||
## Contract and limits
|
||||
|
||||
The public API is in `src/web_admin_tickets.h`. This module is inert until wired
|
||||
by a later integration increment. It adds no routes, session invalidation,
|
||||
transport, task, socket, queue, timer or heap allocation. Callers must authorize
|
||||
HTTP cookie/Origin/CSRF, invalidate the authoritative session store **before**
|
||||
calling revoke, wipe successful token outputs and recheck currentness at later
|
||||
sensitive boundaries. A successful consume is not an authorization lease.
|
||||
|
||||
Two tickets, 32 RNG bytes each, 64 hex characters plus NUL, absolute 30-second
|
||||
lifetime. Only SHA-256 of decoded secret bytes is retained with copied principal,
|
||||
session ID, deadline and unique generation. Both hex cases are accepted. Live
|
||||
digest collisions fail rather than creating ambiguous tickets. No retry loop
|
||||
or live eviction. Pruning checks at most two copied records per invocation.
|
||||
Every revoke advances the epoch even if no record matches, conservatively
|
||||
cancelling unrelated in-flight issue/consume work. Start is idempotent while
|
||||
ready. Stop/start never resets counters, epoch or record generation.
|
||||
|
||||
`issued` counts published tickets, `consumed` counts burned matches (including
|
||||
subsequently denied admissions), `rejected` counts failed issue/consume calls;
|
||||
`capacity_rejections` is a subset of rejected. All counters saturate at UINT32_MAX.
|
||||
Snapshot prunes expired/stale records and exports counts, readiness and storage
|
||||
size only. A capacity failure is ESP_ERR_NO_MEM; malformed input INVALID_ARG;
|
||||
unauthorized/stale/lifecycle-raced work INVALID_STATE; no live consume match
|
||||
NOT_FOUND; SHA failure ESP_FAIL; RNG errors propagate. Failed issue wipes all 65
|
||||
output bytes when output is non-NULL. Output must not alias inputs.
|
||||
|
||||
Host measured sizes: ticket 104 B, two-ticket state 248 B, fake lock 4 B, snapshot
|
||||
40 B; snapshot `storage_bytes` = 252 B. Estimated 32-bit target sizes: ticket
|
||||
96 B, state 232 B, plus the target portMUX (typically 8 B), roughly **240 B static
|
||||
RAM**. These are estimates, not target linker measurements. Issue plus nested
|
||||
prune has 240 B of explicit ticket/random local payload on this host (about
|
||||
224 B on a 32-bit target), excluding scalar/compiler frames and session/RNG/SHA
|
||||
call stacks; caller also owns a 65 B token. No measured target stack/flash delta.
|
||||
|
||||
Hooks test deterministic interleavings, not true multicore scheduling or IDF
|
||||
portMUX semantics. They do not validate the real DRBG, mbedTLS, session database,
|
||||
HTTP admission, hardware, or full Phase 8D.5 integration. Post-check account
|
||||
changes without notification are subject to the same no-lease boundary as the
|
||||
session API. Combined hardware validation remains pending; no firmware build
|
||||
or device operation is part of this increment.
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile production ticket C with deterministic boundary fakes; no firmware build."""
|
||||
import os
|
||||
import pathlib
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
os.environ["CCACHE_DISABLE"] = "1"
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
HEADERS = runpy.run_path(str(HERE.parent / "web_session_store/run.py"))["HEADERS"]
|
||||
with tempfile.TemporaryDirectory(prefix="web-admin-tickets-") as directory:
|
||||
tmp = pathlib.Path(directory)
|
||||
for name, text in HEADERS.items():
|
||||
path = tmp / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
sanitize = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] if "--sanitize" in sys.argv else []
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
|
||||
*sanitize, "-I" + str(tmp), "-I" + str(ROOT / "src"),
|
||||
str(HERE / "test.c"), "-lcrypto", "-o", str(tmp / "test")],
|
||||
check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||
@@ -0,0 +1,285 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include <assert.h>
|
||||
#include <ctype.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <openssl/sha.h>
|
||||
/* Include unmodified production C to inspect wipes, ABA and exhaustion without
|
||||
* adding firmware-only test hooks. Platform/user/session headers remain real. */
|
||||
#include "web_admin_tickets.c"
|
||||
|
||||
int host_lock_depth;
|
||||
static int64_t clock_us;
|
||||
static unsigned random_sequence;
|
||||
static bool rng_fail, sha_fail, session_fail, live[4];
|
||||
static user_principal_t principals[4];
|
||||
static void (*rng_hook)(void), (*sha_hook)(void), (*check_hook)(void);
|
||||
static unsigned check_calls, hook_at;
|
||||
static unsigned tests;
|
||||
|
||||
int64_t esp_timer_get_time(void) { assert(!host_lock_depth); return clock_us; }
|
||||
void secure_wipe(void *p, size_t n)
|
||||
{
|
||||
volatile unsigned char *v = p;
|
||||
while (n--) *v++ = 0;
|
||||
}
|
||||
static void fire(void (**hook)(void))
|
||||
{
|
||||
void (*call)(void) = *hook;
|
||||
*hook = NULL;
|
||||
if (call) call();
|
||||
}
|
||||
esp_err_t secure_random_fill(void *p, size_t n)
|
||||
{
|
||||
assert(!host_lock_depth && n == 32);
|
||||
memset(p, ++random_sequence, n);
|
||||
fire(&rng_hook);
|
||||
return rng_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
int mbedtls_sha256(const unsigned char *p, size_t n, unsigned char *out, int mode)
|
||||
{
|
||||
assert(!host_lock_depth && n == 32 && mode == 0);
|
||||
assert(SHA256(p, n, out));
|
||||
fire(&sha_hook);
|
||||
return sha_fail ? -1 : 0;
|
||||
}
|
||||
esp_err_t web_session_store_check_principal(web_session_id_t id,
|
||||
const user_principal_t *p, bool *valid)
|
||||
{
|
||||
assert(!host_lock_depth);
|
||||
++check_calls;
|
||||
*valid = id < 4 && live[id] && same_principal(&principals[id], p);
|
||||
if (check_calls == hook_at) fire(&check_hook);
|
||||
/* Like the real session resolver, recheck liveness before returning;
|
||||
* never upgrade an already-failed check after a slot replacement. */
|
||||
*valid = *valid && id < 4 && live[id] && same_principal(&principals[id], p);
|
||||
return session_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
static void zero(const void *p, size_t n)
|
||||
{
|
||||
const unsigned char *v = p;
|
||||
while (n--) assert(*v++ == 0);
|
||||
}
|
||||
static void reset(void)
|
||||
{
|
||||
/* Test isolation only; production never resets these generations. */
|
||||
memset(&s_state, 0, sizeof(s_state));
|
||||
clock_us = 100;
|
||||
random_sequence = 0;
|
||||
rng_fail = sha_fail = session_fail = false;
|
||||
rng_hook = sha_hook = check_hook = NULL;
|
||||
check_calls = hook_at = 0;
|
||||
for (unsigned i = 1; i < 4; ++i) {
|
||||
live[i] = true;
|
||||
principals[i] = (user_principal_t) {
|
||||
.user_id = i, .auth_generation = 1, .role = USER_ROLE_ADMIN,
|
||||
.method = USER_AUTH_METHOD_PASSWORD, .username_length = 1,
|
||||
.username = {(char)('a' + i - 1), 0},
|
||||
};
|
||||
}
|
||||
web_admin_tickets_start();
|
||||
}
|
||||
static void passed(const char *name) { ++tests; printf("PASS %s\n", name); }
|
||||
static void issue(unsigned id, char *token)
|
||||
{
|
||||
assert(web_admin_tickets_issue(id, &principals[id], token) == ESP_OK);
|
||||
}
|
||||
static void restart(void) { web_admin_tickets_stop(); web_admin_tickets_start(); }
|
||||
static void revoke_all(void) { web_admin_tickets_revoke(0, NULL, 0); }
|
||||
static void revoke_other(void) { web_admin_tickets_revoke(99, NULL, 0); }
|
||||
static void stale(void) { live[1] = false; }
|
||||
static void expire(void) { clock_us += WEB_ADMIN_TICKET_LIFETIME_US; }
|
||||
static void fail_issue(void)
|
||||
{
|
||||
char token[65];
|
||||
memset(token, 'x', sizeof(token));
|
||||
assert(web_admin_tickets_issue(1, &principals[1], token) != ESP_OK);
|
||||
zero(token, sizeof(token));
|
||||
zero(s_state.tickets, sizeof(s_state.tickets));
|
||||
}
|
||||
static char replacement[65];
|
||||
static void replace_stale(void)
|
||||
{
|
||||
revoke_all();
|
||||
live[1] = true;
|
||||
random_sequence = 0; /* Same digest, ID and deadline: only generation differs. */
|
||||
issue(1, replacement);
|
||||
}
|
||||
static char nested_token[65];
|
||||
static esp_err_t nested_result;
|
||||
static void nested_issue(void)
|
||||
{
|
||||
issue(2, nested_token);
|
||||
}
|
||||
static void nested_consume(void)
|
||||
{
|
||||
nested_result = web_admin_tickets_consume(nested_token, 1, &principals[1]);
|
||||
}
|
||||
int main(void)
|
||||
{
|
||||
char a[65], b[65], c[65];
|
||||
web_admin_tickets_snapshot_t snap;
|
||||
reset();
|
||||
web_admin_tickets_stop(); fail_issue();
|
||||
web_admin_tickets_start(); issue(1, a);
|
||||
uint64_t epoch = s_state.epoch;
|
||||
web_admin_tickets_start(); assert(s_state.epoch == epoch);
|
||||
assert(strlen(a) == 64);
|
||||
for (unsigned i = 0; i < 64; ++i) assert(isxdigit((unsigned char)a[i]));
|
||||
uint8_t raw[32]; memset(raw, 1, sizeof(raw));
|
||||
uint8_t expected[32]; assert(SHA256(raw, sizeof(raw), expected));
|
||||
assert(equal_digest(s_state.tickets[1].digest, expected));
|
||||
assert(memcmp(s_state.tickets[1].digest, raw, 32));
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
|
||||
zero(s_state.tickets, sizeof(s_state.tickets));
|
||||
passed("lifecycle, hex/digest storage, single use and wipe");
|
||||
|
||||
reset(); issue(1, a); issue(2, b);
|
||||
assert(web_admin_tickets_issue(3, &principals[3], c) == ESP_ERR_NO_MEM);
|
||||
zero(c, sizeof(c)); web_admin_tickets_get_snapshot(&snap);
|
||||
assert(snap.active == 2 && snap.issued == 2 && snap.rejected == 1 &&
|
||||
snap.capacity_rejections == 1 && snap.ready);
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
|
||||
assert(web_admin_tickets_consume(b, 2, &principals[2]) == ESP_OK);
|
||||
reset(); issue(3, c); rng_hook = nested_issue;
|
||||
assert(web_admin_tickets_issue(1, &principals[1], a) == ESP_ERR_NO_MEM);
|
||||
zero(a, sizeof(a));
|
||||
assert(web_admin_tickets_consume(nested_token, 2, &principals[2]) == ESP_OK);
|
||||
assert(web_admin_tickets_consume(c, 3, &principals[3]) == ESP_OK);
|
||||
passed("capacity rejects without live eviction, competing issue and exact counters");
|
||||
|
||||
reset(); principals[1].role = USER_ROLE_USER; fail_issue();
|
||||
principals[1].role = USER_ROLE_ADMIN;
|
||||
principals[1].method = USER_AUTH_METHOD_SSH_PUBLIC_KEY; fail_issue();
|
||||
principals[1].method = USER_AUTH_METHOD_PASSWORD;
|
||||
user_principal_t bad = principals[1]; bad.auth_generation++;
|
||||
assert(web_admin_tickets_issue(1, &bad, a) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_admin_tickets_issue(0, &principals[1], a) == ESP_ERR_INVALID_ARG);
|
||||
assert(web_admin_tickets_issue(1, NULL, a) == ESP_ERR_INVALID_ARG);
|
||||
assert(web_admin_tickets_issue(1, &principals[1], NULL) == ESP_ERR_INVALID_ARG);
|
||||
live[1] = false; fail_issue(); live[1] = true;
|
||||
session_fail = true; fail_issue();
|
||||
passed("issue role, password, session/principal binding and errors");
|
||||
|
||||
for (unsigned mode = 0; mode < 8; ++mode) {
|
||||
reset(); issue(1, a); bad = principals[1];
|
||||
unsigned id = 1;
|
||||
if (mode == 0) id = 2;
|
||||
if (mode == 1) bad.role = USER_ROLE_USER;
|
||||
if (mode == 2) bad.method = USER_AUTH_METHOD_SSH_PUBLIC_KEY;
|
||||
if (mode == 3) bad.auth_generation++;
|
||||
if (mode == 4) live[1] = false;
|
||||
if (mode == 5) session_fail = true;
|
||||
if (mode == 6) id = 0;
|
||||
assert(web_admin_tickets_consume(a, id, mode == 7 ? NULL : &bad) == ESP_ERR_INVALID_STATE);
|
||||
zero(s_state.tickets, sizeof(s_state.tickets));
|
||||
assert(s_state.consumed == 1);
|
||||
}
|
||||
reset(); principals[2] = principals[1]; issue(1, a);
|
||||
assert(web_admin_tickets_consume(a, 2, &principals[2]) == ESP_ERR_INVALID_STATE);
|
||||
zero(s_state.tickets, sizeof(s_state.tickets));
|
||||
passed("consume burns before wrong identity/role/currentness results, same-account session binding");
|
||||
|
||||
reset(); random_sequence = 170; issue(1, a);
|
||||
strcpy(b, a); b[63] = 0;
|
||||
assert(web_admin_tickets_consume(b, 1, &principals[1]) == ESP_ERR_INVALID_ARG);
|
||||
char long_token[66]; memcpy(long_token, a, 64); long_token[64] = 'a'; long_token[65] = 0;
|
||||
assert(web_admin_tickets_consume(long_token, 1, &principals[1]) == ESP_ERR_INVALID_ARG);
|
||||
assert(web_admin_tickets_consume("", 1, &principals[1]) == ESP_ERR_INVALID_ARG);
|
||||
assert(web_admin_tickets_consume(NULL, 1, &principals[1]) == ESP_ERR_INVALID_ARG);
|
||||
strcpy(b, a); b[30] = 'g';
|
||||
assert(web_admin_tickets_consume(b, 1, &principals[1]) == ESP_ERR_INVALID_ARG);
|
||||
for (unsigned i = 0; i < 64; ++i) a[i] = (char)toupper((unsigned char)a[i]);
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
|
||||
passed("exact bounded hex validation and uppercase equivalence");
|
||||
|
||||
reset(); issue(1, a); clock_us += WEB_ADMIN_TICKET_LIFETIME_US - 1;
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
|
||||
issue(1, a); expire();
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
|
||||
issue(1, a); issue(2, b); live[1] = false; issue(3, c);
|
||||
web_admin_tickets_get_snapshot(&snap); assert(snap.active == 2);
|
||||
live[2] = false; web_admin_tickets_get_snapshot(&snap); assert(snap.active == 1);
|
||||
expire(); web_admin_tickets_get_snapshot(&snap); assert(snap.active == 0);
|
||||
clock_us = INT64_MAX - WEB_ADMIN_TICKET_LIFETIME_US + 1;
|
||||
fail_issue();
|
||||
passed("absolute expiry boundary, stale cleanup and time overflow");
|
||||
|
||||
reset(); issue(1, a); issue(2, b);
|
||||
web_admin_tickets_revoke(1, (const uint8_t *)"b", 1);
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
|
||||
assert(web_admin_tickets_consume(b, 2, &principals[2]) == ESP_OK);
|
||||
issue(1, a); issue(2, b);
|
||||
web_admin_tickets_revoke(0, (const uint8_t *)"a", 0);
|
||||
web_admin_tickets_get_snapshot(&snap); assert(snap.active == 2);
|
||||
web_admin_tickets_revoke(0, (const uint8_t *)"a", 1);
|
||||
web_admin_tickets_get_snapshot(&snap); assert(snap.active == 1);
|
||||
revoke_all(); zero(s_state.tickets, sizeof(s_state.tickets));
|
||||
assert(live[1] && live[2]);
|
||||
passed("revoke ID precedence, exact username, all; no session invalidation");
|
||||
|
||||
reset(); rng_fail = true; fail_issue();
|
||||
reset(); sha_fail = true; fail_issue();
|
||||
reset(); issue(1, a); sha_fail = true;
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_FAIL);
|
||||
sha_fail = false;
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
|
||||
reset(); issue(1, a); random_sequence = 0;
|
||||
assert(web_admin_tickets_issue(2, &principals[2], b) == ESP_FAIL);
|
||||
zero(b, sizeof(b));
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
|
||||
passed("RNG/SHA failure, output wipe and live digest collision rejection");
|
||||
|
||||
void (*actions[])(void) = {restart, revoke_all, revoke_other, stale};
|
||||
for (unsigned i = 0; i < sizeof(actions) / sizeof(actions[0]); ++i) {
|
||||
reset(); rng_hook = actions[i]; fail_issue();
|
||||
reset(); sha_hook = actions[i]; fail_issue();
|
||||
}
|
||||
reset(); hook_at = 1; check_hook = restart; fail_issue();
|
||||
passed("issue stop/restart, revoke and stale races across RNG/SHA/currentness");
|
||||
|
||||
for (unsigned i = 0; i < sizeof(actions) / sizeof(actions[0]); ++i) {
|
||||
reset(); issue(1, a); sha_hook = actions[i];
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) != ESP_OK);
|
||||
reset(); issue(1, a); hook_at = check_calls + 2; check_hook = actions[i];
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) != ESP_OK);
|
||||
assert(s_state.consumed == 1);
|
||||
}
|
||||
reset(); issue(1, a); sha_hook = expire;
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
|
||||
reset(); issue(1, a); hook_at = check_calls + 2; check_hook = expire;
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_ERR_INVALID_STATE);
|
||||
reset(); issue(1, nested_token); sha_hook = nested_consume;
|
||||
assert(web_admin_tickets_consume(nested_token, 1, &principals[1]) == ESP_ERR_NOT_FOUND);
|
||||
assert(nested_result == ESP_OK && s_state.consumed == 1);
|
||||
passed("consume crypto/postcheck lifecycle/expiry races and competing consume");
|
||||
|
||||
reset(); issue(1, a); live[1] = false;
|
||||
hook_at = check_calls + 1; check_hook = replace_stale;
|
||||
web_admin_tickets_get_snapshot(&snap); assert(snap.active == 1);
|
||||
assert(web_admin_tickets_consume(replacement, 1, &principals[1]) == ESP_OK);
|
||||
passed("stale-prune slot replacement ABA");
|
||||
|
||||
reset(); issue(1, a); s_state.epoch = UINT64_MAX - 1;
|
||||
revoke_all(); web_admin_tickets_start();
|
||||
assert(s_state.epoch == UINT64_MAX && !s_state.ready); fail_issue();
|
||||
restart(); assert(s_state.epoch == UINT64_MAX && !s_state.ready);
|
||||
reset(); s_state.generation = UINT64_MAX - 1; issue(1, a);
|
||||
assert(web_admin_tickets_issue(1, &principals[1], b) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
|
||||
restart(); assert(!s_state.ready);
|
||||
reset(); s_state.issued = s_state.consumed = s_state.rejected = UINT32_MAX;
|
||||
s_state.capacity_rejections = UINT32_MAX;
|
||||
issue(1, a); issue(2, b);
|
||||
assert(web_admin_tickets_issue(3, &principals[3], c) == ESP_ERR_NO_MEM);
|
||||
assert(web_admin_tickets_consume(a, 1, &principals[1]) == ESP_OK);
|
||||
web_admin_tickets_get_snapshot(&snap);
|
||||
assert(snap.issued == UINT32_MAX && snap.consumed == UINT32_MAX &&
|
||||
snap.rejected == UINT32_MAX && snap.capacity_rejections == UINT32_MAX);
|
||||
web_admin_tickets_get_snapshot(NULL);
|
||||
printf("Host sizes: ticket=%zu state=%zu lock=%zu snapshot=%zu bytes\n",
|
||||
sizeof(ticket_t), sizeof(s_state), sizeof(s_lock), sizeof(snap));
|
||||
passed("nonwrapping epoch/generation, saturating counters, count-only snapshot");
|
||||
printf("%u test groups passed\n", tests);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
# Admin WebSocket transport host harness
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
python3 tests/web_admin_transport/run.py
|
||||
python3 tests/web_admin_transport/run.py --strict
|
||||
python3 tests/web_admin_transport/run.py --tickets
|
||||
python3 tests/web_admin_transport/run.py --sanitize
|
||||
```
|
||||
|
||||
`CC` selects the compiler. The runner compiles the current production
|
||||
`src/web_admin_transport.c` and production public headers into a temporary C11
|
||||
translation unit with `-Wall -Wextra -Werror`. Only include directives are removed;
|
||||
transport functions are not copied or reimplemented. Temporary output is removed.
|
||||
`platform.h` supplies host types; `fakes.h` doubles dependencies; `test.c` exercises
|
||||
production entry points and inspects private state for lifecycle/wipe assertions.
|
||||
No firmware build, network access or device operation is performed.
|
||||
|
||||
## Results recorded 2026-09-06
|
||||
|
||||
Final continuation: `run.py --tickets` passes **19 transport / 12 ticket groups**,
|
||||
including the HTTPD-owned shutdown retry/reuse regression. `server_lifecycle.py`
|
||||
passes **11 groups** against extracted production server lifecycle/URI tables.
|
||||
`python3 tests/web_cookie_auth/run.py --admin` now links the real cookie policy,
|
||||
session store, tickets, private adapter and admin transport for endpoint admission,
|
||||
pre-101 rejection and logout/expiry/currentness cleanup checks; console and runtime
|
||||
IO remain doubled. These supersede the older counts/integration-pending notes
|
||||
below. Final admin closure uses direct HTTPD-owned `shutdown`, not queued IDF
|
||||
session-close work. Parent reports the sequential final firmware build after this
|
||||
fix passed in **23.55 s**, at **95,580 B RAM / 1,637,273 B flash**, and the final
|
||||
independent security integration review found no actionable findings. See
|
||||
`docs/phase8d5_implementation.md` for build history and the pending target procedure.
|
||||
|
||||
After the production empty-frame, input-deadline and timer-generation fixes:
|
||||
|
||||
- `python3 tests/web_admin_transport/run.py`: **18 groups passed**.
|
||||
- `python3 tests/web_admin_transport/run.py --strict`: **18 groups passed**.
|
||||
- All assertions are mandatory by default. `--strict` is retained as a
|
||||
compatibility flag with identical behavior; there are no expected-defect probes
|
||||
or failure exemptions.
|
||||
- Earlier, before these regression additions, `--tickets` also ran the separate
|
||||
production ticket suite: **12 groups passed**. It was not rerun in this update.
|
||||
This is a separate suite, not transport plus real-ticket integration.
|
||||
- The earlier `--sanitize --tickets` attempt was blocked at linking by missing
|
||||
`/usr/lib64/libasan.so.8.0.0` and `/usr/lib64/libubsan.so.1.0.0`.
|
||||
Sanitizers were not rerun in this update; no sanitizer pass is claimed.
|
||||
|
||||
## Meaningful coverage
|
||||
|
||||
- PSRAM-only allocation flags, allocation/timer-create failure cleanup, retry,
|
||||
idempotent initialization and duplicate attachment rejection.
|
||||
- Authentication-helper delegation, role rejection, ticket response/capacity,
|
||||
exact upgrade URI shape, ticket failure and ticket consumption before capacity
|
||||
rejection. Shared console admission precedes 101; fake console assigns index 1
|
||||
to verify that the returned shared-slot token is retained.
|
||||
- One admin socket without replacement; failed upgrade and revocation during
|
||||
console admission release reservations and console state.
|
||||
- At most one outstanding transport poll; byte-preserving input, partial input
|
||||
consumption/retry, consumed-input wiping, output delivery and TX wiping.
|
||||
- Nonfinal/text/oversized frames and another frame while RX is occupied fail
|
||||
closed; stalled input closes after the five-second deadline. Pending bytes are
|
||||
not fed at or after the deadline even if the console can now consume them.
|
||||
- Session/account notification isolation, idle currentness failure, invalidation
|
||||
during currentness checking and between output consumption and send. Notifier
|
||||
paths close the console and flag the slot without socket operations.
|
||||
- Send/queue failure paths, close-trigger suppression after success, deferred
|
||||
action support checks and the output-send drain guard. A queue-submission hook
|
||||
frees and re-admits the HTTPD slot before returning failure: the replacement
|
||||
generation remains unflagged/live and its next poll delivers output.
|
||||
- Detach disables acceptance and new timer submissions. A deterministic hook
|
||||
enters detach during submission, exercises its timeout, then verifies retry.
|
||||
Queued work after detach does no IO. Successful-stop simulation discards pending
|
||||
work and frees HTTPD context before `stopped` retires the queue marker/re-attach.
|
||||
- Disconnect wipes payload and retires console state; replacement generations
|
||||
reject a previous owner token. Dependency fakes assert external calls occur
|
||||
outside the transport critical section and socket/input/output operations occur
|
||||
in the simulated HTTPD owner context.
|
||||
|
||||
## Empty-frame regression
|
||||
|
||||
IDF 5.5's `httpd_ws_recv_frame` uses `frame->len == 0` as its header-parsing
|
||||
sentinel. Calling it twice on an empty frame would parse a second header. The
|
||||
production transport now skips the payload receive for zero-length frames.
|
||||
The mandatory regression asserts one header parse, no input/close side effect,
|
||||
and successful feeding of a following nonempty frame.
|
||||
|
||||
The fake models the sentinel checked in the installed IDF 5.5
|
||||
`components/esp_http_server/src/httpd_ws.c`; it counts parses rather than
|
||||
emulating socket timeout or wire desynchronization. No production source was
|
||||
edited for this regression update.
|
||||
|
||||
## Limits / remaining integration and target work
|
||||
|
||||
This is deterministic single-threaded execution, not a concurrency proof. Locks
|
||||
are assertions and races are selected reentrant hooks; FreeRTOS scheduling,
|
||||
esp_timer scheduling, stack bounds, allocation placement on hardware, and memory
|
||||
floors are not measured. Payload byte counts use host ABI metadata sizes; 512-byte
|
||||
RX plus 1024-byte TX are not the entire allocated struct size.
|
||||
|
||||
Cookie/Origin/CSRF parsing, real session expiry/principal storage, ticket crypto,
|
||||
shared-console allocator/dispatcher/prompts/policy and SSH are doubled here.
|
||||
Their implementation correctness is not established by this harness. In
|
||||
particular it does not prove simultaneous use of both real shared console slots.
|
||||
The independent ticket suite is optional via `--tickets`.
|
||||
|
||||
HTTPD request/context/upgrade/send/close and queue operations are fakes. Close
|
||||
triggering is recorded, not queued as IDF's real session-close work. Queue delivery
|
||||
loss, socket-slot reuse, TLS partial reads/writes, ping/pong/control-frame handling,
|
||||
actual HTTPD stop completion and on-wire pre-101 responses require real-IDF or
|
||||
target validation. The empty-frame sentinel is modeled from source, not linked
|
||||
from IDF. Failed `httpd_ssl_stop` orchestration is the integrating server's duty;
|
||||
this harness only calls `stopped` after simulated successful shutdown.
|
||||
|
||||
Server route registration, six-socket non-eviction policy, revocation hook order,
|
||||
status aggregation, full-client coexistence, serial writer/USB isolation and
|
||||
whole-8D.5 target acceptance remain main integration/target work. No production
|
||||
source is changed by this harness; passing normal mode does not close Phase 8D.5.
|
||||
|
||||
## Temporary authenticated device smoke client
|
||||
|
||||
`client.py` is a local Python-standard-library-only tool, not shipped firmware,
|
||||
UI, or a new endpoint. **Running it contacts the specified device and consumes a
|
||||
login attempt/session and, for an administrator, an admin console slot.** Only run
|
||||
against a device you are authorized to test. It never uploads, builds or erases.
|
||||
|
||||
```sh
|
||||
# System TLS trust; certificate hostname must match the explicit HTTPS origin.
|
||||
python3 tests/web_admin_transport/client.py --url https://device.local
|
||||
|
||||
# Trust a locally obtained PEM CA/device certificate; hostname is still verified.
|
||||
python3 tests/web_admin_transport/client.py --url https://device.local --cafile device-cert.pem --smoke
|
||||
|
||||
# Explicit isolated/local-test opt-in ONLY: warns and disables TLS verification.
|
||||
python3 tests/web_admin_transport/client.py --url https://device.local --insecure --max-runtime 60 --timeout 10
|
||||
```
|
||||
|
||||
Replace the example hostname with your device's certificate-matching hostname.
|
||||
Only HTTPS origins on port 443 are accepted: no URL credentials, application
|
||||
paths, queries or fragments. Host and Origin are derived from that validated
|
||||
origin; redirects and environment proxies are not followed. `--insecure` does
|
||||
**not** enforce private-address routing: it is an explicit operator opt-in, not
|
||||
proof that the destination is local. Prefer `--cafile`, with its certificate
|
||||
obtained through a trusted channel; insecure mode exposes credentials to active
|
||||
network interception.
|
||||
|
||||
Username is requested with `input`, password with non-echoing `getpass`. Password
|
||||
entry fails rather than falling back to echoed input. No credential arguments,
|
||||
cookie files or HTTP debug logs are used. Cookies (including HttpOnly) are kept
|
||||
in an in-memory CookieJar and copied into the WebSocket request header. Routine
|
||||
results never print cookies, CSRF, passwords, tickets, ticket URLs or exception
|
||||
representations. Python immutable strings cannot be reliably erased from memory;
|
||||
this is not protection against process inspection, swap or core dumps.
|
||||
|
||||
The default and only mode is bounded smoke (`--smoke` is optional):
|
||||
|
||||
1. GET `/api/login-challenge` with `X-Login-Bootstrap: 1`, then JSON username/password
|
||||
POST `/api/login` with challenge CSRF, then GET `/api/session` for session CSRF.
|
||||
All requests include the matching Origin.
|
||||
2. For role `user`, require HTTP 403 from the CSRF-protected admin ticket POST.
|
||||
3. For role `admin`, mint a ticket, authenticate `/ws/admin` with the cookie and
|
||||
ticket, validate the 101 handshake, then require 403 when replaying that ticket
|
||||
with the same live cookie. Run binary `help`, an empty binary frame, empty Enter,
|
||||
and `exit`, waiting for prompts/closure rather than sending commands in a burst.
|
||||
4. Close the client socket and attempt CSRF-protected logout in `finally`; require
|
||||
a subsequent session request to return 401. Cleanup failure is reported and
|
||||
makes the command fail. If connectivity or authentication-response delivery
|
||||
fails, server-side cleanup cannot be guaranteed; a session may remain until
|
||||
its absolute expiry. There are no automatic login retries.
|
||||
|
||||
Console bytes are **deliberately printed directly to stdout**, including terminal
|
||||
control sequences. Use a trusted device and do not capture output into routine
|
||||
logs if console commands may disclose sensitive information. Authentication
|
||||
metadata and rejected-response bodies are not printed. Smoke uses no mutating
|
||||
administration command other than closing its own console/login session.
|
||||
|
||||
This version intentionally has no interactive/raw-terminal mode, so it does not
|
||||
change terminal settings or exercise completion/hidden prompts. It requires
|
||||
POSIX interval timers for a hard runtime guard: default 60 seconds, configurable
|
||||
up to 300, starting after credential entry, plus up to 10 seconds for cleanup.
|
||||
Individual transport timeout defaults to 10 seconds (maximum 30). HTTP/upgrade
|
||||
headers or response bodies, frames, and per-command output have bounded sizes.
|
||||
The WS parser accepts only final, unmasked, bounded binary/control frames and
|
||||
masks all client frames; it is not a general-purpose WebSocket implementation.
|
||||
|
||||
**Local validation (2026-09-06):** syntax compiled in memory, and offline in-memory
|
||||
checks passed for valid/rejected URLs, masked client frame encoding, bounded
|
||||
server frame rejection, both-role smoke/replay/command sequencing, HttpOnly
|
||||
CookieJar header forwarding and logout cleanup sequencing. These checks were run
|
||||
without adding test files or opening sockets. No device/network command, TLS
|
||||
handshake, browser test, interactive test or hardware validation was performed.
|
||||
The client is temporary test tooling; its implementation and these local checks
|
||||
do not establish whole-8D.5 acceptance.
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Temporary stdlib-only Phase 8D.5 smoke client; no device validation implied."""
|
||||
import argparse
|
||||
import base64
|
||||
import getpass
|
||||
import hashlib
|
||||
import http.client
|
||||
import http.cookiejar
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import ssl
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import warnings
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
class Failure(Exception):
|
||||
"""Only fixed, secret-free diagnostics may be supplied here."""
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise Failure(message)
|
||||
|
||||
|
||||
def origin_url(value):
|
||||
try:
|
||||
u = urlsplit(value)
|
||||
require(u.scheme == 'https' and u.hostname and not u.username and
|
||||
not u.password and u.port in (None, 443) and
|
||||
u.path in ('', '/') and not u.query and not u.fragment,
|
||||
'URL must be an HTTPS origin on port 443 without credentials/query.')
|
||||
host = u.hostname.encode('idna').decode('ascii').lower()
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
authority = '[' + host + ']' if address.version == 6 else host
|
||||
except ValueError:
|
||||
require(len(host) <= 253 and all(re.fullmatch(
|
||||
r'[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?', label)
|
||||
for label in host.split('.')), 'Invalid hostname.')
|
||||
authority = host
|
||||
require(len(authority) + 8 <= 128, 'Origin exceeds firmware limit.')
|
||||
return host, authority, 'https://' + authority
|
||||
except (ValueError, UnicodeError):
|
||||
raise Failure('Invalid HTTPS origin.') from None
|
||||
|
||||
|
||||
def secret_field(body, field):
|
||||
value = body.get(field)
|
||||
require(isinstance(value, str) and re.fullmatch(r'[0-9a-fA-F]{64}', value),
|
||||
'Missing or malformed authentication field.')
|
||||
return value
|
||||
|
||||
|
||||
class Client:
|
||||
def __init__(self, args):
|
||||
self.host, self.authority, self.origin = origin_url(args.url)
|
||||
self.context = ssl.create_default_context(cafile=args.cafile)
|
||||
if args.insecure:
|
||||
print('WARNING: LOCAL TEST ONLY: TLS certificate/hostname verification DISABLED.',
|
||||
file=sys.stderr)
|
||||
self.context.check_hostname = False
|
||||
self.context.verify_mode = ssl.CERT_NONE
|
||||
self.jar = http.cookiejar.CookieJar()
|
||||
self.timeout = args.timeout
|
||||
self.deadline = time.monotonic() + args.max_runtime
|
||||
self.csrf = None
|
||||
self.ws = None
|
||||
|
||||
def budget(self):
|
||||
left = self.deadline - time.monotonic()
|
||||
require(left > 0, 'Maximum runtime exceeded.')
|
||||
return min(self.timeout, left)
|
||||
|
||||
def cookie_request(self, path):
|
||||
request = urllib.request.Request(self.origin + path)
|
||||
self.jar.add_cookie_header(request) # Secure/HttpOnly cookies stay in memory.
|
||||
return request
|
||||
|
||||
def api(self, path, method='GET', body=None, headers=None, expected=200):
|
||||
request = self.cookie_request(path)
|
||||
fields = {'Origin': self.origin, 'Connection': 'close'}
|
||||
fields.update(headers or {})
|
||||
cookie = request.get_header('Cookie')
|
||||
if cookie:
|
||||
fields['Cookie'] = cookie
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
if data is not None:
|
||||
fields['Content-Type'] = 'application/json'
|
||||
conn = http.client.HTTPSConnection(self.host, 443, timeout=self.budget(),
|
||||
context=self.context)
|
||||
try:
|
||||
conn.request(method, path, body=data, headers=fields)
|
||||
response = conn.getresponse() # No redirects or proxy/environment routing.
|
||||
self.jar.extract_cookies(response, request)
|
||||
require(response.status == expected, 'Unexpected HTTP status: %d.' % response.status)
|
||||
payload = response.read(4097)
|
||||
require(len(payload) <= 4096, 'HTTP response exceeds bound.')
|
||||
result = json.loads(payload) if payload else {}
|
||||
require(isinstance(result, dict), 'Expected JSON object.')
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def upgrade(self, ticket, expected=101):
|
||||
path = '/ws/admin?ticket=' + ticket
|
||||
cookie = self.cookie_request(path).get_header('Cookie')
|
||||
require(cookie is not None, 'Session cookie unavailable for upgrade.')
|
||||
key = base64.b64encode(os.urandom(16)).decode('ascii')
|
||||
raw = socket.create_connection((self.host, 443), self.budget())
|
||||
sock = None
|
||||
try:
|
||||
sock = self.context.wrap_socket(raw, server_hostname=self.host)
|
||||
sock.settimeout(self.budget())
|
||||
message = ('GET %s HTTP/1.1\r\nHost: %s\r\nOrigin: %s\r\n'
|
||||
'Upgrade: websocket\r\nConnection: Upgrade\r\n'
|
||||
'Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: %s\r\n'
|
||||
'Cookie: %s\r\n\r\n') % (path, self.authority, self.origin, key, cookie)
|
||||
sock.sendall(message.encode('ascii'))
|
||||
header = bytearray()
|
||||
while not header.endswith(b'\r\n\r\n'):
|
||||
require(len(header) < 4096, 'Upgrade headers exceed bound.')
|
||||
sock.settimeout(self.budget())
|
||||
byte = sock.recv(1) # Do not consume an immediately following WS frame.
|
||||
require(byte, 'Connection ended during upgrade.')
|
||||
header.extend(byte)
|
||||
lines = bytes(header).decode('ascii').split('\r\n')
|
||||
parts = lines[0].split(' ', 2)
|
||||
require(len(parts) >= 2 and parts[0] == 'HTTP/1.1' and parts[1].isdigit(),
|
||||
'Malformed upgrade response.')
|
||||
require(int(parts[1]) == expected, 'Unexpected upgrade status: %d.' % int(parts[1]))
|
||||
if expected != 101:
|
||||
return None
|
||||
fields = {}
|
||||
for line in lines[1:-2]:
|
||||
name, separator, value = line.partition(':')
|
||||
name = name.lower()
|
||||
require(separator and name not in fields, 'Ambiguous upgrade headers.')
|
||||
fields[name] = value.strip()
|
||||
accept = base64.b64encode(hashlib.sha1((key +
|
||||
'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest()).decode()
|
||||
require(fields.get('sec-websocket-accept') == accept and
|
||||
fields.get('upgrade', '').lower() == 'websocket' and
|
||||
'upgrade' in [v.strip() for v in fields.get('connection', '').lower().split(',')] and
|
||||
'sec-websocket-extensions' not in fields and
|
||||
'sec-websocket-protocol' not in fields, 'Invalid WebSocket handshake.')
|
||||
result, sock = sock, None
|
||||
return result
|
||||
finally:
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
elif expected != 101:
|
||||
raw.close()
|
||||
if sock is None and raw.fileno() != -1:
|
||||
raw.close()
|
||||
|
||||
def send(self, payload, opcode=2):
|
||||
require(len(payload) <= (125 if opcode >= 8 else 512), 'Client frame exceeds bound.')
|
||||
mask = os.urandom(4)
|
||||
length = len(payload)
|
||||
header = bytes([0x80 | opcode, 0x80 | (length if length < 126 else 126)])
|
||||
if length >= 126:
|
||||
header += struct.pack('!H', length)
|
||||
self.ws.settimeout(self.budget())
|
||||
self.ws.sendall(header + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload)))
|
||||
|
||||
def exact(self, count):
|
||||
data = bytearray()
|
||||
while len(data) < count:
|
||||
self.ws.settimeout(self.budget())
|
||||
chunk = self.ws.recv(count - len(data))
|
||||
if not chunk:
|
||||
raise EOFError
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
|
||||
def frame(self):
|
||||
first, second = self.exact(2)
|
||||
opcode, length = first & 15, second & 127
|
||||
require(first & 0x80 and not first & 0x70 and not second & 0x80 and
|
||||
opcode in (2, 8, 9, 10), 'Unsupported server frame.')
|
||||
require(length != 127 and (opcode < 8 or length <= 125), 'Server frame exceeds bound.')
|
||||
if length == 126:
|
||||
length = struct.unpack('!H', self.exact(2))[0]
|
||||
require(length >= 126, 'Noncanonical frame length.')
|
||||
require(length <= 1024 and not (opcode == 8 and length == 1), 'Invalid server frame length.')
|
||||
return opcode, self.exact(length)
|
||||
|
||||
def drain(self, closing=False):
|
||||
recent = bytearray()
|
||||
total = 0
|
||||
while True:
|
||||
try:
|
||||
opcode, payload = self.frame()
|
||||
except EOFError:
|
||||
require(closing, 'WebSocket closed before command prompt.')
|
||||
return
|
||||
if opcode == 8:
|
||||
require(closing, 'WebSocket closed before command prompt.')
|
||||
return
|
||||
if opcode == 9:
|
||||
self.send(payload, 10)
|
||||
if opcode != 2:
|
||||
continue
|
||||
total += len(payload)
|
||||
require(total <= 65536, 'Console output exceeds smoke bound.')
|
||||
sys.stdout.buffer.write(payload) # Deliberate console output, never auth metadata.
|
||||
sys.stdout.buffer.flush()
|
||||
recent.extend(payload)
|
||||
del recent[:-128]
|
||||
if not closing and recent.endswith(b'admin@serial-tool> '):
|
||||
return
|
||||
|
||||
def smoke(self, username, password):
|
||||
challenge = self.api('/api/login-challenge', headers={'X-Login-Bootstrap': '1'})
|
||||
self.api('/api/login', 'POST', {'username': username, 'password': password},
|
||||
{'X-CSRF-Token': secret_field(challenge, 'csrf')})
|
||||
session = self.api('/api/session')
|
||||
self.csrf = secret_field(session, 'csrf')
|
||||
require(session.get('role') in ('user', 'admin'), 'Unexpected session role.')
|
||||
if session['role'] == 'user':
|
||||
self.api('/api/admin/ws-ticket', 'POST', headers={'X-CSRF-Token': self.csrf}, expected=403)
|
||||
print('PASS: user ticket request rejected (403).')
|
||||
return
|
||||
ticket = secret_field(self.api('/api/admin/ws-ticket', 'POST',
|
||||
headers={'X-CSRF-Token': self.csrf}), 'ticket')
|
||||
self.ws = self.upgrade(ticket)
|
||||
self.upgrade(ticket, expected=403) # Live cookie + consumed ticket: reject before capacity.
|
||||
self.drain()
|
||||
for command in (b'help\r', b'', b'\r'):
|
||||
self.send(command)
|
||||
if command:
|
||||
self.drain()
|
||||
self.send(b'exit\r')
|
||||
self.drain(closing=True)
|
||||
print('\nPASS: admin upgrade/replay, help, empty frame/Enter and exit smoke.')
|
||||
|
||||
def cleanup(self):
|
||||
if self.ws is not None:
|
||||
self.ws.close()
|
||||
self.deadline = time.monotonic() + 10
|
||||
try:
|
||||
if any(cookie.name == '__Host-sak-session' for cookie in self.jar):
|
||||
if self.csrf is None:
|
||||
self.csrf = secret_field(self.api('/api/session'), 'csrf')
|
||||
self.api('/api/logout', 'POST', headers={'X-CSRF-Token': self.csrf}, expected=204)
|
||||
self.api('/api/session', expected=401)
|
||||
print('PASS: logout and unauthenticated session check.')
|
||||
finally:
|
||||
self.jar.clear()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--url', required=True, help='HTTPS origin, port 443 only')
|
||||
trust = parser.add_mutually_exclusive_group()
|
||||
trust.add_argument('--cafile', help='trusted PEM CA/device certificate; hostname must match')
|
||||
trust.add_argument('--insecure', action='store_true', help='LOCAL TEST ONLY: disable TLS verification')
|
||||
parser.add_argument('--smoke', action='store_true', help='bounded smoke (default; only mode)')
|
||||
parser.add_argument('--timeout', type=float, default=10)
|
||||
parser.add_argument('--max-runtime', type=float, default=60)
|
||||
args = parser.parse_args()
|
||||
client = None
|
||||
result = 0
|
||||
try:
|
||||
require(hasattr(signal, 'setitimer'), 'This bounded client requires POSIX interval timers.')
|
||||
require(0 < args.timeout <= 30 and 0 < args.max_runtime <= 300, 'Invalid timeout/runtime bounds.')
|
||||
def expired(signum, frame):
|
||||
raise Failure('Maximum runtime exceeded.')
|
||||
signal.signal(signal.SIGALRM, expired)
|
||||
client = Client(args)
|
||||
username = input('Username: ')
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('error', getpass.GetPassWarning)
|
||||
password = getpass.getpass('Password: ')
|
||||
client.deadline = time.monotonic() + args.max_runtime
|
||||
signal.setitimer(signal.ITIMER_REAL, args.max_runtime)
|
||||
try:
|
||||
client.smoke(username, password)
|
||||
finally:
|
||||
password = None # Python cannot guarantee erasure of immutable strings.
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
print('FAIL: ' + (str(error) if isinstance(error, Failure) else
|
||||
'Operation failed; details suppressed to protect credentials/tickets.'), file=sys.stderr)
|
||||
result = 1
|
||||
finally:
|
||||
if client is not None and hasattr(signal, 'setitimer'):
|
||||
signal.setitimer(signal.ITIMER_REAL, 0)
|
||||
try:
|
||||
signal.setitimer(signal.ITIMER_REAL, 10)
|
||||
client.cleanup()
|
||||
except (Exception, KeyboardInterrupt):
|
||||
print('WARNING: logout cleanup unconfirmed; session may remain until expiry.', file=sys.stderr)
|
||||
result = 1
|
||||
finally:
|
||||
signal.setitimer(signal.ITIMER_REAL, 0)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,136 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Deterministic dependencies; production transport is included after this file. */
|
||||
static int server_storage;
|
||||
#define SERVER ((void *)&server_storage)
|
||||
static bool httpd_owner, alloc_fail, timer_fail, auth_allowed, session_current;
|
||||
static bool console_live, console_full, queue_fail, send_fail, upgrade_fail;
|
||||
static bool ticket_live, upgrade_requested, revoke_on_open, revoke_on_send;
|
||||
static unsigned upgrades, closes, sends, queues, wipes, checks, receive_headers;
|
||||
static size_t feed_limit, fed_length, output_length;
|
||||
static uint8_t fed[2048], output[1024], sent[1024];
|
||||
static size_t sent_length;
|
||||
static char status[64], response_body[256];
|
||||
static web_session_view_t auth_view;
|
||||
static admin_ssh_console_token_t console_token;
|
||||
static const admin_console_owner_t *console_owner;
|
||||
static httpd_req_t *live_request;
|
||||
static httpd_ws_frame_t incoming;
|
||||
static void (*queued_work)(void *);
|
||||
static void *queued_argument;
|
||||
static void (*check_hook)(void);
|
||||
static void (*queue_hook)(void);
|
||||
static void (*timer_callback)(void *);
|
||||
static void io(void) { OUTSIDE(); assert(httpd_owner); }
|
||||
static void *heap_caps_calloc(size_t n, size_t size, unsigned caps) {
|
||||
OUTSIDE(); assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
|
||||
return alloc_fail ? NULL : calloc(n, size);
|
||||
}
|
||||
static void heap_caps_free(void *p) { OUTSIDE(); free(p); }
|
||||
static esp_err_t esp_timer_create(const esp_timer_create_args_t *a, esp_timer_handle_t *t) {
|
||||
OUTSIDE(); assert(a->skip_unhandled_events); timer_callback = a->callback;
|
||||
if (timer_fail) return ESP_FAIL;
|
||||
*t = &server_storage; return ESP_OK;
|
||||
}
|
||||
static esp_err_t esp_timer_start_periodic(esp_timer_handle_t t, uint64_t period) {
|
||||
OUTSIDE(); assert(t && period == 20000); return ESP_OK;
|
||||
}
|
||||
static esp_err_t esp_timer_delete(esp_timer_handle_t t) { OUTSIDE(); assert(t); return ESP_OK; }
|
||||
esp_err_t web_session_store_check_principal(web_session_id_t id, const user_principal_t *p, bool *valid) {
|
||||
OUTSIDE(); ++checks;
|
||||
if (check_hook) { void (*hook)(void) = check_hook; check_hook = NULL; hook(); }
|
||||
*valid = session_current && id == auth_view.id && p &&
|
||||
p->user_id == auth_view.principal.user_id && p->auth_generation == auth_view.principal.auth_generation &&
|
||||
p->role == auth_view.principal.role && p->method == auth_view.principal.method &&
|
||||
p->username_length == auth_view.principal.username_length &&
|
||||
!memcmp(p->username, auth_view.principal.username, p->username_length);
|
||||
return ESP_OK;
|
||||
}
|
||||
static esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
|
||||
web_session_view_t *v, bool *allowed) {
|
||||
io(); assert(r); assert(mutation != upgrade);
|
||||
*v = auth_view; *allowed = auth_allowed; return ESP_OK;
|
||||
}
|
||||
void web_admin_tickets_start(void) { OUTSIDE(); }
|
||||
void web_admin_tickets_stop(void) { OUTSIDE(); ticket_live = false; }
|
||||
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *u, size_t n) {
|
||||
OUTSIDE(); (void)id; (void)u; (void)n;
|
||||
}
|
||||
esp_err_t web_admin_tickets_issue(web_session_id_t id, const user_principal_t *p, char token[WEB_ADMIN_TICKET_LENGTH + 1U]) {
|
||||
OUTSIDE(); assert(id == auth_view.id && p->role == USER_ROLE_ADMIN);
|
||||
if (ticket_live) return ESP_ERR_NO_MEM;
|
||||
memset(token, 'a', 64); token[64] = 0; ticket_live = true; return ESP_OK;
|
||||
}
|
||||
esp_err_t web_admin_tickets_consume(const char *t, web_session_id_t id, const user_principal_t *p) {
|
||||
OUTSIDE(); assert(id && p); bool valid = ticket_live && strlen(t) == 64;
|
||||
ticket_live = false; return valid ? ESP_OK : ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *t, const user_principal_t *p,
|
||||
const admin_console_owner_t *owner) {
|
||||
OUTSIDE(); assert(p->role == USER_ROLE_ADMIN);
|
||||
if (console_full) return ESP_ERR_INVALID_STATE;
|
||||
assert(!console_live); t->slot_index = 1; console_token = *t; console_owner = owner; console_live = true;
|
||||
if (revoke_on_open) { session_current = false; web_admin_transport_revoke(auth_view.id, NULL, 0); }
|
||||
return ESP_OK;
|
||||
}
|
||||
void admin_ssh_console_close(const admin_ssh_console_token_t *t) {
|
||||
OUTSIDE();
|
||||
if (console_live && !memcmp(t, &console_token, sizeof(*t))) console_live = false;
|
||||
}
|
||||
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *t, const uint8_t *data,
|
||||
size_t length, size_t *consumed) {
|
||||
io(); assert(console_live && t->session_id == console_token.session_id);
|
||||
*consumed = length < feed_limit ? length : feed_limit;
|
||||
assert(fed_length + *consumed <= sizeof(fed));
|
||||
memcpy(fed + fed_length, data, *consumed); fed_length += *consumed; return *consumed != 0;
|
||||
}
|
||||
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *t, uint8_t *data,
|
||||
size_t capacity, size_t *received) {
|
||||
io(); assert(t->session_id == console_token.session_id);
|
||||
*received = output_length < capacity ? output_length : capacity;
|
||||
memcpy(data, output, *received); output_length -= *received;
|
||||
if (revoke_on_send) { session_current = false; web_admin_transport_revoke(auth_view.id, NULL, 0); }
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t admin_ssh_console_get_session_snapshot(const admin_ssh_console_token_t *t,
|
||||
admin_ssh_console_session_snapshot_t *s) {
|
||||
OUTSIDE(); assert(t); *s = (admin_ssh_console_session_snapshot_t){.active = console_live}; return ESP_OK;
|
||||
}
|
||||
static esp_err_t httpd_queue_work(httpd_handle_t h, void (*fn)(void *), void *arg) {
|
||||
OUTSIDE(); assert(h == SERVER); ++queues;
|
||||
if (queue_hook) { void (*hook)(void) = queue_hook; queue_hook = NULL; hook(); }
|
||||
if (queue_fail) return ESP_FAIL;
|
||||
assert(!queued_work); queued_work = fn; queued_argument = arg; return ESP_OK;
|
||||
}
|
||||
static void *httpd_sess_get_ctx(httpd_handle_t h, int fd) {
|
||||
io(); assert(h == SERVER); return live_request && live_request->fd == fd ? live_request->sess_ctx : NULL;
|
||||
}
|
||||
static int httpd_ws_get_fd_info(httpd_handle_t h, int fd) { io(); assert(h == SERVER && fd >= 0); return HTTPD_WS_CLIENT_WEBSOCKET; }
|
||||
#define SHUT_RDWR 2
|
||||
static bool shutdown_fail;
|
||||
static int shutdown(int fd, int how) {
|
||||
io(); assert(how == SHUT_RDWR && live_request && live_request->fd == fd);
|
||||
++closes; return shutdown_fail ? -1 : 0;
|
||||
}
|
||||
static int httpd_req_to_sockfd(httpd_req_t *r) { io(); return r->fd; }
|
||||
static esp_err_t httpd_ws_send_frame_async(httpd_handle_t h, int fd, httpd_ws_frame_t *f) {
|
||||
io(); assert(h == SERVER && fd >= 0 && f->final && f->type == HTTPD_WS_TYPE_BINARY);
|
||||
++sends; sent_length = f->len; memcpy(sent, f->payload, f->len); return send_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
/* Match IDF 5.5's frame->len == 0 sentinel, including its empty-frame reparse. */
|
||||
static esp_err_t httpd_ws_recv_frame(httpd_req_t *r, httpd_ws_frame_t *f, size_t capacity) {
|
||||
io(); (void)r;
|
||||
if (f->len == 0) { ++receive_headers; f->len = incoming.len; f->final = incoming.final; f->type = incoming.type; }
|
||||
if (!capacity || !f->len) return ESP_OK;
|
||||
if (f->len > capacity) return ESP_ERR_INVALID_ARG;
|
||||
memcpy(f->payload, incoming.payload, f->len); return ESP_OK;
|
||||
}
|
||||
static esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s) { io(); (void)r; snprintf(status, sizeof(status), "%s", s); return ESP_OK; }
|
||||
static esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s) { io(); (void)r; assert(!strcmp(s, "application/json")); return ESP_OK; }
|
||||
static esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *k, const char *v) { io(); (void)r; assert(k && v); return ESP_OK; }
|
||||
static esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *s) { io(); (void)r; snprintf(response_body, sizeof(response_body), "%s", s); return ESP_OK; }
|
||||
static bool web_httpd_upgrade_requested(httpd_req_t *r) { io(); (void)r; return upgrade_requested; }
|
||||
static esp_err_t web_httpd_upgrade(httpd_req_t *r, esp_err_t (*handler)(httpd_req_t *)) {
|
||||
io(); assert(r && handler && console_live); ++upgrades; return upgrade_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
static bool web_httpd_unread_body(httpd_req_t *r) { io(); (void)r; return false; }
|
||||
static void web_httpd_wipe_request(httpd_req_t *r, bool closing) { io(); (void)r; (void)closing; ++wipes; }
|
||||
@@ -0,0 +1,52 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <limits.h>
|
||||
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_NO_MEM, ESP_ERR_INVALID_ARG,
|
||||
ESP_ERR_INVALID_STATE, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND,
|
||||
ESP_ERR_TIMEOUT, ESP_ERR_NOT_ALLOWED };
|
||||
typedef int portMUX_TYPE;
|
||||
#define portMUX_INITIALIZER_UNLOCKED 0
|
||||
static int lock_depth;
|
||||
#define taskENTER_CRITICAL(lock) do { (void)(lock); assert(lock_depth++ == 0); } while (0)
|
||||
#define taskEXIT_CRITICAL(lock) do { (void)(lock); assert(--lock_depth == 0); } while (0)
|
||||
#define OUTSIDE() assert(lock_depth == 0)
|
||||
#define MALLOC_CAP_SPIRAM 1
|
||||
#define MALLOC_CAP_8BIT 2
|
||||
|
||||
typedef void *httpd_handle_t;
|
||||
typedef struct httpd_req {
|
||||
httpd_handle_t handle;
|
||||
const char *uri;
|
||||
void *sess_ctx;
|
||||
void (*free_ctx)(void *);
|
||||
int fd;
|
||||
} httpd_req_t;
|
||||
typedef struct {
|
||||
bool final;
|
||||
int type;
|
||||
uint8_t *payload;
|
||||
size_t len;
|
||||
} httpd_ws_frame_t;
|
||||
enum { HTTPD_WS_TYPE_BINARY = 2, HTTPD_WS_TYPE_TEXT = 1,
|
||||
HTTPD_WS_TYPE_CLOSE = 8, HTTPD_WS_CLIENT_WEBSOCKET = 3 };
|
||||
typedef void *esp_timer_handle_t;
|
||||
typedef struct {
|
||||
void (*callback)(void *);
|
||||
const char *name;
|
||||
bool skip_unhandled_events;
|
||||
} esp_timer_create_args_t;
|
||||
static int64_t now;
|
||||
static int64_t esp_timer_get_time(void) { OUTSIDE(); return now; }
|
||||
static void vTaskDelay(unsigned ticks) { OUTSIDE(); now += ticks * 1000; }
|
||||
static void secure_wipe(void *p, size_t n) {
|
||||
volatile unsigned char *b = p;
|
||||
while (n--) *b++ = 0;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic production-C transport harness; no target build or device IO."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
|
||||
def stripped(path):
|
||||
return '\n'.join(line for line in path.read_text().splitlines()
|
||||
if not line.startswith(('#include', '#pragma once'))) + '\n'
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--sanitize', action='store_true')
|
||||
parser.add_argument('--tickets', action='store_true', help='also run separate real ticket suite')
|
||||
parser.add_argument('--strict', action='store_true',
|
||||
help='compatibility flag: all regression assertions are mandatory by default')
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix='web-admin-transport-') as directory:
|
||||
path = Path(directory)
|
||||
unit = (HERE / 'platform.h').read_text() + '\n'
|
||||
for header in ('user_database.h', 'web_session_store.h', 'admin_ssh_console.h',
|
||||
'web_admin_tickets.h', 'web_admin_transport.h'):
|
||||
unit += stripped(ROOT / 'src' / header)
|
||||
unit += (HERE / 'fakes.h').read_text() + '\n'
|
||||
unit += stripped(ROOT / 'src/web_admin_transport.c')
|
||||
unit += (HERE / 'test.c').read_text()
|
||||
(path / 'test.c').write_text(unit)
|
||||
flags = ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] if args.sanitize else []
|
||||
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror',
|
||||
'-g', *flags, str(path / 'test.c'), '-o', str(path / 'test')],
|
||||
check=True, timeout=30)
|
||||
subprocess.run([str(path / 'test')], check=True, timeout=15)
|
||||
if args.tickets:
|
||||
subprocess.run(['python3', str(ROOT / 'tests/web_admin_tickets/run.py'),
|
||||
*(['--sanitize'] if args.sanitize else [])], check=True, timeout=60)
|
||||
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile production server lifecycle and URI tables against fixed host fakes.
|
||||
|
||||
No HTTP handlers, TLS/HTTPD runtime, transport implementation or scheduler is
|
||||
executed. Assertions cover server orchestration and values passed to registration
|
||||
and SSL-start fakes, not actual requests/101, socket eviction or concurrent stop.
|
||||
No firmware build, network access or device operation. CC selects the compiler.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
SOURCE = ROOT / 'src/web_server.c'
|
||||
source = SOURCE.read_text()
|
||||
|
||||
|
||||
def function(name):
|
||||
match = re.search(r'^(?:static )?esp_err_t ' + name + r'\(void\)\n\{.*?^\}',
|
||||
source, re.M | re.S)
|
||||
if not match:
|
||||
raise RuntimeError('Production function shape changed: ' + name)
|
||||
return match.group() + '\n'
|
||||
|
||||
|
||||
def define(path, name):
|
||||
match = re.search(r'^#define ' + name + r' .+$', path.read_text(), re.M)
|
||||
if not match:
|
||||
raise RuntimeError('Missing production constant: ' + name)
|
||||
return match.group() + '\n'
|
||||
|
||||
|
||||
# Extract complete initializers, retaining real handler pointers and flags.
|
||||
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
|
||||
source, re.M | re.S)
|
||||
# Non-array declarations have no brackets; explicit shape avoids silent omission.
|
||||
if len(uri_tables) != 13:
|
||||
raise RuntimeError('Review URI extraction: expected 11 descriptors and two tables')
|
||||
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
|
||||
source.index('static esp_err_t ensure_mutex(void)')]
|
||||
header = (ROOT / 'src/web_server.h').read_text()
|
||||
header = '\n'.join(line for line in header.splitlines()
|
||||
if not line.startswith(('#include', '#pragma once')))
|
||||
constants = define(SOURCE, 'WEB_SERVER_PORT')
|
||||
for filename, names in {
|
||||
'web_admin_transport.h': ('WEB_ADMIN_TICKET_URI', 'WEB_ADMIN_WS_URI'),
|
||||
'web_serial_transport.h': ('WEB_SERIAL_TRANSPORT_TICKET_URI', 'WEB_SERIAL_TRANSPORT_WS_URI'),
|
||||
'web_security.h': ('WEB_SECURITY_CERTIFICATE_DER_CAPACITY', 'WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY'),
|
||||
}.items():
|
||||
for name in names:
|
||||
constants += define(ROOT / 'src' / filename, name)
|
||||
|
||||
FAKES = r'''
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_STATE, ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT };
|
||||
typedef void *SemaphoreHandle_t;
|
||||
typedef void *httpd_handle_t;
|
||||
typedef struct { int unused; } httpd_req_t;
|
||||
typedef int httpd_err_code_t;
|
||||
enum { HTTP_GET, HTTP_POST, HTTPD_404_NOT_FOUND = 404, HTTPD_405_METHOD_NOT_ALLOWED = 405 };
|
||||
enum { WEB_UI_RESOURCE_XTERM_JS, WEB_UI_RESOURCE_XTERM_CSS, WEB_UI_RESOURCE_ADDON_FIT_JS,
|
||||
WEB_UI_RESOURCE_APP_JS, WEB_UI_RESOURCE_LOGO_PNG };
|
||||
typedef struct {
|
||||
const char *uri; int method; esp_err_t (*handler)(httpd_req_t *);
|
||||
void *user_ctx; bool is_websocket, handle_ws_control_frames;
|
||||
} httpd_uri_t;
|
||||
typedef struct {
|
||||
struct { unsigned max_open_sockets, max_uri_handlers; bool lru_purge_enable;
|
||||
unsigned recv_wait_timeout, send_wait_timeout; } httpd;
|
||||
const uint8_t *servercert, *prvtkey_pem;
|
||||
size_t servercert_len, prvtkey_len;
|
||||
unsigned port_secure, tls_handshake_timeout_ms;
|
||||
} httpd_ssl_config_t;
|
||||
/* Nonproduction defaults deliberately make explicit overrides observable. */
|
||||
#define HTTPD_SSL_CONFIG_DEFAULT() ((httpd_ssl_config_t){.httpd = {.max_open_sockets = 1, .lru_purge_enable = true}})
|
||||
#define portMAX_DELAY 0
|
||||
static int mutex_storage, server_storage, locked;
|
||||
#define SERVER ((void *)&server_storage)
|
||||
static bool mutex_fail, auth_live, ssl_live, admin_owned, serial_live;
|
||||
static esp_err_t serial_init_error, admin_init_error, admin_attach_error;
|
||||
static esp_err_t auth_error, ssl_start_error, ssl_stop_error, admin_detach_error;
|
||||
static unsigned serial_inits, admin_inits, auth_starts, auth_stops;
|
||||
static unsigned ssl_starts, ssl_stops, serial_attaches, serial_detaches;
|
||||
static unsigned admin_attaches, admin_detaches, admin_stoppeds;
|
||||
static unsigned registration_calls, registration_fail_at, registered_count, unregister_calls;
|
||||
static bool unregister_fail;
|
||||
static const httpd_uri_t *registered[32];
|
||||
static char events[128]; static size_t event_length;
|
||||
static void event(char value) { assert(!locked && event_length + 1 < sizeof(events)); events[event_length++] = value; events[event_length] = 0; }
|
||||
static SemaphoreHandle_t xSemaphoreCreateMutex(void) { assert(!locked); return mutex_fail ? NULL : &mutex_storage; }
|
||||
static void xSemaphoreTake(SemaphoreHandle_t m, int wait) { (void)wait; assert(m && !locked); locked = 1; }
|
||||
static void xSemaphoreGive(SemaphoreHandle_t m) { assert(m && locked); locked = 0; }
|
||||
static void secure_wipe(void *p, size_t n) { assert(!locked); memset(p, 0, n); }
|
||||
#define HANDLER(name) static esp_err_t name(httpd_req_t *r) { (void)r; assert(!"HTTP handler must not run in lifecycle harness"); return ESP_FAIL; }
|
||||
HANDLER(root_handler) HANDLER(status_handler) HANDLER(ticket_handler)
|
||||
HANDLER(websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
|
||||
HANDLER(web_admin_transport_ticket_handler) HANDLER(web_admin_transport_upgrade_handler)
|
||||
static esp_err_t route_error_handler(httpd_req_t *r, httpd_err_code_t c) { (void)r; (void)c; assert(0); return ESP_FAIL; }
|
||||
static esp_err_t web_serial_transport_init(void) { assert(!locked); ++serial_inits; return serial_init_error; }
|
||||
static esp_err_t web_cookie_auth_start(void) { assert(!locked); ++auth_starts; auth_live = auth_error == ESP_OK; return auth_error; }
|
||||
static void web_cookie_auth_stop(void) { event('A'); ++auth_stops; auth_live = false; }
|
||||
static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t *lc,
|
||||
uint8_t *key, size_t nk, size_t *lk) {
|
||||
assert(!locked && auth_live && nc && nk); cert[0] = 1; key[0] = 2; *lc = *lk = 1; return ESP_OK;
|
||||
}
|
||||
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
|
||||
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
|
||||
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
|
||||
assert(config->httpd.max_uri_handlers == 16 && config->port_secure == 443);
|
||||
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
|
||||
assert(config->tls_handshake_timeout_ms == 5000);
|
||||
assert(config->servercert_len == 1 && config->servercert[0] == 1);
|
||||
assert(config->prvtkey_len == 1 && config->prvtkey_pem[0] == 2);
|
||||
if (ssl_start_error != ESP_OK) return ssl_start_error;
|
||||
*server = SERVER; ssl_live = true; return ESP_OK;
|
||||
}
|
||||
static esp_err_t register_one(httpd_handle_t server) {
|
||||
assert(!locked && server == SERVER && ssl_live); ++registration_calls;
|
||||
return registration_calls == registration_fail_at ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
if (!strcmp(uri->uri, "/api/admin/ws-ticket") || !strcmp(uri->uri, "/ws/admin")) {
|
||||
assert(registration_calls >= 16);
|
||||
assert(serial_init_error != ESP_OK || serial_live);
|
||||
} else assert(registration_calls < 14);
|
||||
esp_err_t error = register_one(s);
|
||||
if (error == ESP_OK) { assert(registered_count < 32); registered[registered_count++] = uri; }
|
||||
return error;
|
||||
}
|
||||
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
|
||||
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
|
||||
assert(registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST);
|
||||
++unregister_calls;
|
||||
for (unsigned i = 0; i < registered_count; ++i) {
|
||||
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
|
||||
if (unregister_fail) return ESP_FAIL;
|
||||
memmove(®istered[i], ®istered[i + 1],
|
||||
(registered_count - i - 1) * sizeof(registered[0]));
|
||||
--registered_count;
|
||||
return ESP_OK;
|
||||
}
|
||||
}
|
||||
assert(!"unregister must target the previously registered ticket");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
static esp_err_t httpd_register_err_handler(httpd_handle_t s, httpd_err_code_t code,
|
||||
esp_err_t (*handler)(httpd_req_t *, httpd_err_code_t)) {
|
||||
assert(registration_calls == 14 || registration_calls == 15);
|
||||
assert((code == 404 || code == 405) && handler == route_error_handler);
|
||||
return register_one(s);
|
||||
}
|
||||
static esp_err_t web_serial_transport_attach_server(httpd_handle_t s) {
|
||||
assert(!locked && s == SERVER && ssl_live && auth_live && registration_calls == 16);
|
||||
++serial_attaches; serial_live = true; return ESP_OK;
|
||||
}
|
||||
static esp_err_t web_admin_transport_init(void) { assert(!locked && auth_live && registration_calls == 18); ++admin_inits; return admin_init_error; }
|
||||
static esp_err_t web_admin_transport_attach(httpd_handle_t s) {
|
||||
assert(!locked && s == SERVER && ssl_live && !admin_owned); ++admin_attaches;
|
||||
admin_owned = admin_attach_error == ESP_OK; return admin_attach_error;
|
||||
}
|
||||
static esp_err_t web_admin_transport_detach(httpd_handle_t s) {
|
||||
assert(s == SERVER && ssl_live && admin_owned && !auth_live);
|
||||
event('D'); ++admin_detaches; return admin_detach_error;
|
||||
}
|
||||
static esp_err_t web_serial_transport_detach_server(httpd_handle_t s) {
|
||||
assert(s == SERVER && ssl_live && serial_live && !auth_live);
|
||||
event('S'); ++serial_detaches; serial_live = false; return ESP_OK;
|
||||
}
|
||||
static esp_err_t httpd_ssl_stop(httpd_handle_t s) {
|
||||
assert(s == SERVER && ssl_live && !auth_live); event('H'); ++ssl_stops;
|
||||
if (ssl_stop_error == ESP_OK) ssl_live = false;
|
||||
return ssl_stop_error;
|
||||
}
|
||||
static void web_admin_transport_stopped(httpd_handle_t s) {
|
||||
assert(s == SERVER && !ssl_live && admin_owned && admin_detaches);
|
||||
event('R'); ++admin_stoppeds; admin_owned = false;
|
||||
}
|
||||
'''
|
||||
|
||||
TESTS = r'''
|
||||
static void clear_events(void) { event_length = 0; events[0] = 0; }
|
||||
static void reset(void) {
|
||||
assert(!locked);
|
||||
s_server_mutex = NULL; s_server = NULL; s_initialized = s_transitioning = false;
|
||||
s_serial_transport_init_attempted = s_serial_transport_initialized = false;
|
||||
s_serial_transport_attached = s_admin_transport_owned = false;
|
||||
s_last_error = s_serial_transport_error = ESP_ERR_INVALID_STATE;
|
||||
memset(&s_counters, 0, sizeof(s_counters));
|
||||
mutex_fail = auth_live = ssl_live = admin_owned = serial_live = false;
|
||||
serial_init_error = admin_init_error = admin_attach_error = ESP_OK;
|
||||
auth_error = ssl_start_error = ssl_stop_error = admin_detach_error = ESP_OK;
|
||||
serial_inits = admin_inits = auth_starts = auth_stops = ssl_starts = ssl_stops = 0;
|
||||
serial_attaches = serial_detaches = admin_attaches = admin_detaches = admin_stoppeds = 0;
|
||||
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
|
||||
unregister_fail = false; clear_events();
|
||||
}
|
||||
static void fresh_registration(void) { registration_calls = registered_count = 0; }
|
||||
static void start(void) {
|
||||
assert(web_server_start() == ESP_OK);
|
||||
assert(s_server == SERVER && s_admin_transport_owned && s_serial_transport_attached);
|
||||
assert(auth_live && ssl_live && admin_owned && serial_live && !s_transitioning);
|
||||
}
|
||||
static const httpd_uri_t *route(const char *uri) {
|
||||
const httpd_uri_t *found = NULL;
|
||||
for (unsigned i = 0; i < registered_count; ++i) if (!strcmp(registered[i]->uri, uri)) {
|
||||
assert(!found); found = registered[i];
|
||||
}
|
||||
assert(found); return found;
|
||||
}
|
||||
int main(void) {
|
||||
reset(); mutex_fail = true;
|
||||
assert(web_server_init() == ESP_ERR_NO_MEM && !s_initialized && !serial_inits);
|
||||
mutex_fail = false; serial_init_error = ESP_FAIL;
|
||||
assert(web_server_init() == ESP_OK && s_initialized && !s_serial_transport_initialized);
|
||||
assert(web_server_start() == ESP_OK && auth_live && ssl_live && admin_owned);
|
||||
assert(serial_inits == 1 && !serial_attaches && !auth_stops);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS mutex failure and serial-init failure isolation from authenticated HTTPS");
|
||||
|
||||
for (unsigned mode = 0; mode < 2; ++mode) {
|
||||
reset(); if (mode == 0) admin_init_error = ESP_ERR_NO_MEM; else admin_attach_error = ESP_FAIL;
|
||||
assert(web_server_start() == ESP_OK && auth_live && ssl_live && serial_live);
|
||||
assert(s_serial_transport_attached && !s_admin_transport_owned && !auth_stops);
|
||||
assert(admin_inits == 1 && admin_attaches == mode);
|
||||
assert(web_server_stop() == ESP_OK && !admin_detaches && !admin_stoppeds);
|
||||
}
|
||||
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
||||
|
||||
reset(); start(); assert(registered_count == 16 && registration_calls == 18);
|
||||
const httpd_uri_t *ticket = route("/api/admin/ws-ticket"), *ws = route("/ws/admin");
|
||||
assert(ticket->method == HTTP_POST && ticket->handler == web_admin_transport_ticket_handler && !ticket->is_websocket);
|
||||
assert(ws->method == HTTP_GET && ws->handler == web_admin_transport_upgrade_handler && !ws->is_websocket);
|
||||
assert(route("/ws/serial")->method == HTTP_GET && !route("/ws/serial")->is_websocket);
|
||||
assert(route("/api/login")->method == HTTP_POST && route("/api/session")->method == HTTP_GET);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && auth_starts == 1 && ssl_starts == 1);
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ADSHR"));
|
||||
assert(!s_server && !s_admin_transport_owned && !s_transitioning && s_counters.stops == 1);
|
||||
puts("PASS production URI tables/registration, six sockets/no LRU, admission and ordered normal stop");
|
||||
|
||||
fresh_registration(); start(); assert(ssl_starts == 2 && serial_inits == 1 && admin_inits == 2);
|
||||
assert(s_counters.starts == 2 && web_server_stop() == ESP_OK && admin_stoppeds == 2);
|
||||
puts("PASS restart after successful stop reattaches without repeated serial initialization");
|
||||
|
||||
reset(); start(); admin_detach_error = ESP_ERR_TIMEOUT; clear_events();
|
||||
assert(web_server_stop() == ESP_ERR_TIMEOUT && !strcmp(events, "AD"));
|
||||
assert(!ssl_stops && !serial_detaches && !admin_stoppeds);
|
||||
assert(s_server == SERVER && s_admin_transport_owned && admin_owned && ssl_live);
|
||||
assert(s_serial_transport_attached && !s_transitioning && s_last_error == ESP_ERR_TIMEOUT);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && auth_starts == 1);
|
||||
admin_detach_error = ESP_OK; clear_events();
|
||||
assert(web_server_stop() == ESP_OK && !strcmp(events, "ADSHR") && admin_detaches == 2);
|
||||
puts("PASS admin detach timeout fences SSL stop, retains ownership and permits stop retry");
|
||||
|
||||
reset(); start(); ssl_stop_error = ESP_FAIL; clear_events();
|
||||
assert(web_server_stop() == ESP_FAIL && !strcmp(events, "ADSH"));
|
||||
assert(s_server == SERVER && s_admin_transport_owned && admin_owned && ssl_live);
|
||||
assert(!s_serial_transport_attached && !s_transitioning && !admin_stoppeds);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; clear_events();
|
||||
assert(web_server_stop() == ESP_OK && !strcmp(events, "ADHR"));
|
||||
assert(admin_detaches == 2 && serial_detaches == 1 && admin_stoppeds == 1 && !s_admin_transport_owned);
|
||||
puts("PASS failed SSL stop retains admin ownership; stopped runs only after successful retry");
|
||||
|
||||
for (unsigned failure = 1; failure <= 16; ++failure) {
|
||||
reset(); registration_fail_at = failure;
|
||||
assert(web_server_start() == ESP_FAIL);
|
||||
assert(registration_calls == failure && !admin_inits && !admin_attaches && !serial_attaches);
|
||||
assert(!auth_live && !ssl_live && ssl_stops == 1 && !s_server && !s_admin_transport_owned);
|
||||
assert(!admin_detaches && !admin_stoppeds && !s_transitioning && s_counters.start_failures == 1);
|
||||
}
|
||||
puts("PASS required registration positions 1..16 fail fatally before transport attachment");
|
||||
|
||||
for (unsigned failure = 17; failure <= 18; ++failure) {
|
||||
reset(); registration_fail_at = failure;
|
||||
assert(web_server_start() == ESP_OK && registration_calls == failure);
|
||||
assert(auth_live && ssl_live && serial_live && s_server == SERVER);
|
||||
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
|
||||
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
|
||||
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
|
||||
assert(registered_count == 14 && unregister_calls == failure - 17);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
|
||||
assert(route("/ws/serial")->handler == websocket_handler);
|
||||
assert(route("/api/session")->handler == web_cookie_auth_handler);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||
assert(!admin_detaches && !admin_stoppeds);
|
||||
registration_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 16 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
|
||||
}
|
||||
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
|
||||
|
||||
reset(); registration_fail_at = 18; unregister_fail = true;
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 15);
|
||||
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
|
||||
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
|
||||
ticket = route("/api/admin/ws-ticket");
|
||||
assert(ticket->method == HTTP_POST && !ticket->is_websocket &&
|
||||
ticket->handler == web_admin_transport_ticket_handler);
|
||||
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/ws/admin"));
|
||||
/* Handler identity is checked, not its authentication implementation (doubled). */
|
||||
assert(!auth_stops && !ssl_stops && !s_transitioning && s_last_error == ESP_OK);
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||
assert(!admin_detaches && !admin_stoppeds);
|
||||
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 16 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
|
||||
|
||||
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
|
||||
assert(web_server_start() == ESP_FAIL && s_server == SERVER && ssl_live);
|
||||
assert(!s_admin_transport_owned && !admin_attaches && !auth_live);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; clear_events();
|
||||
assert(web_server_stop() == ESP_OK && !strcmp(events, "AH") && !admin_stoppeds);
|
||||
registration_fail_at = 0; fresh_registration(); start(); assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS registration cleanup SSL failure retains partial server for stop/restart without admin ownership");
|
||||
|
||||
reset(); auth_error = ESP_FAIL;
|
||||
assert(web_server_start() == ESP_FAIL && !ssl_starts && !admin_inits && !s_server);
|
||||
reset(); ssl_start_error = ESP_FAIL;
|
||||
assert(web_server_start() == ESP_FAIL && !auth_live && !registration_calls && !ssl_stops);
|
||||
reset(); assert(web_server_stop() == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_init() == ESP_OK); s_transitioning = true;
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && !auth_starts);
|
||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
|
||||
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
|
||||
puts("11 lifecycle groups passed (16 required fatal positions, 2 optional positions, plus failed unregister)");
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
unit = FAKES + header + '\n' + constants + state + '\n'.join(uri_tables)
|
||||
unit += function('ensure_mutex')
|
||||
unit += ''.join(function(name) for name in ('web_server_init', 'web_server_start', 'web_server_stop'))
|
||||
unit += TESTS
|
||||
with tempfile.TemporaryDirectory(prefix='web-admin-server-lifecycle-') as directory:
|
||||
temporary = Path(directory)
|
||||
c_file = temporary / 'test.c'
|
||||
c_file.write_text(unit)
|
||||
executable = temporary / 'test'
|
||||
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror',
|
||||
'-g', str(c_file), '-o', str(executable)], check=True, timeout=30)
|
||||
subprocess.run([str(executable)], check=True, timeout=15)
|
||||
print('Compiled production init/start/stop, URI initializers and configuration; dependency behavior is faked.')
|
||||
@@ -0,0 +1,234 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
static char uri[128];
|
||||
static httpd_req_t request;
|
||||
static unsigned cases;
|
||||
static bool zeroed(const void *p, size_t n) {
|
||||
const uint8_t *b = p; for (size_t i = 0; i < n; ++i) if (b[i]) return false; return true;
|
||||
}
|
||||
static void reset(void) {
|
||||
assert(lock_depth == 0);
|
||||
free(s_payload); s_payload = NULL;
|
||||
memset(&s_slot, 0, sizeof(s_slot)); memset(&s_counts, 0, sizeof(s_counts));
|
||||
s_timer = NULL; s_server = NULL; s_initialized = s_accepting = s_queued = false;
|
||||
s_submitting = s_generation = 0;
|
||||
httpd_owner = true; alloc_fail = timer_fail = false;
|
||||
auth_allowed = session_current = upgrade_requested = true;
|
||||
console_live = console_full = queue_fail = send_fail = upgrade_fail = shutdown_fail = false;
|
||||
ticket_live = revoke_on_open = revoke_on_send = false;
|
||||
upgrades = closes = sends = queues = wipes = checks = receive_headers = 0;
|
||||
feed_limit = SIZE_MAX; fed_length = output_length = sent_length = 0;
|
||||
memset(fed, 0, sizeof(fed)); memset(output, 0, sizeof(output)); memset(sent, 0, sizeof(sent));
|
||||
memset(status, 0, sizeof(status)); memset(response_body, 0, sizeof(response_body));
|
||||
queued_work = NULL; queued_argument = NULL; check_hook = queue_hook = NULL;
|
||||
console_owner = NULL; live_request = NULL; now = 1000000;
|
||||
auth_view = (web_session_view_t){.id = 7, .principal = {
|
||||
.user_id = 3, .auth_generation = 9, .role = USER_ROLE_ADMIN,
|
||||
.method = USER_AUTH_METHOD_PASSWORD, .username_length = 5, .username = "admin"}};
|
||||
snprintf(uri, sizeof(uri), "%s?ticket=%064d", WEB_ADMIN_WS_URI, 0);
|
||||
request = (httpd_req_t){.handle = SERVER, .uri = uri, .fd = 12};
|
||||
incoming = (httpd_ws_frame_t){.final = true, .type = HTTPD_WS_TYPE_BINARY};
|
||||
}
|
||||
static void start(void) {
|
||||
assert(web_admin_transport_init() == ESP_OK);
|
||||
assert(web_admin_transport_attach(SERVER) == ESP_OK);
|
||||
}
|
||||
static void admit(void) {
|
||||
ticket_live = true;
|
||||
assert(web_admin_transport_upgrade_handler(&request) == ESP_OK);
|
||||
assert(upgrades == 1 && s_slot.active && console_live && request.free_ctx);
|
||||
assert(s_slot.token.slot_index == 1); live_request = &request;
|
||||
}
|
||||
static void tick(void) { httpd_owner = false; timer_callback(NULL); httpd_owner = true; }
|
||||
static void work(void) {
|
||||
assert(queued_work); void (*fn)(void *) = queued_work; void *arg = queued_argument;
|
||||
queued_work = NULL; queued_argument = NULL; fn(arg);
|
||||
}
|
||||
static void disconnected(void) {
|
||||
assert(request.free_ctx); request.free_ctx(request.sess_ctx);
|
||||
request.free_ctx = NULL; request.sess_ctx = NULL; live_request = NULL;
|
||||
}
|
||||
static void ok(const char *name) { ++cases; printf("PASS %s\n", name); }
|
||||
static void revoke_check(void) { session_current = false; web_admin_transport_revoke(auth_view.id, NULL, 0); }
|
||||
static void detach_in_submit(void) {
|
||||
assert(web_admin_transport_detach(SERVER) == ESP_ERR_TIMEOUT);
|
||||
assert(!s_accepting && s_server == SERVER && s_submitting == 1);
|
||||
}
|
||||
static void replace_in_submit(void) {
|
||||
assert(!httpd_owner && s_submitting == 1 && s_queued);
|
||||
uint32_t generation = s_slot.token.slot_generation;
|
||||
httpd_owner = true;
|
||||
disconnected();
|
||||
assert(!s_slot.occupied && !console_live);
|
||||
upgrades = 0;
|
||||
admit();
|
||||
assert(s_slot.token.slot_generation != generation && !s_slot.close_requested);
|
||||
httpd_owner = false;
|
||||
}
|
||||
int main(void) {
|
||||
reset(); alloc_fail = true; assert(web_admin_transport_init() == ESP_ERR_NO_MEM);
|
||||
assert(!s_initialized && !s_payload); alloc_fail = false; timer_fail = true;
|
||||
assert(web_admin_transport_init() == ESP_FAIL && !s_payload);
|
||||
timer_fail = false; start(); assert(web_admin_transport_init() == ESP_OK);
|
||||
assert(web_admin_transport_attach(SERVER) == ESP_ERR_INVALID_STATE);
|
||||
ok("PSRAM-only allocation failure, timer failure, retry and duplicate attach");
|
||||
|
||||
reset(); start(); auth_allowed = false;
|
||||
assert(web_admin_transport_ticket_handler(&request) == ESP_OK && !ticket_live);
|
||||
assert(web_admin_transport_upgrade_handler(&request) == ESP_OK && !upgrades);
|
||||
auth_allowed = true; auth_view.principal.role = USER_ROLE_USER;
|
||||
assert(web_admin_transport_ticket_handler(&request) != ESP_OK && !strcmp(status, "403 Forbidden"));
|
||||
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !upgrades);
|
||||
auth_view.principal.role = USER_ROLE_ADMIN;
|
||||
assert(web_admin_transport_ticket_handler(&request) == ESP_OK && ticket_live);
|
||||
assert(strstr(response_body, "\"expires_in\":30"));
|
||||
assert(web_admin_transport_ticket_handler(&request) != ESP_OK && !strcmp(status, "503 Service Unavailable"));
|
||||
ok("authorization delegation, admin role and ticket capacity responses");
|
||||
|
||||
reset(); start(); ticket_live = true; upgrade_requested = false;
|
||||
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && ticket_live && !upgrades);
|
||||
upgrade_requested = true; request.uri = "/ws/admin?ticket=short";
|
||||
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !upgrades);
|
||||
request.uri = uri; ticket_live = false;
|
||||
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !s_slot.occupied);
|
||||
ticket_live = true; console_full = true;
|
||||
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !ticket_live && !s_slot.occupied && !upgrades);
|
||||
console_full = false; revoke_on_open = true; ticket_live = true;
|
||||
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !console_live && !s_slot.occupied && !upgrades);
|
||||
ok("pre-101 malformed/ticket/shared-console rejection and revocation during admission");
|
||||
|
||||
reset(); start(); upgrade_fail = true; ticket_live = true;
|
||||
assert(web_admin_transport_upgrade_handler(&request) != ESP_OK && !console_live && !s_slot.occupied);
|
||||
assert(zeroed(s_payload, sizeof(*s_payload)));
|
||||
ok("failed upgrade unwinds console, slot and payload");
|
||||
|
||||
reset(); start(); admit(); admin_ssh_console_token_t old = s_slot.token;
|
||||
ticket_live = true; httpd_req_t second = request; second.fd = 13; second.sess_ctx = NULL;
|
||||
assert(web_admin_transport_upgrade_handler(&second) != ESP_OK && upgrades == 1 && console_live);
|
||||
tick(); tick(); tick(); assert(queues == 1 && s_queued);
|
||||
memcpy(output, "hello", 5); output_length = 5; work();
|
||||
assert(sends == 1 && sent_length == 5 && !memcmp(sent, "hello", 5));
|
||||
assert(zeroed(s_payload->tx, sizeof(s_payload->tx)) && !s_slot.sending);
|
||||
disconnected(); assert(!console_live && !s_slot.occupied && zeroed(s_payload, sizeof(*s_payload)));
|
||||
upgrades = 0; admit(); assert(s_slot.token.session_id != old.session_id);
|
||||
assert(!owner_current(&old, &auth_view.principal));
|
||||
ok("single admin slot, shared slot token, one outstanding poll, TX wiping, disconnect/reuse");
|
||||
|
||||
reset(); start(); admit(); uint8_t bytes[] = {0, 1, 2, 255};
|
||||
incoming.payload = bytes; incoming.len = sizeof(bytes); feed_limit = 2;
|
||||
assert(frame_handler(&request) == ESP_OK && fed_length == 2 && s_payload->rx_offset == 2);
|
||||
assert(zeroed(s_payload->rx, 2)); tick(); work();
|
||||
assert(fed_length == 4 && !memcmp(fed, bytes, 4) && s_payload->rx_length == 0 && zeroed(s_payload->rx, 4));
|
||||
ok("binary-transparent bounded input with partial consume/retry and wiping");
|
||||
|
||||
reset(); start(); admit(); incoming.payload = bytes; incoming.len = 4; feed_limit = 0;
|
||||
assert(frame_handler(&request) == ESP_OK); now += ADMIN_INPUT_TIMEOUT_US;
|
||||
tick(); work(); assert(closes == 1 && !console_live && s_counts.input_backpressure == 1);
|
||||
tick(); work(); assert(closes == 1);
|
||||
ok("input timeout closes once without new task or notifier IO");
|
||||
|
||||
for (unsigned late = 0; late < 2; ++late) {
|
||||
reset(); start(); admit(); incoming.payload = bytes; incoming.len = 4; feed_limit = 2;
|
||||
assert(frame_handler(&request) == ESP_OK && fed_length == 2);
|
||||
assert(s_payload->rx_offset == 2 && s_payload->rx_length == 4);
|
||||
feed_limit = SIZE_MAX; /* Dispatcher is now ready, but the bytes are expired. */
|
||||
now = s_payload->input_deadline + late;
|
||||
tick(); work();
|
||||
assert(fed_length == 2 && s_payload->rx_offset == 2);
|
||||
assert(closes == 1 && !console_live && s_counts.input_backpressure == 1);
|
||||
disconnected(); assert(zeroed(s_payload, sizeof(*s_payload)));
|
||||
}
|
||||
ok("ready console must not consume pending bytes at or after input deadline");
|
||||
|
||||
for (unsigned mode = 0; mode < 3; ++mode) {
|
||||
reset(); start(); admit(); incoming.payload = bytes; incoming.len = 4;
|
||||
if (mode == 0) incoming.final = false;
|
||||
if (mode == 1) incoming.type = HTTPD_WS_TYPE_TEXT;
|
||||
if (mode == 2) incoming.len = WEB_ADMIN_RX_CAPACITY + 1;
|
||||
assert(frame_handler(&request) != ESP_OK && !fed_length && !console_live);
|
||||
}
|
||||
ok("fragmented, text and oversized input rejected before payload feed");
|
||||
|
||||
reset(); start(); admit(); incoming.payload = bytes; incoming.len = 4; feed_limit = 0;
|
||||
assert(frame_handler(&request) == ESP_OK);
|
||||
assert(frame_handler(&request) != ESP_OK && !fed_length && !console_live);
|
||||
ok("second frame rejected while input buffer occupied");
|
||||
|
||||
reset(); start(); admit(); httpd_owner = false;
|
||||
web_admin_transport_revoke(99, NULL, 0); assert(console_live);
|
||||
web_admin_transport_revoke(0, (const uint8_t *)"other", 5); assert(console_live);
|
||||
web_admin_transport_revoke(0, (const uint8_t *)"admin", 5);
|
||||
assert(!console_live && s_slot.close_requested && !closes); httpd_owner = true;
|
||||
tick(); work(); assert(closes == 1);
|
||||
ok("session/account revocation isolation and HTTPD-only close request");
|
||||
|
||||
reset(); start(); admit(); session_current = false; tick(); work();
|
||||
assert(!console_live && closes == 1 && !sends);
|
||||
reset(); start(); admit(); memcpy(output, "secret", 6); output_length = 6;
|
||||
revoke_on_send = true; tick(); work();
|
||||
assert(!sends && !console_live && zeroed(s_payload->tx, sizeof(s_payload->tx)));
|
||||
reset(); start(); admit(); check_hook = revoke_check;
|
||||
assert(!owner_current(&s_slot.token, &auth_view.principal));
|
||||
ok("idle expiry, revocation between ring read and send, currentness recheck");
|
||||
|
||||
reset(); start(); admit(); send_fail = true; output[0] = 1; output_length = 1;
|
||||
tick(); work(); assert(s_counts.send_failures == 1 && closes == 1 && !console_live);
|
||||
reset(); start(); admit(); queue_fail = true; tick();
|
||||
assert(!s_queued && !s_submitting && s_slot.close_requested && s_counts.queue_failures == 1);
|
||||
queue_fail = false; tick(); work(); assert(!console_live && closes == 1);
|
||||
ok("send failure and queue failure close/retry paths");
|
||||
|
||||
reset(); start(); admit(); request_close(); shutdown_fail = true;
|
||||
tick(); work(); assert(closes == 1 && !s_slot.close_triggered && !queued_work);
|
||||
shutdown_fail = false; tick(); work(); assert(closes == 2 && s_slot.close_triggered && !queued_work);
|
||||
disconnected(); upgrades = 0; admit();
|
||||
tick(); work(); assert(closes == 2 && console_live && !s_slot.close_requested);
|
||||
ok("HTTPD-owned shutdown retries without queuing a reusable HTTPD slot pointer or closing replacement");
|
||||
|
||||
reset(); start(); admit(); queue_fail = true; queue_hook = replace_in_submit;
|
||||
tick();
|
||||
assert(s_counts.queue_failures == 1 && !s_queued && !s_submitting && !queued_work);
|
||||
assert(s_slot.active && console_live && !s_slot.close_requested && !closes);
|
||||
queue_fail = false; memcpy(output, "replacement", 11); output_length = 11;
|
||||
tick(); work();
|
||||
assert(sends == 1 && sent_length == 11 && !memcmp(sent, "replacement", 11));
|
||||
assert(console_live && !s_slot.close_requested && !closes);
|
||||
ok("failed timer submission cannot close HTTPD-replaced generation; replacement poll recovers");
|
||||
|
||||
reset(); start(); admit(); tick(); httpd_owner = false;
|
||||
assert(web_admin_transport_detach(SERVER) == ESP_OK && !console_live);
|
||||
tick(); assert(queues == 1); httpd_owner = true; work(); assert(!sends && !s_queued);
|
||||
disconnected(); httpd_owner = false; web_admin_transport_stopped(SERVER);
|
||||
assert(!s_server && !s_queued); assert(web_admin_transport_attach(SERVER) == ESP_OK);
|
||||
httpd_owner = true;
|
||||
ok("detach stops acceptance/submission; stale queued work no-ops before successful stop");
|
||||
|
||||
reset(); start(); admit(); queue_hook = detach_in_submit; tick();
|
||||
assert(s_queued && !s_submitting && !s_accepting);
|
||||
assert(web_admin_transport_detach(SERVER) == ESP_OK);
|
||||
/* Simulate successful HTTPD stop: queued callbacks are discarded, context freed. */
|
||||
queued_work = NULL; queued_argument = NULL; disconnected();
|
||||
httpd_owner = false; web_admin_transport_stopped(SERVER);
|
||||
assert(!s_server && !s_queued && web_admin_transport_attach(SERVER) == ESP_OK);
|
||||
httpd_owner = true;
|
||||
ok("submission fence timeout/retry and stopped retirement of unexecuted callback");
|
||||
|
||||
reset(); start(); admit();
|
||||
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_NOT_SUPPORTED);
|
||||
assert(console_live && !s_slot.close_requested);
|
||||
s_slot.sending = true; assert(!owner_drained(&s_slot.token)); s_slot.sending = false;
|
||||
assert(owner_drained(&s_slot.token)); httpd_owner = false;
|
||||
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_SELF_CLOSE, 0) == ESP_OK);
|
||||
assert(!console_live && !closes); httpd_owner = true;
|
||||
ok("unsupported deferred action has no side effects; self-close notifier and drain guard");
|
||||
|
||||
reset(); start(); admit(); incoming.len = 0;
|
||||
assert(frame_handler(&request) == ESP_OK);
|
||||
assert(receive_headers == 1 && !fed_length && console_live && !s_slot.close_requested);
|
||||
incoming.payload = bytes; incoming.len = sizeof(bytes);
|
||||
assert(frame_handler(&request) == ESP_OK && receive_headers == 2);
|
||||
assert(fed_length == sizeof(bytes) && !memcmp(fed, bytes, sizeof(bytes)));
|
||||
ok("empty binary frame parsed once; following nonempty frame feeds normally");
|
||||
free(s_payload); s_payload = NULL;
|
||||
printf("%u groups passed\n", cases);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# Web authentication parser host tests
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
python3 tests/web_auth_parse/run.py
|
||||
```
|
||||
|
||||
Requires Python 3 (standard library only) and a host `cc` supporting shared
|
||||
libraries. The runner compiles the actual `src/web_auth_parse.c` with
|
||||
`-std=c11 -Wall -Wextra -Werror -shared -fPIC` into a temporary directory, loads
|
||||
it with `ctypes`, and removes build artifacts on exit. No firmware dependencies,
|
||||
parser substitutes, network access, or persistent build artifacts are used.
|
||||
Compilation errors and test failures produce nonzero exit status.
|
||||
|
||||
Tables cover DNS/IPv4 origin canonicalization, case folding and optional `:443`,
|
||||
malformed authorities/origins and unsupported IPv6; selected cookie presence,
|
||||
uniqueness, exact lowercase 64-digit hex and surrounding cookie syntax; strict
|
||||
login JSON, both field orders, escapes, Unicode/surrogate pairs, invalid UTF-8,
|
||||
NUL, duplicate/unknown fields, truncation and byte limits (512-byte body,
|
||||
16-byte username, 64-byte password). Boundary cases include canonical origin
|
||||
capacity and the 1024-byte Cookie header limit.
|
||||
|
||||
Inputs use exact byte spans without implicit terminators and never alias
|
||||
outputs. Each output is first filled with `0xA5`; every failed call must clear
|
||||
**all** output bytes, including credential structure padding and unused array
|
||||
bytes. Successful results check canonical/decoded bytes and termination.
|
||||
|
||||
## Limitations
|
||||
|
||||
- This is a focused parser contract suite, not HTTP integration, authorization,
|
||||
CSRF/session, duplicate HTTP header-line, TLS, credential-policy, or hardware
|
||||
testing. Empty credentials are syntactically valid; database policy is separate.
|
||||
- IPv6 is intentionally rejected, not normalized or supported.
|
||||
- Python mirrors the public C struct and capacities; interface changes must
|
||||
update these tests. Shared-library loading assumes a Unix-like host/compiler.
|
||||
- Tables are not exhaustive fuzzing, memory-safety instrumentation, or proof of
|
||||
constant-time behavior. Output wiping is observed after return; this does not
|
||||
establish erasure of internal temporaries or successful credentials. Test
|
||||
credentials/tokens are synthetic, not secrets.
|
||||
- NULL output pointers and overlapping buffers are not exercised: valid,
|
||||
disjoint output storage is provided for every call.
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dependency-free contract tests against the production parser, not a model."""
|
||||
import ctypes as C
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class Credentials(C.Structure):
|
||||
_fields_ = [("username_length", C.c_size_t), ("password_length", C.c_size_t),
|
||||
("username", C.c_uint8 * 17), ("password", C.c_uint8 * 65)]
|
||||
|
||||
|
||||
def span(value):
|
||||
# No implicit NUL terminator; keep the allocation alive throughout the call.
|
||||
return None if value is None else (C.c_char * max(1, len(value))).from_buffer_copy(value or b"\0")
|
||||
|
||||
|
||||
def size(value):
|
||||
return 0 if value is None else len(value)
|
||||
|
||||
|
||||
def login(username=b"u", password=b"p"):
|
||||
return b'{"username":"' + username + b'","password":"' + password + b'"}'
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory(prefix="web-auth-parse-") as temporary:
|
||||
library = Path(temporary) / "parser.so"
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
"-shared", "-fPIC", "-I", str(ROOT / "src"),
|
||||
str(ROOT / "src/web_auth_parse.c"), "-o", str(library)], check=True)
|
||||
api = C.CDLL(str(library))
|
||||
api.web_auth_parse_origin.argtypes = [C.c_void_p, C.c_size_t, C.c_void_p, C.c_size_t, C.c_void_p]
|
||||
api.web_auth_parse_cookie.argtypes = [C.c_void_p, C.c_size_t, C.c_char_p, C.c_void_p]
|
||||
api.web_auth_parse_login.argtypes = [C.c_void_p, C.c_size_t, C.POINTER(Credentials)]
|
||||
for name in ("origin", "cookie", "login"):
|
||||
getattr(api, "web_auth_parse_" + name).restype = C.c_bool
|
||||
failures, count = [], 0
|
||||
|
||||
def check(kind, label, invoke, output, expected, extract):
|
||||
nonlocal count
|
||||
count += 1
|
||||
C.memset(C.addressof(output), 0xA5, C.sizeof(output))
|
||||
result = invoke()
|
||||
raw = C.string_at(C.addressof(output), C.sizeof(output))
|
||||
if result != (expected is not None):
|
||||
failures.append(f"{kind}: {label}: unexpected success={result}")
|
||||
if not result and raw != bytes(len(raw)):
|
||||
failures.append(f"{kind}: {label}: failure did not wipe every output byte")
|
||||
if result and expected is not None and extract(output) != expected:
|
||||
failures.append(f"{kind}: {label}: incorrect decoded/canonical output")
|
||||
|
||||
origins = [(b"EXAMPLE.Com", b"https://example.com", b"https://example.com"),
|
||||
(b"a-b.local", b"https://A-B.LOCAL", b"https://a-b.local"),
|
||||
(b"localhost", b"https://localhost", b"https://localhost"),
|
||||
(b"192.168.1.1", b"https://192.168.1.1", b"https://192.168.1.1")]
|
||||
for host in (b"EXAMPLE.COM", b"192.168.1.1"):
|
||||
for hp in (b"", b":443"):
|
||||
for op in (b"", b":443"):
|
||||
origins.append((host + hp, b"https://" + host.lower() + op,
|
||||
b"https://" + host.lower()))
|
||||
bad_hosts = [b"", b" ", b"example.com ", b" example.com", b"a..b", b".a", b"a.",
|
||||
b"-a", b"a-", b"a_b", b"a/b", b"a?b", b"a#b", b"u@a", b"a,b",
|
||||
b"a\\b", b"a\tb", b"a\r\nb", b"a\0b", b"caf\xc3\xa9", b"[::1]", b"::1",
|
||||
b"[::1]:443", b"a:80", b"a:444", b"a:", b"a:0443", b"a:+443",
|
||||
b"a:443:443", b"a" * 64 + b".com", b"a" * 130]
|
||||
origins += [(h, b"https://" + h, None) for h in bad_hosts]
|
||||
origins += [(b"example.com", o, None) for o in
|
||||
(None, b"", b"null", b"http://example.com", b"https://other.com",
|
||||
b"https://example.com/", b"https://example.com/path", b"https://example.com?x",
|
||||
b"https://example.com#x", b"https://u@example.com", b"https://example.com:80",
|
||||
b"https://example.com:0443", b" https://example.com", b"https://example.com ",
|
||||
b"https://example.com\0", b"https://example.com\r\n", b"https://[::1]",
|
||||
b"https://example.com https://example.com", b"https:///example.com")]
|
||||
origins += [(None, b"https://example.com", None)]
|
||||
# 128 canonical bytes fit with the terminator; 129 do not.
|
||||
for n in (56, 57):
|
||||
host = b"a" * 63 + b"." + b"b" * n
|
||||
for hp in (b"", b":443"):
|
||||
for op in (b"", b":443"):
|
||||
origins.append((host + hp, b"https://" + host + op,
|
||||
b"https://" + host if n == 56 else None))
|
||||
for index, (host, origin, expected) in enumerate(origins):
|
||||
h, o, out = span(host), span(origin), C.create_string_buffer(129)
|
||||
check("origin", str(index), lambda: api.web_auth_parse_origin(h, size(host), o, size(origin), out),
|
||||
out, expected, lambda x: x.value)
|
||||
|
||||
token = b"0123456789abcdef" * 4
|
||||
selected = b"sid=" + token
|
||||
cookies = [(selected, token), (b"x=1; " + selected + b"; y=two", token),
|
||||
(b"sid2=other; " + selected, token), (b"SID=other; " + selected, token),
|
||||
(b"empty=; " + selected, token)]
|
||||
cookies += [(h, None) for h in
|
||||
(None, b"", b"x=1", b"SID=" + token, b"sid2=" + token,
|
||||
selected + b"; " + selected, selected + b"; sid=bad", b"sid=bad; " + selected,
|
||||
b"sid=" + token[:-1], selected + b"0", b"sid=" + token.upper(),
|
||||
b"sid=" + b"g" * 64, b'sid="' + token + b'"', b"sid=", b"sid",
|
||||
selected + b"; broken", b"broken; " + selected, selected + b"; =x",
|
||||
selected + b"; bad name=x", selected + b"; x=bad,value",
|
||||
selected + b"; x=bad\\value", selected + b"\r\n", selected + b"\0",
|
||||
selected + b"; x=\x01", selected + b"; x=\x7f", selected + b"; x=\xff")]
|
||||
for length in (1024, 1025):
|
||||
cookies.append((selected + b"; x=" + b"a" * (length - len(selected) - 4),
|
||||
token if length == 1024 else None))
|
||||
for index, (header, expected) in enumerate(cookies):
|
||||
h, out = span(header), C.create_string_buffer(65)
|
||||
check("cookie", str(index), lambda: api.web_auth_parse_cookie(h, size(header), b"sid", out),
|
||||
out, expected, lambda x: x.value)
|
||||
|
||||
cases = [(login(), (b"u", b"p")), (login(b"", b""), (b"", b"")),
|
||||
(b' \r\n\t{ "password" : "p", "username" : "u" } \t', (b"u", b"p")),
|
||||
(b'{"user\\u006eame":"u","pass\\u0077ord":"p"}', (b"u", b"p")),
|
||||
(login(b'\\"\\\\\\/\\b\\f\\n\\r\\t', b"\\u0041"), (b'"\\/\b\f\n\r\t', b"A")),
|
||||
(login(b"\\u00e9", b"\\ud83d\\ude00"), ("é".encode(), "😀".encode()))]
|
||||
for text in ("é", "€", "😀", "\U0010ffff"):
|
||||
for ascii_only in (True, False):
|
||||
body = json.dumps({"username": text, "password": text}, ensure_ascii=ascii_only).encode()
|
||||
cases.append((body, (text.encode(), text.encode())))
|
||||
for n in (15, 16, 17):
|
||||
cases.append((login(b"a" * n), (b"a" * n, b"p") if n <= 16 else None))
|
||||
cases.append((login(b"\\u0061" * n), (b"a" * n, b"p") if n <= 16 else None))
|
||||
for n in (63, 64, 65):
|
||||
cases.append((login(password=b"a" * n), (b"u", b"a" * n) if n <= 64 else None))
|
||||
cases.append((login(password=b"\\u0061" * n), (b"u", b"a" * n) if n <= 64 else None))
|
||||
for field, limit in (("username", 16), ("password", 64)):
|
||||
for extra in (b"", b"a"):
|
||||
value = "😀".encode() * (limit // 4) + extra
|
||||
u, p = (value, b"p") if field == "username" else (b"u", value)
|
||||
cases.append((login(u, p), (u, p) if not extra else None))
|
||||
for n in (511, 512, 513):
|
||||
cases.append((login() + b" " * (n - len(login())), (b"u", b"p") if n <= 512 else None))
|
||||
bad_json = [None, b"", b"{}", b"[]", b"null", b'{"username":"u"}', b'{"password":"p"}',
|
||||
login() + b"x", login() + login(), b"\xef\xbb\xbf" + login(),
|
||||
login()[:-1] + b',}', login()[:-1] + b',"extra":"x"}',
|
||||
login()[:-1] + b',"username":"v"}', login()[:-1] + b',"password":"q"}',
|
||||
login()[:-1] + b',"user\\u006eame":"v"}',
|
||||
b'{"username":1,"password":"p"}', b'{"username":"u","password":null}',
|
||||
b'{"username":[],"password":"p"}', b'{"username":"u" "password":"p"}',
|
||||
b"{'username':'u','password':'p'}", b'/*x*/' + login()]
|
||||
for value in (b"\0", b"\\u0000", b"\\x41", b"\\q", b"\\u123", b"\\uZZZZ",
|
||||
b"\\ud800", b"\\udc00", b"\\ud800\\u0041", b"\\udc00\\ud800",
|
||||
b"\x80", b"\xc0\xaf", b"\xc1\xbf", b"\xc2", b"\xe2\x82", b"\xf0\x9f\x98",
|
||||
b"\xe0\x80\x80", b"\xed\xa0\x80", b"\xf0\x80\x80\x80",
|
||||
b"\xf4\x90\x80\x80", b"\xf5\x80\x80\x80", b"\xff", b"\xc2A"):
|
||||
bad_json.extend((login(value), login(password=value)))
|
||||
bad_json += [login(bytes([n])) for n in range(1, 32)]
|
||||
# Every truncated prefix of a valid document must fail, including late failures.
|
||||
bad_json += [login()[:n] for n in range(len(login()))]
|
||||
bad_json += [login() + b"\0", b'{"user\xffname":"u","password":"p"}']
|
||||
cases += [(body, None) for body in bad_json]
|
||||
|
||||
def decoded(out):
|
||||
u, p = out.username_length, out.password_length
|
||||
if u > 16 or p > 64 or out.username[u] or out.password[p]:
|
||||
return "invalid lengths or missing terminator"
|
||||
return bytes(out.username[:u]), bytes(out.password[:p])
|
||||
|
||||
for index, (body, expected) in enumerate(cases):
|
||||
b, out = span(body), Credentials()
|
||||
check("login", str(index), lambda: api.web_auth_parse_login(b, size(body), C.byref(out)),
|
||||
out, expected, decoded)
|
||||
for failure in failures:
|
||||
print("FAIL:", failure)
|
||||
print(f"{count} cases; {len(failures)} failures")
|
||||
return bool(failures)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,17 @@
|
||||
# Cookie authentication and HTTPD adapter host checks
|
||||
|
||||
Run from the project root:
|
||||
|
||||
```sh
|
||||
python3 tests/web_cookie_auth/run.py
|
||||
```
|
||||
|
||||
Requires Python 3, a C11 compiler (`cc`), OpenSSL headers/libcrypto, and the pinned ESP-IDF source installation. The runner uses `IDF_PATH` when set, otherwise `~/.platformio/packages/framework-espidf`. It writes only an automatically removed temporary directory. No network, device, pip/npm packages or server is needed. Do not disable C assertions.
|
||||
|
||||
The runner compiles production `web_cookie_auth`, `web_session_store`, `web_auth_parse` and `web_httpd_adapter` with bounded HTTPD/database/time/RNG doubles. It also executes the session-store public API suite. The installed IDF header getters, append-only response-header setter and right-aligned pending-data reader are extracted verbatim and compiled into the harness.
|
||||
|
||||
Coverage includes challenge reuse/consumption/expiry, capacities without eviction, global throttle, fragmented login bodies, secure cookie attributes and two simultaneous Set-Cookie fields, session-specific logout, duplicate fields/cookies, Origin/CSRF/method/Fetch Metadata rejection, Basic denial, currentness, stop/login and failure paths, six-header login budget, upgrade-state installation, and request cleanup preserving all 0–128 pending lengths through partial reads.
|
||||
|
||||
This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, browser, multicore task or hardware test. The private struct doubles do not prove binary layout; firmware compilation uses the actual pinned headers, and the version guard requires a new audit on SDK changes. Handshake sending and transport revocation are doubled. Actual on-wire pre-101 rejection, frame routing, pipelining/early bytes, cookie/CSP/browser recovery and loaded expiry latency remain M1 target gates. No sanitizer or runtime memory-reserve result is implied.
|
||||
|
||||
See `docs/phase8d3_implementation.md` for source verification, other suite commands, build accounting and the target checklist.
|
||||
@@ -0,0 +1,139 @@
|
||||
/* Real cookie policy/store/tickets/transport; only console and runtime IO doubled. */
|
||||
#include <stdlib.h>
|
||||
#include <sys/socket.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "web_admin_tickets.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
static bool console_active;
|
||||
static const admin_console_owner_t *admin_owner;
|
||||
static admin_ssh_console_token_t admin_token;
|
||||
static void (*timer_poll)(void *), (*pending_poll)(void *);
|
||||
static void *pending_argument;
|
||||
static httpd_req_t connected;
|
||||
static unsigned admin_closes;
|
||||
void *heap_caps_calloc(size_t n, size_t size, unsigned caps) {
|
||||
assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); return calloc(n, size);
|
||||
}
|
||||
void heap_caps_free(void *p) { free(p); }
|
||||
esp_err_t esp_timer_create(const esp_timer_create_args_t *args, esp_timer_handle_t *timer) {
|
||||
timer_poll = args->callback; *timer = &server; return ESP_OK;
|
||||
}
|
||||
esp_err_t esp_timer_start_periodic(esp_timer_handle_t timer, uint64_t us) {
|
||||
assert(timer && us == 20000); return ESP_OK;
|
||||
}
|
||||
esp_err_t esp_timer_delete(esp_timer_handle_t timer) { (void)timer; return ESP_OK; }
|
||||
void vTaskDelay(TickType_t ticks) { now += ticks * 1000; }
|
||||
esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal, const admin_console_owner_t *owner) {
|
||||
assert(principal->role == USER_ROLE_ADMIN && !console_active);
|
||||
token->slot_index = 1; admin_token = *token; admin_owner = owner;
|
||||
console_active = true; return ESP_OK;
|
||||
}
|
||||
void admin_ssh_console_close(const admin_ssh_console_token_t *token) {
|
||||
if (!memcmp(token, &admin_token, sizeof(*token))) console_active = false;
|
||||
}
|
||||
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
const uint8_t *data, size_t length, size_t *consumed) {
|
||||
(void)token; (void)data; *consumed = length; return true;
|
||||
}
|
||||
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
|
||||
uint8_t *data, size_t capacity, size_t *received) {
|
||||
(void)token; (void)data; (void)capacity; *received = 0; return ESP_OK;
|
||||
}
|
||||
esp_err_t admin_ssh_console_get_session_snapshot(const admin_ssh_console_token_t *token,
|
||||
admin_ssh_console_session_snapshot_t *snapshot) {
|
||||
(void)token; *snapshot = (admin_ssh_console_session_snapshot_t){.active = console_active}; return ESP_OK;
|
||||
}
|
||||
int httpd_req_to_sockfd(httpd_req_t *request) { (void)request; return 12; }
|
||||
void *httpd_sess_get_ctx(httpd_handle_t handle, int fd) {
|
||||
assert(handle == &server && fd == 12); return connected.sess_ctx;
|
||||
}
|
||||
httpd_ws_client_info_t httpd_ws_get_fd_info(httpd_handle_t handle, int fd) {
|
||||
(void)handle; (void)fd; return HTTPD_WS_CLIENT_WEBSOCKET;
|
||||
}
|
||||
int shutdown(int fd, int how) {
|
||||
assert(fd == 12 && how == SHUT_RDWR); ++admin_closes; return 0;
|
||||
}
|
||||
esp_err_t httpd_queue_work(httpd_handle_t handle, void (*work)(void *), void *arg) {
|
||||
assert(handle == &server && !pending_poll); pending_poll = work; pending_argument = arg; return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_ws_recv_frame(httpd_req_t *request, httpd_ws_frame_t *frame, size_t size) {
|
||||
(void)request; (void)frame; (void)size; return ESP_FAIL;
|
||||
}
|
||||
esp_err_t httpd_ws_send_frame_async(httpd_handle_t handle, int fd, httpd_ws_frame_t *frame) {
|
||||
(void)handle; (void)fd; (void)frame; return ESP_OK;
|
||||
}
|
||||
static void admin_poll(void) {
|
||||
timer_poll(NULL); assert(pending_poll);
|
||||
void (*work)(void *) = pending_poll; pending_poll = NULL; work(pending_argument);
|
||||
}
|
||||
static void admin_request(const issued_t *session, const char *uri, bool mutation,
|
||||
bool with_origin, bool with_csrf) {
|
||||
begin(uri, mutation ? HTTP_POST : HTTP_GET, NULL);
|
||||
add("Host", "device.example");
|
||||
if (with_origin) add("Origin", origin);
|
||||
if (session) {
|
||||
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", session->token);
|
||||
add("Cookie", cookies);
|
||||
if (with_csrf) add("X-CSRF-Token", session->view.csrf);
|
||||
}
|
||||
if (!mutation) {
|
||||
aux.ws_handshake_detect = true;
|
||||
add("Sec-WebSocket-Version", "13");
|
||||
add("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
|
||||
}
|
||||
}
|
||||
static void admin_tests(void) {
|
||||
auth_reset(); assert(web_admin_transport_init() == ESP_OK);
|
||||
assert(web_admin_transport_attach(&server) == ESP_OK);
|
||||
user_principal_t administrator = alice; administrator.role = USER_ROLE_ADMIN;
|
||||
issued_t user = mint(&bob), admin = mint(&administrator), other = mint(&administrator);
|
||||
unsigned before = upgrades;
|
||||
for (unsigned mode = 0; mode < 5; ++mode) {
|
||||
admin_request(mode == 0 ? NULL : mode == 1 ? &user : &admin,
|
||||
WEB_ADMIN_TICKET_URI, true, mode != 2, mode != 3);
|
||||
if (mode == 4) add("Origin", origin);
|
||||
(void)web_admin_transport_ticket_handler(&req);
|
||||
assert(strcmp(response_status, "200 OK") && upgrades == before);
|
||||
web_admin_tickets_snapshot_t tickets; web_admin_tickets_get_snapshot(&tickets); assert(!tickets.active);
|
||||
}
|
||||
admin_request(&admin, WEB_ADMIN_TICKET_URI, true, true, true);
|
||||
assert(web_admin_transport_ticket_handler(&req) == ESP_OK && !strcmp(response_status, "200 OK"));
|
||||
char ticket[65], uri[128]; const char *at = strstr(output, "\"ticket\":\""); assert(at);
|
||||
memcpy(ticket, at + 10, 64); ticket[64] = 0;
|
||||
snprintf(uri, sizeof(uri), "%s?ticket=%s", WEB_ADMIN_WS_URI, ticket);
|
||||
for (unsigned mode = 0; mode < 4; ++mode) {
|
||||
admin_request(mode == 0 ? NULL : mode == 1 ? &user : &admin, uri, false, mode != 2, false);
|
||||
if (mode == 3) add("Cookie", "ambiguous");
|
||||
(void)web_admin_transport_upgrade_handler(&req);
|
||||
assert(upgrades == before && !console_active);
|
||||
}
|
||||
admin_request(&other, uri, false, true, false);
|
||||
assert(web_admin_transport_upgrade_handler(&req) != ESP_OK && upgrades == before);
|
||||
admin_request(&admin, uri, false, true, false);
|
||||
assert(web_admin_transport_upgrade_handler(&req) != ESP_OK && upgrades == before); /* burned */
|
||||
puts("PASS: combined admin endpoints reject missing cookie/Origin/CSRF, duplicates, user role and cross-session ticket replay before 101");
|
||||
for (unsigned mode = 0; mode < 3; ++mode) {
|
||||
assert(web_admin_tickets_issue(admin.view.id, &administrator, ticket) == ESP_OK);
|
||||
snprintf(uri, sizeof(uri), "%s?ticket=%s", WEB_ADMIN_WS_URI, ticket);
|
||||
admin_request(&admin, uri, false, true, false);
|
||||
assert(web_admin_transport_upgrade_handler(&req) == ESP_OK && upgrades == ++before);
|
||||
connected = req; assert(console_active && admin_owner->is_current(&admin_token, &administrator));
|
||||
if (mode == 0) {
|
||||
admin_request(&admin, "/api/logout", true, true, true); expect("204 No Content");
|
||||
assert(!console_active); present(&other);
|
||||
} else if (mode == 1) stale_user = administrator.user_id;
|
||||
else now = admin.view.expires_at_us;
|
||||
admin_poll(); assert(!console_active && admin_closes == mode + 1);
|
||||
connected.free_ctx(connected.sess_ctx); memset(&connected, 0, sizeof(connected));
|
||||
stale_user = 0;
|
||||
if (mode < 2) { web_session_store_invalidate(admin.view.id); admin = mint(&administrator); }
|
||||
}
|
||||
assert(web_admin_transport_detach(&server) == ESP_OK);
|
||||
web_admin_transport_stopped(&server);
|
||||
assert(web_admin_transport_attach(&server) == ESP_OK);
|
||||
puts("PASS: real ticket-to-101 admission, isolated logout notification, missed account revocation, absolute expiry, cleanup and restart");
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Production policy/store/adapter with bounded HTTPD and database doubles.
|
||||
|
||||
Getter and append-header functions are extracted verbatim from installed IDF,
|
||||
not reimplemented with convenient merging or overwrite semantics.
|
||||
"""
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
os.environ["CCACHE_DISABLE"] = "1"
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
sys.path.insert(0, str(HERE.parent / "web_session_store"))
|
||||
from run import HEADERS
|
||||
from serial_headers import SERIAL_HEADERS
|
||||
|
||||
HEADERS.update(SERIAL_HEADERS)
|
||||
HEADERS["esp_http_server.h"] += """
|
||||
#define ESP_ERR_HTTPD_INVALID_REQ 0x200
|
||||
#define ESP_ERR_HTTPD_RESULT_TRUNC 0x201
|
||||
#define ESP_ERR_HTTPD_RESP_HDR 0x202
|
||||
esp_err_t httpd_resp_sendstr(httpd_req_t *, const char *);
|
||||
int httpd_req_recv(httpd_req_t *, char *, size_t);
|
||||
"""
|
||||
HEADERS["esp_idf_version.h"] = """
|
||||
#define ESP_IDF_VERSION_VAL(a,b,c) ((a)*10000+(b)*100+(c))
|
||||
#define ESP_IDF_VERSION ESP_IDF_VERSION_VAL(5,5,0)
|
||||
"""
|
||||
HEADERS["esp_httpd_priv.h"] = """#pragma once
|
||||
#include "esp_http_server.h"
|
||||
struct sock_db { bool ws_handshake_done; esp_err_t (*ws_handler)(httpd_req_t *);
|
||||
bool ws_control_frames; void *ws_user_ctx; char pending_data[128]; size_t pending_len; };
|
||||
struct httpd_req_aux { struct sock_db *sd; char *scratch; size_t scratch_cur_size, remaining_len;
|
||||
unsigned req_hdrs_count, resp_hdrs_count; bool ws_handshake_detect;
|
||||
struct resp_hdr { const char *field, *value; } *resp_hdrs; };
|
||||
struct httpd_data { struct { unsigned max_resp_headers; } config; };
|
||||
esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
|
||||
"""
|
||||
|
||||
admin = "--admin" in sys.argv
|
||||
if admin:
|
||||
HEADERS["esp_heap_caps.h"] = """#pragma once
|
||||
#include <stddef.h>
|
||||
#define MALLOC_CAP_SPIRAM 1
|
||||
#define MALLOC_CAP_8BIT 2
|
||||
void *heap_caps_calloc(size_t, size_t, unsigned);
|
||||
void heap_caps_free(void *);
|
||||
"""
|
||||
HEADERS["esp_timer.h"] += """
|
||||
#include <stdbool.h>
|
||||
typedef void *esp_timer_handle_t;
|
||||
typedef struct { void (*callback)(void *); const char *name; bool skip_unhandled_events; } esp_timer_create_args_t;
|
||||
int esp_timer_create(const esp_timer_create_args_t *, esp_timer_handle_t *);
|
||||
int esp_timer_start_periodic(esp_timer_handle_t, uint64_t);
|
||||
int esp_timer_delete(esp_timer_handle_t);
|
||||
"""
|
||||
|
||||
def function(source, name):
|
||||
start = source.index(name + "(")
|
||||
start = source.rfind("\n", 0, start) + 1
|
||||
end = source.index("\n}", start) + 2
|
||||
return source[start:end]
|
||||
|
||||
idf = pathlib.Path(os.environ.get("IDF_PATH", str(pathlib.Path.home() / ".platformio/packages/framework-espidf")))
|
||||
parse = (idf / "components/esp_http_server/src/httpd_parse.c").read_text()
|
||||
txrx = (idf / "components/esp_http_server/src/httpd_txrx.c").read_text()
|
||||
extracted = """
|
||||
#pragma GCC diagnostic ignored "-Wsign-compare"
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include "esp_httpd_priv.h"
|
||||
#define ESP_LOGD(...) ((void)0)
|
||||
#define MIN(a,b) ((a) < (b) ? (a) : (b))
|
||||
static bool httpd_valid_req(httpd_req_t *r) { return r && r->aux; }
|
||||
static size_t strlcpy(char *d, const char *s, size_t n) {
|
||||
size_t len = strlen(s); if (n) { size_t m = len < n-1 ? len : n-1; memcpy(d,s,m); d[m]=0; } return len;
|
||||
}
|
||||
"""
|
||||
for name in ["httpd_req_get_hdr_value_len", "httpd_req_get_hdr_value_str"]:
|
||||
# The declarations start at line beginning; avoid earlier calls in parser.
|
||||
prefix = "size_t " if name.endswith("len") else "esp_err_t "
|
||||
extracted += function(parse[parse.index(prefix + name):], name) + "\n"
|
||||
extracted += function(txrx[txrx.index("esp_err_t httpd_resp_set_hdr"):], "httpd_resp_set_hdr")
|
||||
extracted += "\n" + function(txrx[txrx.index("static size_t httpd_recv_pending"):], "httpd_recv_pending")
|
||||
extracted += "\nsize_t host_read_pending(httpd_req_t *r, char *out, size_t n) { return httpd_recv_pending(r, out, n); }\n"
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
tmp = pathlib.Path(directory)
|
||||
for name, text in HEADERS.items():
|
||||
path = tmp / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
(tmp / "installed_httpd.c").write_text(extracted)
|
||||
sources = [HERE / "test.c", tmp / "installed_httpd.c"]
|
||||
sources += [ROOT / "src" / name for name in ["web_session_store.c", "web_auth_parse.c", "web_cookie_auth.c", "web_httpd_adapter.c"]]
|
||||
if admin:
|
||||
sources += [ROOT / "src" / name for name in ["web_admin_tickets.c", "web_admin_transport.c"]]
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g", "-DHOST_OPENSSL",
|
||||
*(["-DHOST_ADMIN"] if admin else []),
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||
@@ -0,0 +1,271 @@
|
||||
/* Production store dependency doubles and its existing public API suite. */
|
||||
#define main store_tests
|
||||
#include "../web_session_store/test.c"
|
||||
#undef main
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "esp_httpd_priv.h"
|
||||
#ifdef HOST_ADMIN
|
||||
#include "web_admin_transport.h"
|
||||
#endif
|
||||
|
||||
static struct httpd_data server = {.config.max_resp_headers = 8};
|
||||
static struct sock_db socket_state;
|
||||
static struct resp_hdr response_headers[8];
|
||||
static char scratch[1024], output[1024], cookie_values[2][200];
|
||||
static const char *request_body;
|
||||
static size_t body_offset;
|
||||
static unsigned password_calls, cookie_count, sends, upgrades;
|
||||
static unsigned fail_header, setter_calls;
|
||||
static bool send_fail, recv_fail;
|
||||
static void (*password_hook)(void);
|
||||
static char response_status[48];
|
||||
static struct httpd_req_aux aux;
|
||||
static httpd_req_t req;
|
||||
size_t host_read_pending(httpd_req_t *r, char *out, size_t n);
|
||||
|
||||
esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *status) {
|
||||
(void)r; if (fail_header && ++setter_calls == fail_header) return ESP_FAIL;
|
||||
snprintf(response_status, sizeof(response_status), "%s", status); return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *type) {
|
||||
(void)r; (void)type; return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) {
|
||||
(void)r; ++sends; assert(strlen(body) < sizeof(output)); strcpy(output, body);
|
||||
cookie_count = 0;
|
||||
for (unsigned i = 0; i < aux.resp_hdrs_count; ++i) {
|
||||
assert(response_headers[i].value);
|
||||
assert(strcmp(response_headers[i].field, "WWW-Authenticate"));
|
||||
if (!strcmp(response_headers[i].field, "Set-Cookie")) {
|
||||
assert(cookie_count < 2);
|
||||
snprintf(cookie_values[cookie_count++], 200, "%s", response_headers[i].value);
|
||||
}
|
||||
}
|
||||
return send_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
int httpd_req_recv(httpd_req_t *r, char *out, size_t size) {
|
||||
(void)r; if (recv_fail) return -1;
|
||||
if (size > 7) size = 7; /* Fragment every login body. */
|
||||
memcpy(out, request_body + body_offset, size); body_offset += size;
|
||||
aux.remaining_len -= size; return (int)size;
|
||||
}
|
||||
esp_err_t web_login_ui_send_response(httpd_req_t *r) { return httpd_resp_sendstr(r, "login document"); }
|
||||
esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id) {
|
||||
web_session_store_invalidate(id);
|
||||
#ifdef HOST_ADMIN
|
||||
web_admin_transport_revoke(id, NULL, 0);
|
||||
#endif
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *r, const char *protocol) {
|
||||
(void)r; (void)protocol; ++upgrades; return ESP_OK;
|
||||
}
|
||||
esp_err_t user_database_authenticate_password(const uint8_t *u, size_t un,
|
||||
const uint8_t *p, size_t pn, user_principal_t *principal, bool *authenticated) {
|
||||
assert(!host_lock_depth); ++password_calls;
|
||||
if (password_hook) { void (*hook)(void) = password_hook; password_hook = NULL; hook(); }
|
||||
*authenticated = un == 5 && !memcmp(u, "alice", 5) && pn == 12 && !memcmp(p, "password1234", 12);
|
||||
if (*authenticated) *principal = alice;
|
||||
return db_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
|
||||
static void begin(const char *uri, int method, const char *body) {
|
||||
memset(scratch, 0, sizeof(scratch)); memset(response_headers, 0, sizeof(response_headers));
|
||||
memset(&socket_state, 0, sizeof(socket_state));
|
||||
aux = (struct httpd_req_aux){.sd = &socket_state, .scratch = scratch,
|
||||
.scratch_cur_size = sizeof(scratch), .resp_hdrs = response_headers};
|
||||
req = (httpd_req_t){.handle = &server, .aux = &aux, .uri = uri, .method = method,
|
||||
.content_len = body ? strlen(body) : 0};
|
||||
aux.remaining_len = req.content_len;
|
||||
request_body = body; body_offset = 0;
|
||||
response_status[0] = output[0] = 0; cookie_count = 0;
|
||||
}
|
||||
static void add(const char *key, const char *value) {
|
||||
char *at = scratch;
|
||||
for (unsigned i = 0; i < aux.req_hdrs_count; ++i) at += strlen(at) + 1;
|
||||
assert((size_t)(at - scratch) + strlen(key) + strlen(value) + 3 < sizeof(scratch));
|
||||
sprintf(at, "%s: %s", key, value); ++aux.req_hdrs_count;
|
||||
}
|
||||
static void same_origin(void) { add("Host", "device.example"); add("Origin", origin); }
|
||||
static void expect(const char *status) {
|
||||
(void)web_cookie_auth_handler(&req);
|
||||
assert(!strcmp(response_status, status));
|
||||
}
|
||||
static void token_from(const char *value, char token[65]) {
|
||||
const char *start = strchr(value, '='); assert(start && strlen(start + 1) >= 64);
|
||||
memcpy(token, start + 1, 64); token[64] = 0;
|
||||
}
|
||||
static void csrf_from(char csrf[65]) {
|
||||
const char *start = strstr(output, "\"csrf\":\""); assert(start);
|
||||
memcpy(csrf, start + 8, 64); csrf[64] = 0;
|
||||
}
|
||||
static void challenge(char token[65], char csrf[65]) {
|
||||
begin("/api/login-challenge", HTTP_GET, NULL); add("Host", "device.example");
|
||||
add("X-Login-Bootstrap", "1"); expect("200 OK");
|
||||
assert(cookie_count == 1); token_from(cookie_values[0], token); csrf_from(csrf);
|
||||
}
|
||||
static const char good_body[] = "{\"username\":\"alice\",\"password\":\"password1234\"}";
|
||||
static void login_request(const char *token, const char *csrf, const char *body) {
|
||||
begin("/api/login", HTTP_POST, body); same_origin(); add("Content-Type", "application/json");
|
||||
add("X-CSRF-Token", csrf);
|
||||
char cookies[100]; snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
|
||||
}
|
||||
static void auth_reset(void) {
|
||||
web_cookie_auth_stop(); reset(); assert(web_cookie_auth_start() == ESP_OK);
|
||||
password_calls = 0; password_hook = NULL;
|
||||
}
|
||||
|
||||
#ifdef HOST_ADMIN
|
||||
#include "admin_test.c"
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
char token[65], csrf[65], session[65], cookies[200];
|
||||
challenge(token, csrf);
|
||||
begin("/api/login-challenge", HTTP_GET, NULL); add("Host", "device.example"); add("X-Login-Bootstrap", "1");
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-prelogin=%s", token); add("Cookie", cookies);
|
||||
expect("200 OK"); assert(cookie_count == 0);
|
||||
login_request(token, csrf, good_body); expect("200 OK");
|
||||
assert(password_calls == 1 && cookie_count == 2 && snapshot().active == 1);
|
||||
assert(strstr(cookie_values[0], "__Host-sak-prelogin="));
|
||||
assert(strstr(cookie_values[0], "Max-Age=0"));
|
||||
assert(strstr(cookie_values[1], "Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600"));
|
||||
token_from(cookie_values[1], session);
|
||||
begin("/api/session", HTTP_GET, NULL); same_origin();
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", session); add("Cookie", cookies);
|
||||
expect("200 OK"); csrf_from(csrf);
|
||||
issued_t other = mint(&alice);
|
||||
begin("/api/logout", HTTP_POST, NULL); same_origin(); add("Cookie", cookies); add("X-CSRF-Token", csrf);
|
||||
expect("204 No Content"); present(&other); assert(snapshot().active == 1);
|
||||
begin("/api/session", HTTP_GET, NULL); same_origin(); add("Cookie", cookies); expect("401 Unauthorized");
|
||||
puts("PASS: challenge reuse, fragmented login, two independent Set-Cookie fields, session and isolated logout");
|
||||
|
||||
auth_reset();
|
||||
for (unsigned i = 0; i < 5; ++i) {
|
||||
challenge(token, csrf); login_request(token, csrf, "{\"username\":\"alice\",\"password\":\"wrong\"}");
|
||||
expect("401 Unauthorized");
|
||||
login_request(token, csrf, good_body); expect("403 Forbidden");
|
||||
}
|
||||
challenge(token, csrf); login_request(token, csrf, good_body); expect("429 Too Many Requests");
|
||||
assert(password_calls == 5); now += 60000000;
|
||||
challenge(token, csrf); login_request(token, csrf, good_body); expect("200 OK");
|
||||
auth_reset(); for (unsigned i = 0; i < 4; ++i) challenge(token, csrf);
|
||||
begin("/api/login-challenge", HTTP_GET, NULL); same_origin(); add("X-Login-Bootstrap", "1"); expect("503 Service Unavailable");
|
||||
now += 120000000; challenge(token, csrf);
|
||||
auth_reset(); for (unsigned i = 0; i < 4; ++i) (void)mint(&alice);
|
||||
challenge(token, csrf); login_request(token, csrf, good_body); expect("503 Service Unavailable"); assert(snapshot().active == 4);
|
||||
puts("PASS: consumed challenges, global five/60s throttle, expiry and no live challenge/session eviction");
|
||||
|
||||
auth_reset(); challenge(token, csrf);
|
||||
const char *keys[] = {"Host", "Origin", "Cookie", "Content-Type", "X-CSRF-Token"};
|
||||
for (unsigned i = 0; i < sizeof(keys)/sizeof(keys[0]); ++i) {
|
||||
login_request(token, csrf, good_body); add(keys[i], "ambiguous"); expect("400 Bad Request");
|
||||
}
|
||||
assert(password_calls == 0);
|
||||
login_request(token, csrf, good_body); add("Transfer-Encoding", "chunked"); expect("400 Bad Request");
|
||||
login_request(token, csrf, good_body); add("Sec-Fetch-Site", "cross-site"); expect("403 Forbidden");
|
||||
login_request(token, "invalid", good_body); expect("403 Forbidden");
|
||||
login_request(token, csrf, good_body); req.method = HTTP_GET; expect("400 Bad Request");
|
||||
login_request(token, csrf, good_body); req.content_len = 513; expect("413 Payload Too Large");
|
||||
login_request(token, csrf, "{\"username\":\"alice\",\"password\":\"x\",\"unknown\":1}"); expect("400 Bad Request");
|
||||
assert(password_calls == 0);
|
||||
begin("/", HTTP_GET, NULL); same_origin(); add("Authorization", "Basic ignored");
|
||||
web_session_view_t view; bool allowed;
|
||||
assert(web_cookie_auth_require(&req, false, false, &view, &allowed) == ESP_OK && !allowed);
|
||||
assert(!strcmp(response_status, "303 See Other"));
|
||||
begin("/assets/app.js", HTTP_GET, NULL); same_origin();
|
||||
assert(web_cookie_auth_require(&req, false, false, &view, &allowed) == ESP_OK && !allowed);
|
||||
assert(!strcmp(response_status, "401 Unauthorized"));
|
||||
puts("PASS: duplicate security headers, framing, methods, metadata, CSRF, strict JSON and no Basic bypass");
|
||||
|
||||
auth_reset(); other = mint(&alice);
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", other.token);
|
||||
begin("/api/logout", HTTP_POST, NULL); same_origin(); add("Cookie", cookies);
|
||||
expect("403 Forbidden"); present(&other);
|
||||
begin("/api/logout", HTTP_POST, NULL); add("Host", "device.example");
|
||||
add("Cookie", cookies); add("X-CSRF-Token", other.view.csrf);
|
||||
expect("403 Forbidden"); present(&other);
|
||||
begin("/api/logout", HTTP_POST, NULL); add("Host", "device.example"); add("Origin", "https://foreign.example");
|
||||
add("Cookie", cookies); add("X-CSRF-Token", other.view.csrf);
|
||||
expect("403 Forbidden"); present(&other);
|
||||
begin("/api/session", HTTP_GET, NULL); add("Host", "device.example"); add("Cookie", cookies);
|
||||
expect("200 OK");
|
||||
begin("/api/session", HTTP_GET, NULL); add("Host", "alias.local"); add("Cookie", cookies);
|
||||
expect("401 Unauthorized"); present(&other);
|
||||
challenge(token, csrf);
|
||||
begin("/api/login", HTTP_POST, good_body); same_origin(); add("Content-Type", "application/json");
|
||||
add("X-CSRF-Token", csrf);
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s; __Host-sak-prelogin=%s", other.token, token);
|
||||
add("Cookie", cookies); expect("409 Conflict"); assert(password_calls == 0); present(&other);
|
||||
begin("/api/login", HTTP_POST, good_body); same_origin(); add("Content-Type", "application/json");
|
||||
add("X-CSRF-Token", csrf);
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s; __Host-sak-session=%s", other.token, other.token);
|
||||
add("Cookie", cookies); expect("400 Bad Request"); assert(password_calls == 0);
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", other.token);
|
||||
now = other.view.expires_at_us;
|
||||
begin("/api/session", HTTP_GET, NULL); same_origin(); add("Cookie", cookies); expect("401 Unauthorized");
|
||||
auth_reset(); other = mint(&alice); stale_user = alice.user_id;
|
||||
snprintf(cookies, sizeof(cookies), "__Host-sak-session=%s", other.token);
|
||||
begin("/api/session", HTTP_GET, NULL); same_origin(); add("Cookie", cookies); expect("401 Unauthorized");
|
||||
puts("PASS: mandatory mutation Origin/CSRF, origin binding, explicit account switching, duplicate named cookies, expiry/currentness");
|
||||
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
password_hook = web_cookie_auth_stop; expect("503 Service Unavailable"); assert(!snapshot().active);
|
||||
auth_reset(); rng_fail = true;
|
||||
begin("/api/login-challenge", HTTP_GET, NULL); same_origin(); add("X-Login-Bootstrap", "1"); expect("503 Service Unavailable");
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body); send_fail = true;
|
||||
assert(web_cookie_auth_handler(&req) != ESP_OK); send_fail = false; assert(!snapshot().active);
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body); recv_fail = true;
|
||||
expect("400 Bad Request"); recv_fail = false; assert(!password_calls);
|
||||
begin("/api/session", HTTP_GET, NULL); add("Host", "first"); add("host", "second");
|
||||
char value[32]; assert(httpd_req_get_hdr_value_str(&req, "Host", value, sizeof(value)) == ESP_OK);
|
||||
assert(!strcmp(value, "first") && !web_httpd_headers_valid(&req));
|
||||
begin("/ws/serial", HTTP_GET, NULL); same_origin();
|
||||
add("Sec-WebSocket-Version", "13"); add("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
|
||||
assert(!web_httpd_upgrade_requested(&req)); aux.ws_handshake_detect = true;
|
||||
assert(web_httpd_upgrade(&req, web_cookie_auth_handler) == ESP_OK && upgrades == 1);
|
||||
assert(socket_state.ws_handshake_done && !web_httpd_upgrade_requested(&req));
|
||||
begin("/ws/serial", HTTP_GET, NULL); same_origin(); aux.ws_handshake_detect = true;
|
||||
add("Sec-WebSocket-Version", "130"); add("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==");
|
||||
assert(!web_httpd_upgrade_requested(&req));
|
||||
for (size_t keep = 0; keep <= sizeof(socket_state.pending_data); ++keep) {
|
||||
memset(socket_state.pending_data, 's', sizeof(socket_state.pending_data));
|
||||
size_t offset = sizeof(socket_state.pending_data) - keep;
|
||||
memset(socket_state.pending_data + offset, 'p', keep);
|
||||
socket_state.pending_len = keep;
|
||||
web_httpd_wipe_request(&req, false);
|
||||
zero(socket_state.pending_data, offset);
|
||||
char received[128];
|
||||
size_t first = host_read_pending(&req, received, 1);
|
||||
assert(first == (keep ? 1U : 0U));
|
||||
if (first) assert(received[0] == 'p');
|
||||
web_httpd_wipe_request(&req, false);
|
||||
zero(socket_state.pending_data, offset + first);
|
||||
size_t remaining = host_read_pending(&req, received, sizeof(received));
|
||||
assert(remaining == keep - first);
|
||||
for (size_t i = 0; i < remaining; ++i) assert(received[i] == 'p');
|
||||
}
|
||||
memset(socket_state.pending_data, 's', sizeof(socket_state.pending_data));
|
||||
socket_state.pending_len = 3; web_httpd_wipe_request(&req, true);
|
||||
zero(socket_state.pending_data, sizeof(socket_state.pending_data));
|
||||
zero(scratch, sizeof(scratch));
|
||||
puts("PASS: request wiping preserves right-aligned pending data through actual IDF reader (all lengths/partial reads)");
|
||||
puts("PASS: stop/login race, RNG/send/receive failure, actual IDF first-header semantics and explicit upgrade adapter");
|
||||
for (unsigned limit = 0; limit < 6; ++limit) {
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
server.config.max_resp_headers = limit;
|
||||
assert(web_cookie_auth_handler(&req) != ESP_OK);
|
||||
assert(snapshot().active == 0);
|
||||
server.config.max_resp_headers = 8;
|
||||
}
|
||||
auth_reset(); challenge(token, csrf); login_request(token, csrf, good_body);
|
||||
server.config.max_resp_headers = 6; expect("200 OK"); assert(cookie_count == 2);
|
||||
server.config.max_resp_headers = 8;
|
||||
puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login");
|
||||
#ifdef HOST_ADMIN
|
||||
admin_tests();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Standalone login renderer host tests
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
python3 tests/web_login_ui/run.py
|
||||
# Optional compiler selection:
|
||||
CC=clang python3 tests/web_login_ui/run.py
|
||||
```
|
||||
|
||||
Requires Python 3, a C11 compiler supporting Linux shared libraries, and Node.js
|
||||
24 on PATH. No pip/npm packages, ESP-IDF installation, network, device, or live
|
||||
HTTP routes are needed. Do not run Python with `-O` (checks use assertions).
|
||||
|
||||
The runner compiles the actual `src/web_login_ui.c` and header with
|
||||
`-Wall -Wextra -Werror` and tiny ESP/httpd stubs in an automatically removed
|
||||
temporary directory. It checks NULL handling, all response setter failures
|
||||
(stop immediately without sending), send error propagation, content type,
|
||||
security headers, and the eight additional-header-slot budget. It parses the
|
||||
captured HTML to reject external assets and compares the CSP script hash against
|
||||
SHA256 of the exact rendered inline script bytes, including surrounding newlines.
|
||||
JavaScript tests still run on a hash mismatch so it does not hide behavioral
|
||||
test results.
|
||||
|
||||
`browser.cjs` executes that rendered script in Node's VM using DOM/fetch doubles,
|
||||
real Response/ReadableStream, UTF-8 encoders/decoders, and AbortController. It
|
||||
covers no automatic requests, challenge/custom-header and CSRF JSON submission,
|
||||
redirect, wrong credentials/fresh challenges, status errors and manual-only
|
||||
Retry-After backoff, malformed/oversized responses, network failure/timeout,
|
||||
input byte limits, duplicate submission, and pagehide/pageshow generation safety
|
||||
(including late fetches and late body reads), attempt-abort cleanup on every
|
||||
error status, disabled pending inputs, and clearing re-entered passwords. Error
|
||||
bodies carry a marker that
|
||||
must never appear in displayed error text.
|
||||
|
||||
Limitations: this is not a real browser, ESP-IDF HTTP server, authentication
|
||||
backend, or hardware test. It does not validate route registration, cookies,
|
||||
TLS, native form validation, browser CSP enforcement, layout/accessibility, or
|
||||
actual bfcache behavior. Timers and clock advancement are deterministic doubles;
|
||||
fetch doubles can intentionally ignore abort to exercise stale completion paths.
|
||||
Error-body stream abortion is modeled with fetch abort listeners, not real socket
|
||||
cleanup. Clearing DOM fields/JavaScript references is best-effort secret lifetime
|
||||
reduction, not guaranteed erasure of garbage-collected strings or browser internals.
|
||||
The 16/64-byte field limits bound maximally escaped JSON to 509 bytes, so the
|
||||
separate >512-byte request guard is not independently reachable with valid fields;
|
||||
the suite tests worst-case expansion rather than bypassing those field checks.
|
||||
Nothing here exposes a route or regenerates protected web assets.
|
||||
@@ -0,0 +1,189 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
const {script} = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
|
||||
const token = 'a'.repeat(64), secret = 'SERVER_BODY_MUST_NOT_APPEAR';
|
||||
const json = value => new Response(JSON.stringify(value));
|
||||
const challenge = (csrf = token) => json({csrf, expires_in: 120});
|
||||
const success = () => json({authenticated: true});
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; };
|
||||
function browser(queue = []) {
|
||||
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map();
|
||||
for (const id of ['login', 'username', 'password', 'submit', 'message']) nodes[id] = {
|
||||
value: '', disabled: id === 'submit', textContent: '', attrs: {},
|
||||
setAttribute(k, v) { this.attrs[k] = v; },
|
||||
addEventListener(k, fn) { this[k] = fn; }
|
||||
};
|
||||
let now = 100000, timerId = 0;
|
||||
vm.runInNewContext(script, {
|
||||
document: {getElementById: id => { assert.ok(nodes[id], id); return nodes[id]; }},
|
||||
window: {addEventListener: (k, fn) => { events[k] = fn; }, location: {replace: p => redirects.push(p)}},
|
||||
TextEncoder, TextDecoder, Uint8Array, AbortController, Response, Date: {now: () => now},
|
||||
setTimeout: (fn, ms) => { timers.set(++timerId, {fn, ms}); return timerId; },
|
||||
clearTimeout: id => timers.delete(id),
|
||||
fetch: async (url, options) => {
|
||||
// Guard the no-referrer/Origin:null regression; this VM does not synthesize browser headers.
|
||||
assert.ok(['/api/login-challenge', '/api/login'].includes(url));
|
||||
if (options.method === 'POST') assert.equal(options.mode, 'cors');
|
||||
assert.equal(options.headers?.Origin, undefined);
|
||||
calls.push({url, ...options});
|
||||
assert.ok(queue.length, 'unexpected/automatic fetch');
|
||||
const next = queue.shift();
|
||||
return typeof next === 'function' ? next(options) : next;
|
||||
}
|
||||
}, {timeout: 1000});
|
||||
assert.equal(calls.length, 0); assert.equal(timers.size, 0); assert.equal(nodes.submit.disabled, false);
|
||||
return {nodes, events, calls, redirects, timers, queue, advance: ms => { now += ms; },
|
||||
submit: (user = 'alice', pass = 'password') => {
|
||||
nodes.username.value = user; nodes.password.value = pass;
|
||||
let prevented = false;
|
||||
const result = nodes.login.submit({preventDefault() { prevented = true; }});
|
||||
assert.ok(prevented); return result;
|
||||
},
|
||||
idle() {
|
||||
for (const id of ['submit', 'username', 'password']) assert.equal(nodes[id].disabled, false, id);
|
||||
assert.equal(nodes.password.value, '');
|
||||
assert.equal(timers.size, 0); assert.ok(!nodes.message.textContent.includes(secret));
|
||||
}
|
||||
};
|
||||
}
|
||||
let passed = 0;
|
||||
async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', name); }
|
||||
(async () => {
|
||||
await test('no automatic fetch; challenge GET, CSRF JSON POST, redirect', async () => {
|
||||
const b = browser([challenge(), success()]); await b.submit(); b.idle();
|
||||
assert.deepEqual(b.redirects, ['/']); assert.equal(b.calls.length, 2);
|
||||
const [get, post] = b.calls;
|
||||
assert.equal(get.url, '/api/login-challenge'); assert.equal(get.method || 'GET', 'GET');
|
||||
assert.equal(get.headers['X-Login-Bootstrap'], '1'); assert.equal(get.body, undefined);
|
||||
assert.equal(post.url, '/api/login'); assert.equal(post.method, 'POST');
|
||||
assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
|
||||
assert.deepEqual(JSON.parse(post.body), {username: 'alice', password: 'password'});
|
||||
for (const call of b.calls) {
|
||||
for (const [k, v] of Object.entries({credentials: 'same-origin', mode: 'cors', cache: 'no-store', redirect: 'error'})) assert.equal(call[k], v);
|
||||
assert.ok(call.signal instanceof AbortSignal);
|
||||
}
|
||||
});
|
||||
await test('wrong credentials stay on page; manual retry obtains fresh challenge', async () => {
|
||||
const b = browser([challenge(), new Response(secret, {status: 401})]);
|
||||
await b.submit(); b.idle(); assert.deepEqual(b.redirects, []);
|
||||
assert.match(b.nodes.message.textContent, /incorrect/i);
|
||||
b.queue.push(challenge('b'.repeat(64)), success()); await b.submit();
|
||||
assert.equal(b.calls[2].url, '/api/login-challenge'); assert.equal(b.calls[3].headers['X-CSRF-Token'], 'b'.repeat(64));
|
||||
});
|
||||
await test('status errors at both stages; manual-only bounded backoff', async () => {
|
||||
for (const stage of ['challenge', 'login']) for (const status of [400, 401, 403, 409, 413, 415, 429, 503, 500]) {
|
||||
let response, aborted = false;
|
||||
const b = browser([...(stage === 'login' ? [challenge()] : []), options => {
|
||||
// Model fetch abort terminating its body stream, not a real HTTP socket.
|
||||
const stream = new ReadableStream({start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(secret));
|
||||
options.signal.addEventListener('abort', () => {
|
||||
aborted = true; controller.error(new DOMException('Aborted', 'AbortError'));
|
||||
}, {once: true});
|
||||
}});
|
||||
response = new Response(stream, {status, headers: {'Retry-After': '2'}});
|
||||
return response;
|
||||
}]);
|
||||
await b.submit(); b.idle(); assert.deepEqual(b.redirects, []);
|
||||
assert.ok(aborted, `${stage} ${status}: response stream abort`);
|
||||
for (const call of b.calls) assert.ok(call.signal.aborted, `${stage} ${status}: signal`);
|
||||
await assert.rejects(response.body.getReader().read(), {name: 'AbortError'});
|
||||
assert.ok(b.nodes.message.textContent); const count = b.calls.length;
|
||||
if ([429, 503].includes(status)) { await b.submit(); assert.equal(b.calls.length, count); }
|
||||
b.advance(3000); await tick(); assert.equal(b.calls.length, count); assert.equal(b.timers.size, 0);
|
||||
b.queue.push(challenge(), success()); await b.submit(); assert.deepEqual(b.redirects, ['/']);
|
||||
}
|
||||
for (const [raw, seconds] of [['0', 1], ['999', 120], ['bad', 5], ['1000', 5], ['', 5]]) {
|
||||
const b = browser([new Response(secret, {status: 429, headers: {'Retry-After': raw}})]);
|
||||
await b.submit(); assert.ok(b.nodes.message.textContent.includes(`Wait ${seconds} seconds`));
|
||||
b.advance(seconds * 1000 - 1); await b.submit(); assert.equal(b.calls.length, 1);
|
||||
b.advance(1); b.queue.push(challenge(), success()); await b.submit(); assert.deepEqual(b.redirects, ['/']);
|
||||
}
|
||||
});
|
||||
await test('malformed, oversized, invalid UTF-8, absent and invalid-schema response bodies', async () => {
|
||||
for (const stage of ['challenge', 'login']) {
|
||||
const good = JSON.stringify(stage === 'challenge' ? {csrf: token, expires_in: 1} : {authenticated: true});
|
||||
const invalid = [() => new Response(secret), () => new Response(null), () => new Response(Uint8Array.of(255)),
|
||||
() => new Response(good.padEnd(513)), () => json(null), () => json({}), () => json([]),
|
||||
...(stage === 'challenge' ? [() => json({csrf: token, expires_in: 0}), () => json({csrf: token, expires_in: 121}),
|
||||
() => json({csrf: token, expires_in: 1.5}), () => json({csrf: token, expires_in: '1'}),
|
||||
() => challenge('A'.repeat(64)), () => challenge('a'.repeat(63))] : [() => json({authenticated: 'true'}), () => json({authenticated: false})])];
|
||||
for (const make of invalid) {
|
||||
const b = browser([...(stage === 'login' ? [challenge()] : []), make()]);
|
||||
await b.submit(); b.idle(); assert.deepEqual(b.redirects, []);
|
||||
assert.match(b.nodes.message.textContent, /Could not confirm/); assert.equal(b.calls.length, stage === 'login' ? 2 : 1);
|
||||
}
|
||||
const b = browser(stage === 'login' ? [challenge(), new Response(good.padEnd(512))] : [new Response(good.padEnd(512)), success()]);
|
||||
await b.submit(); assert.deepEqual(b.redirects, ['/']);
|
||||
}
|
||||
let cancelled = false;
|
||||
const stream = new ReadableStream({start(c) { c.enqueue(new Uint8Array(300).fill(32)); c.enqueue(new Uint8Array(213).fill(32)); }, cancel() { cancelled = true; }});
|
||||
const b = browser([new Response(stream)]); await b.submit(); b.idle(); assert.ok(cancelled);
|
||||
});
|
||||
await test('network errors at both stages; deadline abort; manual recovery', async () => {
|
||||
for (const stage of ['challenge', 'login']) {
|
||||
const b = browser([...(stage === 'login' ? [challenge()] : []), () => { throw new Error(secret); }]);
|
||||
await b.submit(); b.idle(); assert.deepEqual(b.redirects, []); assert.match(b.nodes.message.textContent, /Could not confirm/);
|
||||
b.queue.push(challenge(), success()); await b.submit(); assert.deepEqual(b.redirects, ['/']);
|
||||
}
|
||||
const b = browser([o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error(secret))))]);
|
||||
const pending = b.submit(); await tick(); const timer = [...b.timers.values()][0];
|
||||
assert.equal(timer.ms, 15000); timer.fn(); await pending; b.idle(); assert.ok(b.calls[0].signal.aborted);
|
||||
});
|
||||
await test('UTF-8 and JSON byte limits, empty and NUL input, exact boundaries', async () => {
|
||||
for (const [u, p] of [['', 'p'], ['u', ''], ['a'.repeat(17), 'p'], ['u', 'a'.repeat(65)],
|
||||
['é'.repeat(9), 'p'], ['u', 'é'.repeat(33)], ['u\0', 'p'], ['u', 'p\0']]) {
|
||||
const b = browser(); await b.submit(u, p); b.idle(); assert.equal(b.calls.length, 0); assert.match(b.nodes.message.textContent, /UTF-8 bytes/);
|
||||
}
|
||||
for (const [u, p] of [['a'.repeat(16), 'p'.repeat(64)], ['é'.repeat(8), '🔑'.repeat(16)], ['\u0001'.repeat(16), '\u0001'.repeat(64)]]) {
|
||||
const b = browser([challenge(), success()]); await b.submit(u, p); assert.deepEqual(b.redirects, ['/']);
|
||||
assert.deepEqual(JSON.parse(b.calls[1].body), {username: u, password: p});
|
||||
assert.ok(new TextEncoder().encode(b.calls[1].body).length <= 512);
|
||||
}
|
||||
});
|
||||
await test('pending inputs disabled; duplicate and completion wipe retyped passwords at both stages', async () => {
|
||||
for (const stage of ['challenge', 'login']) for (const duplicate of [false, true]) {
|
||||
const d = deferred(), b = browser(stage === 'login' ? [challenge(), d.promise] : [d.promise, success()]);
|
||||
const pending = b.submit(); await tick();
|
||||
assert.equal(b.nodes.password.value, ''); assert.equal(b.nodes.login.attrs['aria-busy'], 'true');
|
||||
for (const id of ['submit', 'username', 'password']) assert.ok(b.nodes[id].disabled, id);
|
||||
if (duplicate) {
|
||||
await b.submit('alice', 'manually retyped duplicate');
|
||||
assert.equal(b.nodes.password.value, ''); assert.equal(b.calls.length, stage === 'login' ? 2 : 1);
|
||||
for (const id of ['submit', 'username', 'password']) assert.ok(b.nodes[id].disabled, id);
|
||||
}
|
||||
b.nodes.password.value = 'manually retyped before completion';
|
||||
d.resolve(stage === 'login' ? success() : challenge()); await pending; b.idle();
|
||||
assert.equal(b.nodes.login.attrs['aria-busy'], 'false'); assert.deepEqual(b.redirects, ['/']);
|
||||
}
|
||||
});
|
||||
await test('pagehide aborts; late fetch/body ignored at both stages; pageshow recovers', async () => {
|
||||
for (const stage of ['challenge', 'login']) for (const bodyPending of [false, true]) {
|
||||
const d = deferred(); let streamController;
|
||||
const response = bodyPending ? new Response(new ReadableStream({start(c) { streamController = c; }})) : d.promise;
|
||||
const b = browser([...(stage === 'login' ? [challenge()] : []), response]);
|
||||
const pending = b.submit(); await tick(); assert.equal(b.calls.length, stage === 'login' ? 2 : 1);
|
||||
b.events.pagehide({}); assert.ok(b.calls[0].signal.aborted);
|
||||
for (const id of ['submit', 'username', 'password']) assert.equal(b.nodes[id].disabled, false, id);
|
||||
assert.equal(b.nodes.password.value, ''); b.events.pageshow({persisted: true});
|
||||
assert.equal(b.nodes.message.textContent, 'Ready to sign in.');
|
||||
// New generation remains busy even when the old request finishes.
|
||||
const newer = deferred(); b.queue.push(newer.promise, success()); const retry = b.submit('new-user', 'new-password');
|
||||
const newSignal = b.calls.at(-1).signal;
|
||||
b.nodes.password.value = 'new generation field sentinel';
|
||||
if (bodyPending) {
|
||||
streamController.enqueue(new TextEncoder().encode(JSON.stringify(stage === 'login' ? {authenticated: true} : {csrf: token, expires_in: 60})));
|
||||
streamController.close();
|
||||
} else d.resolve(stage === 'login' ? success() : challenge());
|
||||
await pending; assert.deepEqual(b.redirects, []);
|
||||
for (const id of ['submit', 'username', 'password']) assert.ok(b.nodes[id].disabled, id);
|
||||
assert.equal(b.nodes.username.value, 'new-user'); assert.equal(b.nodes.password.value, 'new generation field sentinel');
|
||||
assert.equal(newSignal.aborted, false); assert.equal(b.timers.size, 1);
|
||||
assert.equal(b.nodes.login.attrs['aria-busy'], 'true'); assert.equal(b.nodes.message.textContent, 'Signing in...');
|
||||
newer.resolve(challenge()); await retry; b.idle(); assert.deepEqual(b.redirects, ['/']);
|
||||
}
|
||||
});
|
||||
console.log(`PASS ${passed} browser test groups`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile the production renderer; test its C contract and rendered JavaScript."""
|
||||
import base64
|
||||
import ctypes as C
|
||||
import hashlib
|
||||
from html.parser import HTMLParser
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
STUB = r'''
|
||||
#include "web_login_ui.h"
|
||||
#include <string.h>
|
||||
static int calls, sends, fail_at, send_error, count;
|
||||
static const char *keys[8], *values[8], *html, *type;
|
||||
void reset(int fail, int error) {
|
||||
calls = sends = count = 0; fail_at = fail; send_error = error;
|
||||
html = type = NULL;
|
||||
}
|
||||
int call_count(void) { return calls; }
|
||||
int send_count(void) { return sends; }
|
||||
int header_count(void) { return count; }
|
||||
const char *header_key(int i) { return keys[i]; }
|
||||
const char *header_value(int i) { return values[i]; }
|
||||
const char *body(void) { return html; }
|
||||
const char *content_type(void) { return type; }
|
||||
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *v) {
|
||||
(void)r; if (++calls == fail_at) return 73; type = v; return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *k, const char *v) {
|
||||
(void)r; if (++calls == fail_at) return 73;
|
||||
if (count == 8) return 74;
|
||||
keys[count] = k; values[count++] = v; return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_resp_send(httpd_req_t *r, const char *v, ssize_t n) {
|
||||
(void)r; ++sends;
|
||||
if (n < 0 || (size_t)n != strlen(v)) return 75;
|
||||
html = v; return send_error;
|
||||
}
|
||||
'''
|
||||
|
||||
class Assets(HTMLParser):
|
||||
def handle_starttag(self, tag, attrs):
|
||||
attrs = dict(attrs)
|
||||
assert 'src' not in attrs and 'srcset' not in attrs, (tag, attrs)
|
||||
assert tag not in ('link', 'iframe', 'object', 'embed'), tag
|
||||
assert not any(k.startswith('on') for k in attrs), attrs
|
||||
|
||||
|
||||
def run():
|
||||
with tempfile.TemporaryDirectory(prefix='web-login-ui-') as directory:
|
||||
tmp = Path(directory)
|
||||
(tmp / 'esp_err.h').write_text('#pragma once\ntypedef int esp_err_t;\n'
|
||||
'#define ESP_OK 0\n#define ESP_ERR_INVALID_ARG 258\n')
|
||||
(tmp / 'esp_http_server.h').write_text('''#pragma once
|
||||
#include "esp_err.h"
|
||||
#include <sys/types.h>
|
||||
typedef struct { int unused; } httpd_req_t;
|
||||
esp_err_t httpd_resp_set_type(httpd_req_t *, const char *);
|
||||
esp_err_t httpd_resp_set_hdr(httpd_req_t *, const char *, const char *);
|
||||
esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
|
||||
''')
|
||||
(tmp / 'stub.c').write_text(STUB)
|
||||
subprocess.run(shlex.split(os.environ.get('CC', 'cc')) + [
|
||||
'-std=c11', '-Wall', '-Wextra', '-Werror', '-shared', '-fPIC',
|
||||
'-I', str(tmp), '-I', str(ROOT / 'src'), str(tmp / 'stub.c'),
|
||||
str(ROOT / 'src/web_login_ui.c'), '-o', str(tmp / 'renderer.so')], check=True)
|
||||
lib = C.CDLL(str(tmp / 'renderer.so'))
|
||||
lib.reset.argtypes = [C.c_int, C.c_int]
|
||||
lib.web_login_ui_send_response.argtypes = [C.c_void_p]
|
||||
for name in ('header_key', 'header_value', 'body', 'content_type'):
|
||||
getattr(lib, name).restype = C.c_char_p
|
||||
for name in ('header_key', 'header_value'):
|
||||
getattr(lib, name).argtypes = [C.c_int]
|
||||
request = C.c_int()
|
||||
send = lambda: lib.web_login_ui_send_response(C.byref(request))
|
||||
lib.reset(0, 0)
|
||||
assert lib.web_login_ui_send_response(None) == 258
|
||||
assert lib.call_count() == lib.send_count() == 0
|
||||
assert send() == 0 and lib.send_count() == 1
|
||||
count, calls = lib.header_count(), lib.call_count()
|
||||
assert 0 < count <= 8
|
||||
headers = {lib.header_key(i).decode(): lib.header_value(i).decode() for i in range(count)}
|
||||
assert len(headers) == count
|
||||
assert lib.content_type() == b'text/html; charset=utf-8'
|
||||
html = lib.body().decode()
|
||||
for key, value in {'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer',
|
||||
'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY'}.items():
|
||||
assert headers[key] == value, (key, headers)
|
||||
for failure in range(1, calls + 1):
|
||||
lib.reset(failure, 0)
|
||||
assert send() == 73 and lib.send_count() == 0
|
||||
assert lib.call_count() == failure, 'header processing did not stop'
|
||||
lib.reset(0, 91)
|
||||
assert send() == 91 and lib.send_count() == 1
|
||||
print('PASS C: NULL, headers, every setter failure, send error propagation', flush=True)
|
||||
Assets().feed(html)
|
||||
assert not re.search(r'url\s*\(|@import', html, re.I)
|
||||
scripts = re.findall(r'<script>(.*?)</script>', html, re.S)
|
||||
assert len(scripts) == 1
|
||||
digest = base64.b64encode(hashlib.sha256(scripts[0].encode()).digest()).decode()
|
||||
policy = dict(part.strip().split(' ', 1) for part in headers['Content-Security-Policy'].split(';') if part.strip())
|
||||
expected = {"default-src": "'none'", "script-src": f"'sha256-{digest}'",
|
||||
"connect-src": "'self'", "base-uri": "'none'", "form-action": "'none'",
|
||||
"frame-ancestors": "'none'", "style-src": "'unsafe-inline'"}
|
||||
# Report behavioral failures even when the script and CSP hash drift.
|
||||
(tmp / 'rendered.json').write_text(json.dumps({'html': html, 'headers': headers, 'script': scripts[0]}))
|
||||
result = subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], timeout=30)
|
||||
assert policy == expected, f'CSP mismatch: expected {expected}, got {policy}'
|
||||
assert result.returncode == 0, 'Node browser tests failed'
|
||||
print(f'PASS HTML: standalone assets, {count}/8 header slots, exact CSP SHA256 {digest}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
@@ -0,0 +1,40 @@
|
||||
# Focused web session store host tests
|
||||
|
||||
From the repository root, run:
|
||||
|
||||
```sh
|
||||
python3 tests/web_session_store/run.py
|
||||
python3 tests/web_session_store/run.py --serial
|
||||
python3 tests/web_session_store/run.py --serial --sanitize
|
||||
```
|
||||
|
||||
Requires Python 3 and `cc`. Builds the actual `src/web_session_store.c` against
|
||||
its production headers; generated dependency stubs and binaries live only in an
|
||||
automatically removed temporary directory. Compilation and execution have finite
|
||||
timeouts. No ESP-IDF build, device, network, or general test framework is used.
|
||||
|
||||
The runner probes OpenSSL headers/linking and uses real SHA-256 when available.
|
||||
Otherwise it explicitly reports a deterministic **non-cryptographic digest stub**;
|
||||
that fallback verifies store behavior, not cryptography. RNG is always a deterministic
|
||||
test double, never a test of secure randomness. RNG/SHA/DB callbacks assert they
|
||||
run outside the simulated state lock. DB callbacks inject invalidate/user-invalidate/
|
||||
stop operations to exercise revalidation, but do not model real concurrent threads.
|
||||
|
||||
The default store tests use only the public API: output wiping and record retirement are checked,
|
||||
but private record bytes, allocator failures, hardware locking, and timing under
|
||||
real concurrency are not inspected. Rejections without a specified error contract
|
||||
are checked as non-success; absent tokens, readiness, and capacity use exact errors.
|
||||
Counters are checked with deltas where lifecycle reset semantics are unspecified.
|
||||
|
||||
`--serial` also compiles the production serial transport into the test translation
|
||||
unit to step its private ticket/admission/input/currentness/cleanup boundaries.
|
||||
It links the real session store separately and uses minimal HTTPD/broker/task
|
||||
doubles, not real sockets or a running scheduler. Unused firmware functions are
|
||||
linker-garbage-collected. Tests cover matching-session single-use tickets, every
|
||||
principal field, same-account session isolation, account-wide ticket/socket/store
|
||||
cleanup, serial-init failure isolation, close-queue failure/retry, slot reuse,
|
||||
expiry and database failure without notification, logout during mint/admission,
|
||||
stale periodic work, and Basic operation with the store disabled. No secrets are
|
||||
printed. `--sanitize` enables compiler AddressSanitizer and UndefinedBehaviorSanitizer.
|
||||
Real HTTPD task ordering, queued sends, target timing/stack/heap and browser cookie
|
||||
policy remain outside this harness; cookie routes do not exist in 8D.2.
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Finite host build/run; all generated headers and binaries are temporary."""
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
import sys
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
# Compiler wrappers must not write caches outside the temporary build directory.
|
||||
os.environ["CCACHE_DISABLE"] = "1"
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
HEADERS = {
|
||||
"esp_err.h": """#pragma once
|
||||
typedef int esp_err_t;
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL -1
|
||||
#define ESP_ERR_NO_MEM 0x101
|
||||
#define ESP_ERR_INVALID_ARG 0x102
|
||||
#define ESP_ERR_INVALID_STATE 0x103
|
||||
#define ESP_ERR_NOT_FOUND 0x105
|
||||
#define ESP_ERR_NOT_SUPPORTED 0x106
|
||||
#define ESP_ERR_NOT_ALLOWED 0x10d
|
||||
""",
|
||||
"freertos/FreeRTOS.h": """#pragma once
|
||||
#include <assert.h>
|
||||
typedef int portMUX_TYPE;
|
||||
#define portMUX_INITIALIZER_UNLOCKED 0
|
||||
extern int host_lock_depth;
|
||||
#define taskENTER_CRITICAL(m) do { (void)(m); assert(host_lock_depth++ == 0); } while (0)
|
||||
#define taskEXIT_CRITICAL(m) do { (void)(m); assert(--host_lock_depth == 0); } while (0)
|
||||
""",
|
||||
"esp_timer.h": "#pragma once\n#include <stdint.h>\nint64_t esp_timer_get_time(void);\n",
|
||||
"mbedtls/sha256.h": "#pragma once\n#include <stddef.h>\nint mbedtls_sha256(const unsigned char *, size_t, unsigned char *, int);\n",
|
||||
}
|
||||
|
||||
|
||||
def run():
|
||||
serial = "--serial" in sys.argv
|
||||
if serial:
|
||||
from serial_headers import SERIAL_HEADERS
|
||||
HEADERS.update(SERIAL_HEADERS)
|
||||
source = ROOT / "src/web_session_store.c"
|
||||
if not source.exists():
|
||||
raise SystemExit("Cannot run: src/web_session_store.c has not been created yet")
|
||||
with tempfile.TemporaryDirectory(prefix="web-session-store-") as directory:
|
||||
tmp = pathlib.Path(directory)
|
||||
for name, text in HEADERS.items():
|
||||
path = tmp / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
probe = subprocess.run(
|
||||
["cc", "-x", "c", "-", "-lcrypto", "-o", str(tmp / "probe")],
|
||||
input="#include <openssl/sha.h>\nint main(void) { unsigned char d[32]; return !SHA256(d, 0, d); }\n",
|
||||
text=True, capture_output=True, timeout=20)
|
||||
crypto = ["-DHOST_OPENSSL", "-lcrypto"] if probe.returncode == 0 else []
|
||||
print("SHA256: " + ("OpenSSL" if crypto else "NON-CRYPTOGRAPHIC STUB (not crypto verification)"), flush=True)
|
||||
sanitizer = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] if "--sanitize" in sys.argv else []
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
|
||||
*sanitizer,
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"),
|
||||
"-ffunction-sections", "-fdata-sections", "-Wl,--gc-sections",
|
||||
str(HERE / ("serial_test.c" if serial else "test.c")), str(source),
|
||||
*([str(ROOT / "src/web_auth_parse.c")] if serial else []), *crypto,
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Minimal IDF declarations for the focused serial/session integration harness."""
|
||||
SERIAL_HEADERS = {
|
||||
"sdkconfig.h": "#define CONFIG_HTTPD_WS_SUPPORT 1\n",
|
||||
"driver/uart.h": "typedef int uart_config_t;\n",
|
||||
"freertos/task.h": """#pragma once
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
typedef void *TaskHandle_t;
|
||||
typedef uint32_t TickType_t;
|
||||
typedef uint32_t StackType_t;
|
||||
typedef int StaticTask_t;
|
||||
#define pdMS_TO_TICKS(x) (x)
|
||||
#define pdTRUE 1
|
||||
#define ESP_ERR_TIMEOUT 0x107
|
||||
void xTaskNotifyGive(TaskHandle_t);
|
||||
uint32_t ulTaskNotifyTake(int, TickType_t);
|
||||
void vTaskDelay(TickType_t);
|
||||
TaskHandle_t xTaskCreateStatic(void (*)(void *), const char *, uint32_t, void *,
|
||||
unsigned, StackType_t *, StaticTask_t *);
|
||||
""",
|
||||
"esp_http_server.h": """#pragma once
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
typedef void *httpd_handle_t;
|
||||
typedef struct { httpd_handle_t handle; void *sess_ctx; void (*free_ctx)(void *);
|
||||
int method; size_t content_len; const char *uri; void *aux; } httpd_req_t;
|
||||
typedef enum { HTTPD_WS_TYPE_CONTINUE, HTTPD_WS_TYPE_TEXT, HTTPD_WS_TYPE_BINARY } httpd_ws_type_t;
|
||||
typedef enum { HTTPD_WS_CLIENT_HTTP, HTTPD_WS_CLIENT_WEBSOCKET } httpd_ws_client_info_t;
|
||||
typedef struct { bool final, fragmented; httpd_ws_type_t type; unsigned char *payload;
|
||||
size_t len; } httpd_ws_frame_t;
|
||||
#define HTTP_POST 1
|
||||
#define HTTP_GET 0
|
||||
size_t httpd_req_get_url_query_len(httpd_req_t *);
|
||||
esp_err_t httpd_req_get_url_query_str(httpd_req_t *, char *, size_t);
|
||||
esp_err_t httpd_req_get_hdr_value_str(httpd_req_t *, const char *, char *, size_t);
|
||||
size_t httpd_req_get_hdr_value_len(httpd_req_t *, const char *);
|
||||
int httpd_req_to_sockfd(httpd_req_t *);
|
||||
esp_err_t httpd_resp_set_status(httpd_req_t *, const char *);
|
||||
esp_err_t httpd_resp_set_type(httpd_req_t *, const char *);
|
||||
esp_err_t httpd_resp_set_hdr(httpd_req_t *, const char *, const char *);
|
||||
esp_err_t httpd_resp_send(httpd_req_t *, const char *, int);
|
||||
httpd_ws_client_info_t httpd_ws_get_fd_info(httpd_handle_t, int);
|
||||
esp_err_t httpd_ws_recv_frame(httpd_req_t *, httpd_ws_frame_t *, size_t);
|
||||
esp_err_t httpd_ws_send_frame_async(httpd_handle_t, int, httpd_ws_frame_t *);
|
||||
void *httpd_sess_get_ctx(httpd_handle_t, int);
|
||||
esp_err_t httpd_sess_update_lru_counter(httpd_handle_t, int);
|
||||
esp_err_t httpd_sess_trigger_close(httpd_handle_t, int);
|
||||
esp_err_t httpd_queue_work(httpd_handle_t, void (*)(void *), void *);
|
||||
""",
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/* Exercise production private admission/owner steps with deterministic IDF doubles.
|
||||
* Store remains a separately compiled production module, not a session mock. */
|
||||
#define main store_tests
|
||||
#include "test.c"
|
||||
#undef main
|
||||
#include "../../src/web_serial_transport.c"
|
||||
|
||||
static char query[48];
|
||||
static unsigned broker_connections, broker_disconnects, writes, closes;
|
||||
static unsigned admin_revocations;
|
||||
static web_session_id_t expected_invalidated_id, last_admin_id;
|
||||
static size_t last_admin_username_length;
|
||||
void web_admin_transport_revoke(web_session_id_t id, const uint8_t *username, size_t length)
|
||||
{
|
||||
assert(!host_lock_depth);
|
||||
++admin_revocations;
|
||||
last_admin_id = id;
|
||||
last_admin_username_length = username ? length : 0;
|
||||
if (expected_invalidated_id) {
|
||||
bool current = true;
|
||||
assert(web_session_store_is_current(expected_invalidated_id, ¤t) == ESP_ERR_NOT_FOUND && !current);
|
||||
expected_invalidated_id = 0;
|
||||
}
|
||||
}
|
||||
static esp_err_t close_result = ESP_OK;
|
||||
static httpd_req_t request = { .handle = (void *)1 };
|
||||
static void (*connect_hook)(void);
|
||||
esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s) { (void)r; (void)s; return ESP_OK; }
|
||||
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s) { (void)r; (void)s; return ESP_OK; }
|
||||
esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *k, const char *v) { (void)r; (void)k; (void)v; return ESP_OK; }
|
||||
esp_err_t httpd_resp_send(httpd_req_t *r, const char *s, int n) { (void)r; (void)s; (void)n; return ESP_OK; }
|
||||
void xTaskNotifyGive(TaskHandle_t task) { (void)task; assert(!host_lock_depth); }
|
||||
size_t httpd_req_get_url_query_len(httpd_req_t *r) { (void)r; return strlen(query); }
|
||||
esp_err_t httpd_req_get_url_query_str(httpd_req_t *r, char *out, size_t n) {
|
||||
(void)r; assert(strlen(query) < n); strcpy(out, query); return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_req_get_hdr_value_str(httpd_req_t *r, const char *key, char *out, size_t n) {
|
||||
(void)r;
|
||||
const char *value = !strcmp(key, "Host") ? "device.example" :
|
||||
!strcmp(key, "Origin") ? "https://device.example" : NULL;
|
||||
if (!value) return ESP_ERR_NOT_FOUND;
|
||||
assert(strlen(value) < n); strcpy(out, value); return ESP_OK;
|
||||
}
|
||||
size_t httpd_req_get_hdr_value_len(httpd_req_t *r, const char *key) {
|
||||
char value[140]; return httpd_req_get_hdr_value_str(r, key, value, sizeof(value)) == ESP_OK ? strlen(value) : 0;
|
||||
}
|
||||
bool web_httpd_upgrade_requested(httpd_req_t *r) { (void)r; return true; }
|
||||
esp_err_t web_httpd_upgrade(httpd_req_t *r, esp_err_t (*handler)(httpd_req_t *)) {
|
||||
(void)r; (void)handler; return ESP_OK;
|
||||
}
|
||||
int httpd_req_to_sockfd(httpd_req_t *r) { (void)r; return 10; }
|
||||
httpd_ws_client_info_t httpd_ws_get_fd_info(httpd_handle_t h, int fd) {
|
||||
(void)h; (void)fd; return HTTPD_WS_CLIENT_WEBSOCKET;
|
||||
}
|
||||
esp_err_t httpd_ws_recv_frame(httpd_req_t *r, httpd_ws_frame_t *f, size_t n) {
|
||||
(void)r; f->final = true; f->type = HTTPD_WS_TYPE_BINARY; f->len = 1;
|
||||
if (n) f->payload[0] = 0;
|
||||
return ESP_OK;
|
||||
}
|
||||
bool serial_service_is_running(void) { return true; }
|
||||
esp_err_t serial_service_start(void) { return ESP_OK; }
|
||||
esp_err_t session_broker_connect(session_broker_client_type_t t, const char *name,
|
||||
session_broker_client_id_t *id) {
|
||||
(void)t; (void)name; assert(!host_lock_depth); *id = ++broker_connections;
|
||||
if (connect_hook) { void (*hook)(void) = connect_hook; connect_hook = NULL; hook(); }
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t session_broker_disconnect(session_broker_client_id_t id) {
|
||||
(void)id; assert(!host_lock_depth); ++broker_disconnects; return ESP_OK;
|
||||
}
|
||||
esp_err_t session_broker_request_writer(session_broker_client_id_t id) { (void)id; return ESP_OK; }
|
||||
esp_err_t session_broker_release_writer(session_broker_client_id_t id) { (void)id; return ESP_OK; }
|
||||
esp_err_t session_broker_write(session_broker_client_id_t id, const uint8_t *p, size_t n, size_t *a) {
|
||||
(void)id; (void)p; ++writes; *a = n; return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_sess_trigger_close(httpd_handle_t h, int fd) {
|
||||
(void)h; (void)fd; assert(!host_lock_depth); ++closes; return close_result;
|
||||
}
|
||||
void *httpd_sess_get_ctx(httpd_handle_t h, int fd) { (void)h; (void)fd; return request.sess_ctx; }
|
||||
|
||||
static void serial_reset(void) {
|
||||
reset(); memset(s_slots, 0, sizeof(s_slots)); memset(s_tickets, 0, sizeof(s_tickets));
|
||||
s_initialized = true; s_server = request.handle; request.sess_ctx = NULL;
|
||||
close_result = ESP_OK; connect_hook = NULL;
|
||||
}
|
||||
static void ticket_for(const issued_t *s, char *ticket) {
|
||||
assert(web_serial_transport_mint_ticket(&s->view.principal, s->view.id,
|
||||
ticket, 33) == ESP_OK);
|
||||
}
|
||||
static web_serial_slot_t *connect_session(const issued_t *s) {
|
||||
char ticket[33]; ticket_for(s, ticket);
|
||||
snprintf(query, sizeof(query), "ticket=%s", ticket);
|
||||
assert(connect_websocket(&request, 10, s->view.id) == ESP_OK);
|
||||
return request.sess_ctx;
|
||||
}
|
||||
static void logout_hook(void) { assert(web_serial_transport_revoke_web_session(hook_id) == ESP_OK); }
|
||||
static void reuse_slot_hook(void) {
|
||||
make_slot_free_locked(&s_slots[0]);
|
||||
++s_slots[0].generation; s_slots[0].state = WEB_SERIAL_SLOT_ACTIVE;
|
||||
s_slots[0].principal = bob; s_slots[0].web_session_id = replacement.view.id;
|
||||
}
|
||||
int main(void) {
|
||||
assert(store_tests() == 0);
|
||||
serial_reset(); issued_t a = mint(&alice), b = mint(&alice), c = mint(&bob);
|
||||
char ta[33], tb[33], tc[33]; ticket_for(&a, ta); ticket_for(&b, tb); ticket_for(&c, tc);
|
||||
user_principal_t p; bool consumed;
|
||||
assert(consume_ticket(ta, b.view.id, &p, &consumed) == ESP_OK && !consumed);
|
||||
assert(consume_ticket(ta, 0, &p, &consumed) == ESP_OK && !consumed);
|
||||
assert(consume_ticket(ta, a.view.id, &p, &consumed) == ESP_OK && consumed);
|
||||
assert(consume_ticket(ta, a.view.id, &p, &consumed) == ESP_OK && !consumed);
|
||||
assert(web_serial_transport_mint_ticket(&bob, a.view.id, ta, sizeof(ta)) != ESP_OK);
|
||||
web_serial_slot_t *sa = connect_session(&a), *sb = connect_session(&b);
|
||||
ticket_for(&a, ta);
|
||||
expected_invalidated_id = a.view.id;
|
||||
assert(web_serial_transport_revoke_web_session(a.view.id) == ESP_OK);
|
||||
assert(!expected_invalidated_id && last_admin_id == a.view.id && !last_admin_username_length);
|
||||
assert(sa->close_requested && !sb->close_requested); absent(&a); present(&b); present(&c);
|
||||
assert(consume_ticket(ta, a.view.id, &p, &consumed) == ESP_OK && !consumed);
|
||||
assert(consume_ticket(tb, b.view.id, &p, &consumed) == ESP_OK && consumed);
|
||||
request.sess_ctx = sa; close_result = ESP_FAIL; process_close_request(sa);
|
||||
assert(!sa->close_triggered && sa->close_requested); close_result = ESP_OK;
|
||||
now += 100000; process_close_request(sa); assert(sa->close_triggered);
|
||||
web_serial_session_free(sa); process_broker_disconnect(sa); assert(sa->state == WEB_SERIAL_SLOT_FREE);
|
||||
issued_t d = mint(&alice); sa = connect_session(&d);
|
||||
assert(web_serial_transport_revoke_web_session(a.view.id) == ESP_OK && !sa->close_requested);
|
||||
ticket_for(&b, tb); ticket_for(&d, ta);
|
||||
expected_invalidated_id = b.view.id;
|
||||
assert(web_serial_transport_revoke_user((const uint8_t *)"alice", 5) == ESP_OK);
|
||||
assert(!expected_invalidated_id && !last_admin_id && last_admin_username_length == 5);
|
||||
assert(sa->close_requested && sb->close_requested); absent(&b); absent(&d); present(&c);
|
||||
assert(consume_ticket(ta, d.view.id, &p, &consumed) == ESP_OK && !consumed);
|
||||
assert(consume_ticket(tb, b.view.id, &p, &consumed) == ESP_OK && !consumed);
|
||||
assert(consume_ticket(tc, c.view.id, &p, &consumed) == ESP_OK && consumed);
|
||||
|
||||
/* Lost notification: authoritative checks at input and periodic owner step. */
|
||||
serial_reset(); a = mint(&alice); sa = connect_session(&a);
|
||||
unsigned before = writes; web_session_store_invalidate(a.view.id);
|
||||
assert(process_websocket_frame(&request) == ESP_FAIL && writes == before);
|
||||
serial_reset(); a = mint(&alice); sa = connect_session(&a);
|
||||
now = a.view.expires_at_us; process_principal_currentness(sa); assert(sa->close_requested);
|
||||
serial_reset(); a = mint(&alice); sa = connect_session(&a);
|
||||
stale_user = alice.user_id; now += WEB_SERIAL_CURRENTNESS_INTERVAL_US;
|
||||
process_principal_currentness(sa); assert(sa->close_requested); absent(&a);
|
||||
serial_reset(); a = mint(&alice); ticket_for(&a, ta); stale_user = alice.user_id;
|
||||
(void)consume_ticket(ta, a.view.id, &p, &consumed); assert(!consumed);
|
||||
serial_reset(); a = mint(&alice); sa = connect_session(&a); db_fail = true;
|
||||
before = writes;
|
||||
assert(process_websocket_frame(&request) == ESP_FAIL && writes == before);
|
||||
|
||||
/* In-flight mint/admission cannot publish after logout. */
|
||||
serial_reset(); a = mint(&alice); hook_id = a.view.id; rng_hook = logout_hook;
|
||||
assert(web_serial_transport_mint_ticket(&alice, a.view.id, ta, sizeof(ta)) != ESP_OK && !ta[0]);
|
||||
serial_reset(); a = mint(&alice); ticket_for(&a, ta); hook_id = a.view.id;
|
||||
snprintf(query, sizeof(query), "ticket=%s", ta); connect_hook = logout_hook;
|
||||
before = broker_disconnects;
|
||||
assert(connect_websocket(&request, 10, a.view.id) != ESP_OK);
|
||||
assert(broker_disconnects == before + 1 && s_slots[0].state == WEB_SERIAL_SLOT_FREE);
|
||||
serial_reset(); a = mint(&alice); sa = connect_session(&a); replacement = mint(&bob);
|
||||
now += WEB_SERIAL_CURRENTNESS_INTERVAL_US; stale_user = alice.user_id; db_hook = reuse_slot_hook;
|
||||
process_principal_currentness(sa); assert(!sa->close_requested);
|
||||
|
||||
/* Zero identity is no longer a Basic compatibility route. */
|
||||
serial_reset(); web_session_store_stop();
|
||||
assert(web_serial_transport_mint_ticket(&alice, 0, ta, sizeof(ta)) != ESP_OK);
|
||||
serial_reset(); a = mint(&alice); b = mint(&bob);
|
||||
expected_invalidated_id = a.view.id;
|
||||
assert(web_serial_transport_revoke_sessions() == ESP_OK); absent(&a); absent(&b);
|
||||
assert(!expected_invalidated_id && !last_admin_id && !last_admin_username_length);
|
||||
assert(snapshot().initialized); assert(!host_lock_depth && closes > 0);
|
||||
serial_reset(); a = mint(&alice); b = mint(&bob); s_initialized = false;
|
||||
expected_invalidated_id = a.view.id;
|
||||
unsigned notified = admin_revocations;
|
||||
assert(web_serial_transport_revoke_user((const uint8_t *)"alice", 5) == ESP_ERR_INVALID_STATE);
|
||||
assert(!expected_invalidated_id && admin_revocations == notified + 1);
|
||||
absent(&a); present(&b);
|
||||
serial_reset(); a = mint(&alice);
|
||||
for (unsigned field = 0; field < 6; ++field) {
|
||||
p = alice;
|
||||
switch (field) {
|
||||
case 0: ++p.user_id; break;
|
||||
case 1: ++p.auth_generation; break;
|
||||
case 2: p.role = USER_ROLE_USER; break;
|
||||
case 3: p.method = USER_AUTH_METHOD_SSH_PUBLIC_KEY; break;
|
||||
case 4: --p.username_length; break;
|
||||
case 5: p.username[0] = 'x'; break;
|
||||
}
|
||||
bool current = true;
|
||||
assert(web_session_store_check_principal(a.view.id, &p, ¤t) != ESP_OK && !current);
|
||||
present(&a);
|
||||
}
|
||||
serial_reset(); a = mint(&alice);
|
||||
char tickets[4][33];
|
||||
for (unsigned i = 0; i < 4; ++i) ticket_for(&a, tickets[i]);
|
||||
assert(web_serial_transport_mint_ticket(&alice, a.view.id, ta, sizeof(ta)) == ESP_ERR_NO_MEM);
|
||||
for (unsigned i = 0; i < 4; ++i)
|
||||
assert(consume_ticket(tickets[i], a.view.id, &p, &consumed) == ESP_OK && consumed);
|
||||
puts("PASS: serial/session binding, isolation, cleanup, races, no Basic fallback or live ticket eviction");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/* Public-API host tests; deliberately no access to private session records. */
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "web_session_store.h"
|
||||
#include "secure_random.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#ifdef HOST_OPENSSL
|
||||
#include <openssl/sha.h>
|
||||
#endif
|
||||
|
||||
int host_lock_depth;
|
||||
static int64_t now = 1000000;
|
||||
static unsigned rng_sequence, rng_calls, sha_calls, sha_fail_at;
|
||||
static const char *sha_expected_token;
|
||||
static void (*rng_hook)(void);
|
||||
static bool rng_fail, sha_fail, db_fail;
|
||||
static uint32_t stale_user;
|
||||
static void (*db_hook)(void);
|
||||
static web_session_id_t hook_id;
|
||||
static const char origin[] = "https://device.example";
|
||||
static user_principal_t alice = { .user_id = 1, .auth_generation = 7,
|
||||
.role = USER_ROLE_ADMIN, .method = USER_AUTH_METHOD_PASSWORD,
|
||||
.username_length = 5, .username = "alice" };
|
||||
static user_principal_t bob = { .user_id = 2, .auth_generation = 3,
|
||||
.role = USER_ROLE_USER, .method = USER_AUTH_METHOD_PASSWORD,
|
||||
.username_length = 3, .username = "bob" };
|
||||
typedef struct { char token[65]; web_session_view_t view; } issued_t;
|
||||
static issued_t replacement;
|
||||
|
||||
int64_t esp_timer_get_time(void) { return now; }
|
||||
bool user_database_username_valid(const uint8_t *name, size_t length) {
|
||||
return name != NULL && length > 0 && length <= USER_DATABASE_USERNAME_CAPACITY;
|
||||
}
|
||||
void secure_wipe(void *data, size_t size) {
|
||||
volatile unsigned char *p = data;
|
||||
while (size--) *p++ = 0;
|
||||
}
|
||||
esp_err_t secure_random_fill(void *output, size_t length) {
|
||||
assert(!host_lock_depth);
|
||||
++rng_calls;
|
||||
void (*hook)(void) = rng_hook;
|
||||
rng_hook = NULL;
|
||||
if (hook) hook();
|
||||
unsigned char *p = output;
|
||||
/* Unique per call; partial dirty output on failure exercises caller cleanup. */
|
||||
unsigned seed = ++rng_sequence;
|
||||
for (size_t i = 0; i < length; ++i) p[i] = (unsigned char)(seed + i * 17);
|
||||
return rng_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
int mbedtls_sha256(const unsigned char *data, size_t length,
|
||||
unsigned char *out, int is224) {
|
||||
assert(!host_lock_depth && !is224);
|
||||
memset(out, 0xa5, 32);
|
||||
++sha_calls;
|
||||
if (sha_fail_at && sha_calls == sha_fail_at) {
|
||||
assert(data == (const unsigned char *)sha_expected_token);
|
||||
assert(length == 64 && strlen(sha_expected_token) == 64);
|
||||
return -1;
|
||||
}
|
||||
if (sha_fail) return -1;
|
||||
#ifdef HOST_OPENSSL
|
||||
return SHA256(data, length, out) ? 0 : -1;
|
||||
#else
|
||||
/* Only a deterministic digest double; NOT cryptographic verification. */
|
||||
memset(out, 0, 32);
|
||||
for (size_t i = 0; i < length; ++i)
|
||||
out[i % 32] = (unsigned char)((out[i % 32] * 33U) ^ data[i] ^ i);
|
||||
return 0;
|
||||
#endif
|
||||
}
|
||||
esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *current) {
|
||||
assert(!host_lock_depth);
|
||||
void (*hook)(void) = db_hook;
|
||||
db_hook = NULL;
|
||||
if (hook) hook();
|
||||
*current = !db_fail && p->user_id != stale_user;
|
||||
return db_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
static void zero(const void *data, size_t length) {
|
||||
const unsigned char *p = data;
|
||||
for (size_t i = 0; i < length; ++i) assert(p[i] == 0);
|
||||
}
|
||||
static web_session_store_snapshot_t snapshot(void) {
|
||||
web_session_store_snapshot_t s;
|
||||
assert(web_session_store_get_snapshot(&s) == ESP_OK);
|
||||
return s;
|
||||
}
|
||||
static esp_err_t issue(const user_principal_t *p, issued_t *s) {
|
||||
memset(s, 0xa5, sizeof(*s));
|
||||
return web_session_store_issue(p, origin, strlen(origin), s->token, &s->view);
|
||||
}
|
||||
static issued_t mint(const user_principal_t *p) {
|
||||
issued_t s;
|
||||
assert(issue(p, &s) == ESP_OK);
|
||||
assert(s.view.id && strlen(s.token) == 64 && strlen(s.view.csrf) == 64);
|
||||
assert(s.view.expires_at_us == now + WEB_SESSION_STORE_LIFETIME_US);
|
||||
assert(s.view.principal.user_id == p->user_id);
|
||||
assert(s.view.principal.auth_generation == p->auth_generation);
|
||||
assert(s.view.principal.role == p->role && s.view.principal.method == p->method);
|
||||
assert(s.view.principal.username_length == p->username_length);
|
||||
assert(!strcmp(s.view.principal.username, p->username));
|
||||
return s;
|
||||
}
|
||||
static esp_err_t lookup(const issued_t *s, web_session_view_t *v) {
|
||||
memset(v, 0xa5, sizeof(*v));
|
||||
return web_session_store_lookup(s->token, strlen(s->token), origin, strlen(origin), v);
|
||||
}
|
||||
static void present(const issued_t *s) {
|
||||
web_session_view_t v;
|
||||
assert(lookup(s, &v) == ESP_OK);
|
||||
assert(v.id == s->view.id && v.expires_at_us == s->view.expires_at_us);
|
||||
assert(!strcmp(v.csrf, s->view.csrf));
|
||||
assert(!memcmp(&v.principal, &s->view.principal, sizeof(v.principal)));
|
||||
bool current = false;
|
||||
assert(web_session_store_is_current(v.id, ¤t) == ESP_OK && current);
|
||||
}
|
||||
static void absent(const issued_t *s) {
|
||||
web_session_view_t v;
|
||||
assert(lookup(s, &v) == ESP_ERR_NOT_FOUND);
|
||||
zero(&v, sizeof(v));
|
||||
bool current = true;
|
||||
(void)web_session_store_is_current(s->view.id, ¤t);
|
||||
assert(!current);
|
||||
}
|
||||
static void reset(void) {
|
||||
web_session_store_stop();
|
||||
rng_fail = sha_fail = db_fail = false;
|
||||
stale_user = 0;
|
||||
db_hook = rng_hook = NULL;
|
||||
sha_fail_at = 0;
|
||||
sha_expected_token = NULL;
|
||||
assert(web_session_store_init() == ESP_OK);
|
||||
assert(snapshot().active == 0);
|
||||
}
|
||||
static void invalidate_hook(void) { web_session_store_invalidate(hook_id); }
|
||||
static void user_hook(void) { web_session_store_invalidate_user(alice.user_id); }
|
||||
static void stop_hook(void) { web_session_store_stop(); }
|
||||
static void replace_hook(void) {
|
||||
web_session_store_invalidate(hook_id);
|
||||
replacement = mint(&bob);
|
||||
}
|
||||
static void expire_hook(void) { ++now; }
|
||||
|
||||
int main(void) {
|
||||
issued_t s, a, b, c, slots[4];
|
||||
web_session_view_t v;
|
||||
bool current = true;
|
||||
assert(issue(&alice, &s) == ESP_ERR_INVALID_STATE);
|
||||
zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
memset(&s, 0, sizeof(s)); memset(s.token, 'a', 64);
|
||||
assert(lookup(&s, &v) == ESP_ERR_INVALID_STATE); zero(&v, sizeof(v));
|
||||
assert(web_session_store_is_current(1, ¤t) == ESP_ERR_INVALID_STATE && !current);
|
||||
rng_fail = true;
|
||||
assert(web_session_store_init() != ESP_OK);
|
||||
assert(!snapshot().initialized && snapshot().init_failures == 1);
|
||||
rng_fail = false;
|
||||
assert(web_session_store_init() == ESP_OK);
|
||||
a = mint(&alice);
|
||||
unsigned calls = rng_calls;
|
||||
rng_fail = true;
|
||||
assert(web_session_store_init() == ESP_OK && calls == rng_calls);
|
||||
rng_fail = false; present(&a);
|
||||
|
||||
reset();
|
||||
web_session_store_snapshot_t before = snapshot();
|
||||
for (unsigned i = 0; i < 4; ++i) slots[i] = mint(&alice);
|
||||
assert(issue(&bob, &s) == ESP_ERR_NO_MEM);
|
||||
zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
for (unsigned i = 0; i < 4; ++i) present(&slots[i]);
|
||||
assert(snapshot().active == 4 && snapshot().issued == before.issued + 4);
|
||||
assert(snapshot().capacity_rejections == before.capacity_rejections + 1);
|
||||
assert(snapshot().storage_bytes >= 4 * snapshot().slot_bytes && snapshot().slot_bytes > 0);
|
||||
a = slots[0];
|
||||
char bad[65]; strcpy(bad, a.token); bad[0] = bad[0] == 'a' ? 'b' : 'a';
|
||||
memset(&v, 0xa5, sizeof(v));
|
||||
assert(web_session_store_lookup(bad, 64, origin, strlen(origin), &v) == ESP_ERR_NOT_FOUND);
|
||||
zero(&v, sizeof(v));
|
||||
strcpy(bad, a.token); bad[0] = '!';
|
||||
assert(web_session_store_lookup(bad, 64, origin, strlen(origin), &v) != ESP_OK);
|
||||
assert(web_session_store_lookup(a.token, 63, origin, strlen(origin), &v) != ESP_OK);
|
||||
assert(web_session_store_lookup(a.token, 64, "https://other", 13, &v) == ESP_ERR_NOT_FOUND);
|
||||
zero(&v, sizeof(v)); present(&a);
|
||||
now = a.view.expires_at_us - 1; present(&a);
|
||||
now++; absent(&a);
|
||||
assert(snapshot().active == 0 && snapshot().expired == before.expired + 4);
|
||||
|
||||
reset(); a = mint(&alice); b = mint(&alice); c = mint(&bob);
|
||||
before = snapshot();
|
||||
web_session_store_invalidate(a.view.id); absent(&a); present(&b); present(&c);
|
||||
s = mint(&alice); assert(s.view.id != a.view.id);
|
||||
web_session_store_invalidate(a.view.id); present(&s);
|
||||
web_session_store_invalidate_user(alice.user_id);
|
||||
absent(&b); absent(&s); present(&c);
|
||||
assert(snapshot().invalidated == before.invalidated + 3);
|
||||
web_session_store_stop(); assert(!snapshot().initialized && snapshot().active == 0);
|
||||
assert(web_session_store_init() == ESP_OK);
|
||||
s = mint(&bob); assert(s.view.id != c.view.id); absent(&c); present(&s);
|
||||
|
||||
reset(); stale_user = alice.user_id;
|
||||
assert(issue(&alice, &s) != ESP_OK); zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
stale_user = 0; a = mint(&alice); b = mint(&bob);
|
||||
stale_user = alice.user_id;
|
||||
assert(lookup(&a, &v) != ESP_OK); zero(&v, sizeof(v));
|
||||
stale_user = 0; absent(&a); present(&b);
|
||||
db_fail = true;
|
||||
assert(lookup(&b, &v) != ESP_OK); zero(&v, sizeof(v));
|
||||
db_fail = false; absent(&b); assert(snapshot().active == 0);
|
||||
a = mint(&alice); b = mint(&bob); stale_user = alice.user_id;
|
||||
web_session_store_prune(); stale_user = 0; absent(&a); present(&b);
|
||||
db_fail = true; web_session_store_prune(); db_fail = false; absent(&b);
|
||||
|
||||
reset();
|
||||
for (unsigned mode = 0; mode < 2; ++mode) {
|
||||
rng_fail = mode == 0; sha_fail = mode == 1;
|
||||
assert(issue(&alice, &s) != ESP_OK);
|
||||
zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
assert(snapshot().active == 0);
|
||||
rng_fail = sha_fail = false;
|
||||
}
|
||||
a = mint(&alice); sha_fail = true;
|
||||
assert(lookup(&a, &v) != ESP_OK); zero(&v, sizeof(v));
|
||||
sha_fail = false; present(&a);
|
||||
user_principal_t key = alice; key.method = USER_AUTH_METHOD_SSH_PUBLIC_KEY;
|
||||
assert(issue(&key, &s) != ESP_OK); zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
|
||||
reset(); a = mint(&alice); hook_id = a.view.id; db_hook = invalidate_hook;
|
||||
assert(lookup(&a, &v) != ESP_OK); zero(&v, sizeof(v)); absent(&a);
|
||||
a = mint(&alice); hook_id = a.view.id; db_hook = invalidate_hook; current = true;
|
||||
(void)web_session_store_is_current(a.view.id, ¤t); assert(!current);
|
||||
b = mint(&bob); db_hook = user_hook;
|
||||
assert(issue(&alice, &s) != ESP_OK); zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
present(&b);
|
||||
db_hook = stop_hook;
|
||||
assert(lookup(&b, &v) != ESP_OK); zero(&v, sizeof(v));
|
||||
reset(); db_hook = stop_hook;
|
||||
assert(issue(&alice, &s) != ESP_OK); zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
assert(!snapshot().initialized && snapshot().active == 0);
|
||||
/* Empty prune cannot consume this hook: invalidate during candidate validation. */
|
||||
reset(); db_hook = user_hook;
|
||||
assert(issue(&alice, &s) == ESP_ERR_INVALID_STATE && db_hook == NULL);
|
||||
zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
assert(snapshot().active == 0);
|
||||
a = mint(&alice); present(&a);
|
||||
|
||||
reset(); a = mint(&alice); hook_id = a.view.id; db_hook = replace_hook;
|
||||
assert(lookup(&a, &v) == ESP_ERR_NOT_FOUND && db_hook == NULL);
|
||||
zero(&v, sizeof(v));
|
||||
assert(replacement.view.id != a.view.id && snapshot().active == 1);
|
||||
absent(&a); present(&replacement);
|
||||
|
||||
for (unsigned mode = 0; mode < 2; ++mode) {
|
||||
reset(); a = mint(&alice); before = snapshot();
|
||||
now = a.view.expires_at_us - 1; db_hook = expire_hook;
|
||||
if (mode == 0) {
|
||||
assert(lookup(&a, &v) == ESP_ERR_NOT_FOUND); zero(&v, sizeof(v));
|
||||
} else {
|
||||
current = true;
|
||||
assert(web_session_store_is_current(a.view.id, ¤t) == ESP_ERR_NOT_FOUND);
|
||||
assert(!current);
|
||||
}
|
||||
assert(db_hook == NULL && now == a.view.expires_at_us);
|
||||
assert(snapshot().expired == before.expired + 1); absent(&a);
|
||||
}
|
||||
|
||||
reset(); web_session_store_stop(); before = snapshot(); rng_hook = stop_hook;
|
||||
assert(web_session_store_init() == ESP_ERR_INVALID_STATE && rng_hook == NULL);
|
||||
assert(!snapshot().initialized && snapshot().active == 0);
|
||||
assert(snapshot().init_failures == before.init_failures + 1);
|
||||
assert(issue(&alice, &s) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_session_store_init() == ESP_OK); a = mint(&alice); present(&a);
|
||||
|
||||
reset(); sha_calls = 0; sha_fail_at = 2; sha_expected_token = s.token;
|
||||
assert(issue(&alice, &s) != ESP_OK && sha_calls == 2);
|
||||
zero(s.token, sizeof(s.token)); zero(&s.view, sizeof(s.view));
|
||||
assert(snapshot().active == 0);
|
||||
sha_fail_at = 0; sha_expected_token = NULL; a = mint(&alice); present(&a);
|
||||
assert(!host_lock_depth);
|
||||
puts("PASS: web_session_store public API, failure cleanup, and callback races");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Browser Session and Terminal Selector Tests
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
python3 tests/web_ui_session/run.py
|
||||
```
|
||||
|
||||
Requires a host C compiler, Python 3, and Node with Fetch/Response/ReadableStream
|
||||
support (Node 18+). All compiler outputs and rendered scripts are temporary; no
|
||||
firmware build, generated assets, or device writes are performed.
|
||||
|
||||
The runner compiles production `src/web_ui.c` with HTTPD and vendored-asset data
|
||||
doubles. It reuses the HTTPD stub text from `tests/web_login_ui/run.py`, without
|
||||
importing/executing that runner. Node executes the actual C-rendered application
|
||||
and inline asset-failure script, not a separately maintained implementation.
|
||||
|
||||
Coverage:
|
||||
|
||||
- Resource selection, NULL/invalid input, setter/send failure propagation,
|
||||
eight-header ceiling, no-store document/application, unchanged vendor caching,
|
||||
nosniff/no-referrer/frame denial, exact inline-loader CSP hash and login fallback.
|
||||
- Session validation before initial/retried/restored connections; memory-only
|
||||
CSRF header and empty ticket/logout bodies; safe-text username/absolute expiry.
|
||||
- 401 shutdown and navigation once; manual recovery on 403; bounded Retry-After
|
||||
display/backoff for capacity; network errors never assert successful logout.
|
||||
- Confirmed 204 logout, lost response confirmed by session 401, uncertain logout,
|
||||
cancellation, explicit recovery, and stale session/ticket/status/logout/WS work.
|
||||
- Pagehide/bfcache restoration, late response bodies, and superseded session checks.
|
||||
- Existing writer controls, 1,024-byte binary input chunks, raw binary output,
|
||||
observer input gating, and explicit Disconnect pausing reconnect.
|
||||
- Authentication/ticket response cap 512 bytes, existing status cap 3,072 bytes,
|
||||
15-second request deadline, single status request in flight, bounded retry delay,
|
||||
and unchanged 5,000-line terminal scrollback.
|
||||
- Admin-only navigation, explicit protected-ticket open, 20 switches preserving
|
||||
serial socket/client/writer IDs, separate output and selected-only input.
|
||||
- Independent 64 KiB pending output limits, visible dropped-byte counters and
|
||||
resumed hidden output draining; 512-byte admin input frames and overflow close.
|
||||
- Admin close/reopen/remote exit isolation, 401/logout/expiry/pagehide cancellation,
|
||||
late tickets/callbacks, handshake timeout, selected resize and three listener
|
||||
cleanup/restore cycles.
|
||||
- Session identity changes (username, role or session-stable CSRF) force a clean
|
||||
document before view adoption, close old admin and prevent replacement-session
|
||||
logout; same-session restore retains both scrollbacks behind validation gating.
|
||||
- Undefined initial dimensions recover at unchanged bounds; failed fits never
|
||||
populate the cache, readiness retries stop at three, and teardown fences stale
|
||||
callbacks even after restore. Sixteen Node groups total.
|
||||
|
||||
## Integration and known gaps
|
||||
|
||||
This covers the Phase 8D.3 browser session behavior and 8D.6 selector. The renderer
|
||||
still relies on its caller to authenticate resources; protected asset failures
|
||||
must be 401, never a redirect to HTML served as JavaScript. No Basic fallback is
|
||||
implemented here. Existing 8D.5 server authorization/protocols are unchanged.
|
||||
|
||||
These tests model DOM, timers, fetch cancellation and WebSocket events. They do
|
||||
not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior,
|
||||
actual bfcache policy, cookie expiry, server revocation, or hardware serial byte
|
||||
integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout.
|
||||
The 8D.6 firmware build and pending target checklist are recorded separately in
|
||||
`docs/phase8d6_implementation.md`. No target resource reserve is claimed. Browser secret
|
||||
references are dropped and never persisted/logged, but JavaScript cannot securely
|
||||
wipe engine-managed strings.
|
||||
@@ -0,0 +1,370 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
const {script, loader} = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
|
||||
const token = 'a'.repeat(64);
|
||||
const json = value => new Response(JSON.stringify(value));
|
||||
const session = (extra = {}) => json({username: '<img>', role: 'user', csrf: token, expires_in: 3600, ...extra});
|
||||
const ticket = () => json({ticket: 't'.repeat(32)});
|
||||
const failure = status => new Response('SECRET ERROR BODY', {status, headers: {'Retry-After': '7'}});
|
||||
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return {promise, resolve}; };
|
||||
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
|
||||
function browser({onlyLoader = false, withLoader = false, role = 'user'} = {}) {
|
||||
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': []};
|
||||
const fits = [];
|
||||
let serial = 0;
|
||||
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
|
||||
const emit = (key, event = {}) => { for (const fn of events[key] || []) fn(event); };
|
||||
const timeout = (fn, ms, interval = false) => { timers.set(++serial, {fn, ms, interval}); return serial; };
|
||||
class Socket {
|
||||
static OPEN = 1;
|
||||
constructor(url) { this.url = url; this.readyState = 0; this.bufferedAmount = 0; this.events = {}; this.sent = []; sockets.push(this); }
|
||||
addEventListener(k, fn) { this.events[k] = fn; }
|
||||
emit(k, event = {}) { if (k === 'open') this.readyState = 1; this.events[k]?.(event); this['on' + k]?.(event); }
|
||||
close() { this.closed = true; this.readyState = 3; this.emit('close'); }
|
||||
send(value) { this.sent.push(value); }
|
||||
}
|
||||
class Terminal {
|
||||
constructor(options) { this.options = options; this.writes = []; terminals.push(this); }
|
||||
loadAddon() {} open() {} focus() {} resize(cols, rows) { this.cols = cols; this.rows = rows; } onData(fn) { this.input = fn; }
|
||||
write(bytes, callback) { this.writes.push([...bytes]); if (this.holdWrites) (this.pending ||= []).push(callback); else callback?.(); }
|
||||
}
|
||||
const window = {addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); },
|
||||
setTimeout: timeout, clearTimeout: id => timers.delete(id),
|
||||
setInterval: (fn, ms) => timeout(fn, ms, true), clearInterval: id => timers.delete(id),
|
||||
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
|
||||
location: {origin: 'https://sak.local', replace: path => redirects.push(path)}};
|
||||
const context = vm.createContext({window, document: {getElementById(id) {
|
||||
return nodes[id] ||= {textContent: '', dataset: {}, classList: {toggle() {}},
|
||||
setAttribute(k, v) { this[k] = v; },
|
||||
getBoundingClientRect: () => ({width: 100, height: 100}),
|
||||
addEventListener(k, fn) { this[k] = fn; }};
|
||||
}}, Terminal, FitAddon: {FitAddon: class {
|
||||
constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
|
||||
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
|
||||
}},
|
||||
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date, WebSocket: Socket,
|
||||
fetch: async (url, options) => {
|
||||
// Apply the Origin regression guard to every mutation, including logout.
|
||||
assert.ok(Object.hasOwn(queues, url));
|
||||
if (options.method === 'POST') assert.equal(options.mode, 'cors');
|
||||
assert.equal(options.headers?.Origin, undefined);
|
||||
calls.push({url, ...options});
|
||||
const next = queues[url].shift();
|
||||
if (next !== undefined) return typeof next === 'function' ? next(options) : next;
|
||||
if (url === '/api/session') return session({role});
|
||||
if (url === '/api/status') return json({});
|
||||
if (url === '/api/ws-ticket') return ticket();
|
||||
if (url === '/api/admin/ws-ticket') return json({ticket: '0123456789abcdef'.repeat(4), expires_in: 30});
|
||||
throw new Error('network unavailable');
|
||||
}});
|
||||
if (withLoader || onlyLoader) vm.runInContext(loader, context);
|
||||
const start = () => vm.runInContext(script, context);
|
||||
const fire = ms => {
|
||||
const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`);
|
||||
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
|
||||
};
|
||||
return {nodes, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
|
||||
click: id => nodes[id].click(), window};
|
||||
}
|
||||
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
|
||||
let passed = 0;
|
||||
async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', name); }
|
||||
(async () => {
|
||||
await test('bootstrap, CSRF, bounded expiry safe text, serial protocol and disconnect pause', async () => {
|
||||
const b = await connected();
|
||||
assert.equal(b.calls[0].url, '/api/session');
|
||||
const post = b.calls.find(c => c.url === '/api/ws-ticket');
|
||||
assert.equal(post.method, 'POST'); assert.equal(post.body, ''); assert.equal(post.headers['X-CSRF-Token'], token);
|
||||
for (const call of b.calls) for (const [k, v] of Object.entries({credentials: 'same-origin', mode: call.method === 'POST' ? 'cors' : 'same-origin', cache: 'no-store', redirect: 'error'})) assert.equal(call[k], v);
|
||||
assert.match(b.nodes['session-info'].textContent, /^<img>.*one hour absolute/);
|
||||
const ws = b.sockets[0], term = b.terminals[0]; ws.emit('open');
|
||||
ws.emit('message', {data: JSON.stringify({type: 'hello', clientId: 8, writerId: 8, role: 'writer'})});
|
||||
term.input('x'.repeat(2050)); assert.deepEqual(ws.sent.map(x => x.length), [1024, 1024, 2]);
|
||||
ws.emit('message', {data: Uint8Array.of(0, 255, 13, 10).buffer}); assert.deepEqual(term.writes, [[0, 255, 13, 10]]);
|
||||
b.click('release-control'); assert.equal(ws.sent.at(-1), 'release-writer');
|
||||
ws.emit('message', {data: JSON.stringify({type: 'writer', writerId: 0, role: 'observer'})});
|
||||
b.click('request-control'); assert.equal(ws.sent.at(-1), 'request-writer');
|
||||
b.click('connection-toggle'); assert.ok(ws.closed); assert.equal(b.nodes['connection-toggle'].textContent, 'Connect');
|
||||
const count = b.calls.length; ws.emit('close'); await tick(); assert.equal(b.calls.length, count);
|
||||
b.click('connection-toggle'); await tick(); assert.equal(b.calls[count].url, '/api/session');
|
||||
});
|
||||
await test('401 at session/ticket/status stops everything and navigates only once', async () => {
|
||||
for (const path of ['/api/session', '/api/ws-ticket', '/api/status']) {
|
||||
const b = browser(); b.queues[path].push(failure(401)); b.start(); await tick();
|
||||
assert.deepEqual(b.redirects, ['/login']); assert.ok(b.sockets.every(s => s.closed));
|
||||
assert.equal(b.timers.size, 0); b.window.sakSessionExpired(); assert.equal(b.redirects.length, 1);
|
||||
b.click('connection-toggle'); await tick(); assert.equal(b.redirects.length, 1);
|
||||
}
|
||||
});
|
||||
await test('403 mutation is manual-only; capacity backoff is not credentials and revalidates', async () => {
|
||||
for (const status of [403, 429, 503]) {
|
||||
const b = browser(); b.queues['/api/ws-ticket'].push(failure(status)); b.start(); await tick();
|
||||
assert.deepEqual(b.redirects, []); assert.equal(b.sockets.length, 0);
|
||||
if (status === 403) {
|
||||
assert.match(b.nodes['connection-detail'].textContent, /security check/);
|
||||
assert.equal(b.nodes['connection-toggle'].textContent, 'Connect'); b.click('connection-toggle');
|
||||
} else {
|
||||
assert.match(b.nodes['connection-detail'].textContent, /capacity or backoff/); b.fire(7000);
|
||||
}
|
||||
await tick(); assert.equal(b.calls.filter(c => c.url === '/api/session').length, 2);
|
||||
assert.equal(b.sockets.length, 1);
|
||||
}
|
||||
});
|
||||
await test('logout success, lost success, uncertain network and explicit recovery', async () => {
|
||||
for (const outcome of ['204', 'lost401', 'lost200', 'offline', '403', '503']) {
|
||||
const b = await connected();
|
||||
b.queues['/api/logout'].push(outcome === '204' ? new Response(null, {status: 204}) :
|
||||
['403', '503'].includes(outcome) ? failure(Number(outcome)) : () => { throw new Error('SECRET NETWORK'); });
|
||||
if (outcome === 'lost401') b.queues['/api/session'].push(session(), failure(401));
|
||||
if (outcome === 'offline') b.queues['/api/session'].push(session(), () => { throw new Error('offline'); });
|
||||
await b.click('sign-out'); await tick(); assert.ok(b.sockets[0].closed);
|
||||
assert.equal(b.calls.filter(c => c.url === '/api/logout').length, 1);
|
||||
const post = b.calls.find(c => c.url === '/api/logout'); assert.equal(post.body, ''); assert.equal(post.headers['X-CSRF-Token'], token);
|
||||
if (['204', 'lost401'].includes(outcome)) { assert.deepEqual(b.redirects, ['/login']); assert.equal(b.timers.size, 0); }
|
||||
else {
|
||||
assert.deepEqual(b.redirects, []); assert.match(b.nodes['connection-status'].textContent, /not confirmed/);
|
||||
assert.ok(!b.nodes['connection-detail'].textContent.includes('SECRET'));
|
||||
assert.equal(b.nodes['sign-out'].disabled, false);
|
||||
const count = b.calls.length; b.click('connection-toggle'); await tick(); assert.equal(b.calls[count].url, '/api/session');
|
||||
assert.equal(b.sockets.length, 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
await test('logout cancels pending status/ticket/session; late 401 and WS events cannot affect new work', async () => {
|
||||
for (const path of ['/api/session', '/api/ws-ticket', '/api/status']) {
|
||||
const d = deferred(), b = browser(); b.queues[path].push(d.promise); b.start(); await tick();
|
||||
b.queues['/api/logout'].push(failure(403)); await b.click('sign-out'); await tick();
|
||||
assert.ok(b.calls.find(c => c.url === path).signal.aborted);
|
||||
const detail = b.nodes['connection-detail'].textContent;
|
||||
d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []); assert.equal(b.nodes['connection-detail'].textContent, detail);
|
||||
}
|
||||
const b = await connected(), old = b.sockets[0]; b.click('connection-toggle'); b.click('connection-toggle'); await tick();
|
||||
old.emit('open'); old.emit('message', {data: JSON.stringify({type: 'hello', clientId: 99, writerId: 99, role: 'writer'})}); old.emit('error'); old.emit('close');
|
||||
assert.equal(b.nodes['client-id'].textContent, '—'); assert.equal(b.terminals[0].options.disableStdin, true);
|
||||
});
|
||||
await test('pagehide/restore revalidates, preserves pause; late logout cannot navigate restored page', async () => {
|
||||
for (const paused of [false, true]) {
|
||||
const b = await connected(); if (paused) b.click('connection-toggle');
|
||||
b.emit('pagehide'); const count = b.calls.length; b.emit('pageshow', {persisted: true}); await tick();
|
||||
assert.equal(b.calls[count].url, '/api/session'); assert.equal(b.sockets.length, paused ? 1 : 2);
|
||||
if (paused) assert.equal(b.nodes['connection-toggle'].textContent, 'Connect');
|
||||
}
|
||||
const b = await connected(), d = deferred(); b.queues['/api/logout'].push(d.promise);
|
||||
const pending = b.click('sign-out'); await tick(); b.emit('pagehide'); b.emit('pageshow', {persisted: true}); await tick();
|
||||
d.resolve(new Response(null, {status: 204})); await pending; assert.deepEqual(b.redirects, []);
|
||||
});
|
||||
await test('bounded schema/body validation, timeout, expiry and retry session checks', async () => {
|
||||
for (const response of [session({csrf: 'A'.repeat(64)}), session({expires_in: 3601}), session({expires_in: -1}),
|
||||
session({expires_in: 1.5}), session({role: 'root'}), session({username: 'x'.repeat(17)}),
|
||||
new Response(' '.repeat(513)), new Response(Uint8Array.of(255)), json(null)]) {
|
||||
const b = browser(); b.queues['/api/session'].push(response); b.start(); await tick();
|
||||
assert.equal(b.sockets.length, 0); assert.equal(b.calls.length, 1); assert.ok(b.calls[0].signal.aborted);
|
||||
}
|
||||
const b = browser(); b.queues['/api/session'].push(o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('timeout')))));
|
||||
b.start(); b.fire(15000); await tick(); b.fire(1000); await tick(); assert.equal(b.calls[1].url, '/api/session');
|
||||
const c = browser(); c.queues['/api/session'].push(session({expires_in: 2})); c.start(); await tick();
|
||||
const expiry = [...c.timers.values()].find(timer => timer.ms >= 0 && timer.ms <= 2000);
|
||||
assert.ok(expiry); c.fire(expiry.ms); assert.deepEqual(c.redirects, ['/login']);
|
||||
});
|
||||
await test('late body completions and superseded restore session are ignored', async () => {
|
||||
for (const path of ['/api/session', '/api/ws-ticket', '/api/status']) {
|
||||
let stream;
|
||||
const b = browser(); b.queues[path].push(new Response(new ReadableStream({start(c) { stream = c; }})));
|
||||
b.start(); await tick(); b.emit('pagehide');
|
||||
const text = path === '/api/session' ? {username: 'late', role: 'admin', csrf: token, expires_in: 3600} :
|
||||
path === '/api/ws-ticket' ? {ticket: 't'.repeat(32)} : {wifi: {available: true, state: 'LATE'}};
|
||||
stream.enqueue(new TextEncoder().encode(JSON.stringify(text))); stream.close(); await tick();
|
||||
assert.ok(b.sockets.every(socket => socket.closed)); assert.deepEqual(b.redirects, []); assert.equal(b.timers.size, 0);
|
||||
assert.ok(!b.nodes['wifi-summary'].textContent.includes('LATE'));
|
||||
}
|
||||
const b = await connected(); b.click('connection-toggle'); b.emit('pagehide');
|
||||
const d = deferred(); b.queues['/api/session'].push(d.promise);
|
||||
b.emit('pageshow', {persisted: true}); await tick(); b.click('connection-toggle'); await tick();
|
||||
d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []); assert.equal(b.sockets.length, 2);
|
||||
});
|
||||
await test('inline asset failures: 401 login, offline usable fallback, pagehide and shared navigation guard', async () => {
|
||||
for (const status of [401, 503]) {
|
||||
const b = browser({onlyLoader: true}); b.queues['/api/session'].push(failure(status));
|
||||
b.emit('error', {target: {tagName: 'SCRIPT'}}); await tick();
|
||||
assert.deepEqual(b.redirects, status === 401 ? ['/login'] : []); assert.equal(b.timers.size, 0);
|
||||
b.emit('error', {target: {tagName: 'SCRIPT'}}); assert.equal(b.calls.length, 1);
|
||||
}
|
||||
const b = browser({onlyLoader: true}), d = deferred(); b.queues['/api/session'].push(d.promise);
|
||||
b.emit('error', {target: {tagName: 'LINK'}}); b.emit('pagehide'); d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []);
|
||||
const c = browser({withLoader: true}); c.start(); await tick();
|
||||
c.queues['/api/session'].push(failure(401)); c.emit('error', {target: {tagName: 'IMG'}}); await tick();
|
||||
assert.deepEqual(c.redirects, ['/login']); assert.ok(c.sockets[0].closed); assert.equal(c.timers.size, 0);
|
||||
});
|
||||
async function adminBrowser() {
|
||||
const b = browser({role: 'admin'}); b.queues['/api/session'].push(session({role: 'admin'}));
|
||||
b.start(); await tick(); const ws = b.sockets[0]; ws.emit('open');
|
||||
ws.emit('message', {data: JSON.stringify({type: 'hello', clientId: 8, writerId: 8, role: 'writer'})});
|
||||
b.click('select-admin'); b.click('admin-toggle'); await tick();
|
||||
assert.equal(b.sockets.length, 2); b.sockets[1].emit('open'); return b;
|
||||
}
|
||||
await test('ordinary user is serial-only; admin selection preserves socket/lease and isolates input/output', async () => {
|
||||
const u = await connected(); assert.equal(u.nodes['terminal-selector'].hidden, true);
|
||||
u.click('select-admin'); u.click('admin-toggle'); await tick(); assert.equal(u.terminals.length, 1); assert.equal(u.sockets.length, 1);
|
||||
const b = await adminBrowser(), [serial, admin] = b.sockets, [st, at] = b.terminals;
|
||||
assert.equal(admin.url, 'wss://sak.local/ws/admin?ticket=' + '0123456789abcdef'.repeat(4));
|
||||
assert.equal(b.calls.find(c => c.url === '/api/admin/ws-ticket').headers['X-CSRF-Token'], token);
|
||||
st.input('WRONG'); at.input('x'.repeat(1025)); assert.equal(serial.sent.length, 0);
|
||||
assert.deepEqual(admin.sent.map(x => x.length), [512, 512, 1]);
|
||||
serial.emit('message', {data: Uint8Array.of(0, 255).buffer});
|
||||
admin.emit('message', {data: Uint8Array.of(27, 91).buffer});
|
||||
assert.deepEqual(st.writes, [[0, 255]]); assert.deepEqual(at.writes, [[27, 91]]);
|
||||
b.click('release-control'); assert.equal(serial.sent.at(-1), 'release-writer');
|
||||
for (let i = 0; i < 20; ++i) { b.click('select-serial'); at.input('WRONG'); b.click('select-admin'); }
|
||||
assert.equal(b.sockets.length, 2); assert.equal(b.terminals.length, 2); assert.ok(!serial.closed);
|
||||
assert.equal(b.nodes['client-id'].textContent, '8'); assert.equal(b.nodes['writer-id'].textContent, '8');
|
||||
assert.equal(b.nodes['release-control'].disabled, false); assert.equal(admin.sent.length, 3);
|
||||
b.click('admin-toggle'); assert.ok(admin.closed); assert.equal(admin.onmessage, null); assert.ok(!serial.closed);
|
||||
b.click('admin-toggle'); await tick(); const replacement = b.sockets[2]; replacement.emit('open'); replacement.emit('close');
|
||||
assert.ok(!serial.closed); assert.equal(b.nodes['admin-toggle'].textContent, 'Open admin');
|
||||
b.click('select-serial'); st.input('ok'); assert.deepEqual([...serial.sent.at(-1)], [111, 107]);
|
||||
});
|
||||
await test('admin rejects serial-format and malformed tickets before upgrade without disturbing serial', async () => {
|
||||
for (const value of ['t'.repeat(32), 'a'.repeat(63), 'a'.repeat(65), 'g'.repeat(64), null]) {
|
||||
const b = browser({role: 'admin'}); b.start(); await tick();
|
||||
const serial = b.sockets[0]; serial.emit('open');
|
||||
b.queues['/api/admin/ws-ticket'].push(json({ticket: value}));
|
||||
b.click('select-admin'); b.click('admin-toggle'); await tick();
|
||||
assert.equal(b.sockets.length, 1); assert.ok(!serial.closed);
|
||||
assert.equal(b.nodes['admin-detail'].textContent, 'Admin connection failed. Open admin to retry.');
|
||||
}
|
||||
});
|
||||
await test('bounded hidden output continues draining, separate scrollback and input overflow closes only admin', async () => {
|
||||
const b = await adminBrowser(), [serial, admin] = b.sockets, [st, at] = b.terminals;
|
||||
assert.equal(st.options.scrollback, 5000); assert.equal(at.options.scrollback, 5000);
|
||||
st.holdWrites = true;
|
||||
for (let i = 0; i < 65; ++i) serial.emit('message', {data: new Uint8Array(1024).buffer});
|
||||
assert.equal(st.writes.length, 64); assert.match(b.nodes['output-detail'].textContent, /serial 1024 B, admin 0 B/);
|
||||
st.pending.shift()(); serial.emit('message', {data: Uint8Array.of(42).buffer}); assert.equal(st.writes.at(-1)[0], 42);
|
||||
at.input('x'.repeat(4097)); assert.ok(admin.closed); assert.ok(!serial.closed); assert.equal(admin.sent.length, 0);
|
||||
});
|
||||
await test('admin failures and cancellation are isolated; 401/logout/expiry/pagehide close both', async () => {
|
||||
for (const status of [403, 503, 401]) {
|
||||
const b = await adminBrowser(), serial = b.sockets[0]; b.click('admin-toggle');
|
||||
b.queues['/api/admin/ws-ticket'].push(failure(status)); b.click('admin-toggle'); await tick();
|
||||
assert.equal(!!serial.closed, status === 401);
|
||||
assert.deepEqual(b.redirects, status === 401 ? ['/login'] : []);
|
||||
assert.equal(b.sockets.length, 2);
|
||||
}
|
||||
for (const action of ['pagehide', 'expiry', 'logout']) {
|
||||
const b = await adminBrowser();
|
||||
if (action === 'pagehide') b.emit('pagehide');
|
||||
if (action === 'expiry') b.window.sakSessionExpired();
|
||||
if (action === 'logout') { b.queues['/api/logout'].push(new Response(null, {status: 204})); await b.click('sign-out'); }
|
||||
await tick(); assert.ok(b.sockets.every(s => s.closed)); assert.equal(b.sockets[1].onmessage, null);
|
||||
if (action === 'pagehide') {
|
||||
b.queues['/api/session'].push(session({role: 'admin'})); b.emit('pageshow', {persisted: true}); await tick();
|
||||
assert.equal(b.sockets.length, 3); assert.match(b.sockets[2].url, /\/ws\/serial/);
|
||||
assert.equal(b.nodes['admin-toggle'].textContent, 'Open admin');
|
||||
}
|
||||
}
|
||||
const b = await adminBrowser(); b.click('admin-toggle'); const d = deferred();
|
||||
b.queues['/api/admin/ws-ticket'].push(d.promise); b.click('admin-toggle'); await tick();
|
||||
b.click('admin-toggle'); d.resolve(failure(401)); await tick();
|
||||
assert.deepEqual(b.redirects, []); assert.ok(!b.sockets[0].closed); assert.equal(b.sockets.length, 2);
|
||||
});
|
||||
await test('selected resize, listener cleanup, admin handshake deadline and stale callback fencing', async () => {
|
||||
const b = await adminBrowser(), [serial, old] = b.sockets;
|
||||
b.fire(-1); assert.equal(b.terminals[1].cols, 80); assert.equal(b.terminals[0].cols, undefined);
|
||||
b.click('select-serial'); b.fire(-1); assert.equal(b.terminals[0].rows, 24);
|
||||
const stale = old.onmessage;
|
||||
b.click('select-admin'); b.click('admin-toggle'); b.click('admin-toggle'); await tick();
|
||||
stale({data: Uint8Array.of(99).buffer}); assert.equal(b.terminals[1].writes.length, 0);
|
||||
b.fire(15000); assert.ok(b.sockets[2].closed); assert.ok(!serial.closed);
|
||||
assert.match(b.nodes['admin-detail'].textContent, /timed out/);
|
||||
for (let i = 0; i < 3; ++i) {
|
||||
b.emit('pagehide'); assert.equal(b.events.resize.length, 0);
|
||||
assert.ok(b.sockets.every(s => s.onmessage === null));
|
||||
b.queues['/api/session'].push(session({role: 'admin'}));
|
||||
b.emit('pageshow', {persisted: true}); await tick(); assert.equal(b.events.resize.length, 1);
|
||||
}
|
||||
b.window.sakSessionExpired(); assert.equal(b.events.resize.length, 0);
|
||||
});
|
||||
await test('changed session identity replaces document before adoption, including live admin and paused restore', async () => {
|
||||
const identities = [
|
||||
{username: 'other-admin', role: 'admin', csrf: 'b'.repeat(64)},
|
||||
{username: 'other-user', role: 'user', csrf: 'b'.repeat(64)},
|
||||
{role: 'admin', csrf: 'b'.repeat(64)}, // Same account, different login session.
|
||||
{username: 'other-admin', role: 'admin'}, // Principal fields are checked independently.
|
||||
{role: 'user'}
|
||||
];
|
||||
for (const identity of identities) for (const route of ['restore', 'paused-restore', 'live', 'logout']) {
|
||||
const b = await adminBrowser(), [serial, admin] = b.sockets;
|
||||
serial.emit('message', {data: Uint8Array.of(65).buffer});
|
||||
admin.emit('message', {data: Uint8Array.of(66).buffer});
|
||||
const stale = admin.onmessage, info = b.nodes['session-info'].textContent;
|
||||
const count = b.calls.length;
|
||||
b.queues['/api/session'].push(session(identity));
|
||||
if (route.includes('restore')) {
|
||||
if (route === 'paused-restore') b.click('connection-toggle');
|
||||
b.emit('pagehide');
|
||||
assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true);
|
||||
b.emit('pageshow', {persisted: true}); b.click('select-admin');
|
||||
assert.equal(b.nodes['admin-terminal'].hidden, true);
|
||||
} else if (route === 'live') {
|
||||
b.click('connection-toggle'); assert.ok(!admin.closed); b.click('connection-toggle');
|
||||
} else await b.click('sign-out');
|
||||
await tick();
|
||||
assert.deepEqual(b.redirects, ['/']); assert.ok(serial.closed && admin.closed);
|
||||
assert.equal(b.nodes['session-info'].textContent, info); // B was never adopted into A's document.
|
||||
assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true);
|
||||
assert.deepEqual(b.calls.slice(count).map(c => c.url), ['/api/session']);
|
||||
assert.equal(b.timers.size, 0); assert.equal(b.events.resize.length, 0);
|
||||
stale({data: Uint8Array.of(67).buffer}); b.terminals[1].input('WRONG');
|
||||
b.click('select-admin'); b.click('admin-toggle'); b.emit('pageshow', {persisted: true});
|
||||
assert.deepEqual(b.terminals[1].writes, [[66]]); assert.equal(admin.sent.length, 0);
|
||||
assert.equal(b.nodes['admin-terminal'].hidden, true); assert.equal(b.sockets.length, 2);
|
||||
}
|
||||
});
|
||||
await test('same-session restore preserves both scrollbacks; pending validation never reveals them', async () => {
|
||||
for (const paused of [false, true]) {
|
||||
const b = await adminBrowser();
|
||||
b.sockets[0].emit('message', {data: Uint8Array.of(65).buffer});
|
||||
b.sockets[1].emit('message', {data: Uint8Array.of(66).buffer});
|
||||
if (paused) b.click('connection-toggle');
|
||||
b.emit('pagehide'); const d = deferred(); b.queues['/api/session'].push(d.promise);
|
||||
b.emit('pageshow', {persisted: true}); await tick(); b.click('select-serial'); b.click('select-admin');
|
||||
assert.equal(b.nodes.terminal.hidden, true); assert.equal(b.nodes['admin-terminal'].hidden, true);
|
||||
d.resolve(session({role: 'admin', expires_in: 3500})); await tick();
|
||||
assert.deepEqual(b.redirects, []); assert.equal(b.terminals.length, 2);
|
||||
assert.deepEqual(b.terminals.map(t => t.writes), [[[65]], [[66]]]);
|
||||
assert.equal(b.nodes['admin-terminal'].hidden, false); assert.equal(b.sockets.length, paused ? 2 : 3);
|
||||
}
|
||||
const b = await adminBrowser(), admin = b.sockets[1];
|
||||
admin.emit('message', {data: Uint8Array.of(66).buffer});
|
||||
b.click('connection-toggle'); b.click('connection-toggle'); await tick();
|
||||
assert.ok(!admin.closed); assert.deepEqual(b.redirects, []); assert.deepEqual(b.terminals[1].writes, [[66]]);
|
||||
});
|
||||
await test('undefined first fit retries unchanged bounds, stops after three retries and fences stale work', async () => {
|
||||
const b = await adminBrowser(), fit = b.fits[1];
|
||||
fit.measurements.push(undefined); b.fire(-1);
|
||||
assert.equal(b.terminals[1].cols, undefined); b.fire(-1);
|
||||
assert.equal(b.terminals[1].cols, 80); assert.equal(fit.calls, 2);
|
||||
b.emit('resize'); b.fire(-1); assert.equal(fit.calls, 2); // Successful measurement is cached.
|
||||
b.click('select-serial'); b.click('select-admin');
|
||||
fit.measurements.push(undefined, undefined, undefined, undefined);
|
||||
for (let i = 0; i < 4; ++i) b.fire(-1);
|
||||
assert.equal(fit.calls, 6); assert.ok(![...b.timers.values()].some(t => t.ms === -1));
|
||||
b.emit('resize'); b.fire(-1); assert.equal(fit.calls, 7); // Failed bounds were never cached.
|
||||
for (const action of ['pagehide', 'expiry', 'logout']) {
|
||||
const c = await adminBrowser(); c.fits[1].measurements.push(undefined); c.fire(-1);
|
||||
const stale = [...c.timers.values()].find(t => t.ms === -1).fn;
|
||||
if (action === 'pagehide') c.emit('pagehide');
|
||||
if (action === 'expiry') c.window.sakSessionExpired();
|
||||
if (action === 'logout') { c.queues['/api/logout'].push(failure(403)); await c.click('sign-out'); }
|
||||
await tick(); stale(); assert.equal(c.fits[1].calls, 1);
|
||||
assert.ok(![...c.timers.values()].some(t => t.ms === -1));
|
||||
if (action === 'pagehide') {
|
||||
c.emit('pageshow', {persisted: true}); await tick(); stale();
|
||||
assert.equal(c.fits[1].calls, 1); c.fire(-1); assert.equal(c.fits[1].calls, 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile the actual C renderer and exercise its emitted app/loader in Node."""
|
||||
import base64
|
||||
import ctypes as C
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
|
||||
|
||||
def run():
|
||||
# Reuse only the HTTPD test double text, not the standalone login renderer.
|
||||
source = (ROOT / 'tests/web_login_ui/run.py').read_text()
|
||||
stub = source.split("STUB = r'''", 1)[1].split("'''", 1)[0].replace('web_login_ui.h', 'web_ui.h')
|
||||
for asset in ('xterm_js_gz', 'xterm_css_gz', 'addon_fit_js_gz', 'logo_png'):
|
||||
stub += f'\nconst unsigned char web_asset_{asset}[] = "stub";\nconst size_t web_asset_{asset}_size = 4;\n'
|
||||
with tempfile.TemporaryDirectory(prefix='web-ui-session-') as directory:
|
||||
tmp = Path(directory)
|
||||
(tmp / 'esp_err.h').write_text('#pragma once\ntypedef int esp_err_t;\n#define ESP_OK 0\n#define ESP_ERR_INVALID_ARG 258\n')
|
||||
(tmp / 'esp_http_server.h').write_text('''#pragma once
|
||||
#include "esp_err.h"
|
||||
#include <sys/types.h>
|
||||
typedef struct { int unused; } httpd_req_t;
|
||||
esp_err_t httpd_resp_set_type(httpd_req_t *, const char *);
|
||||
esp_err_t httpd_resp_set_hdr(httpd_req_t *, const char *, const char *);
|
||||
esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
|
||||
''')
|
||||
(tmp / 'stub.c').write_text(stub)
|
||||
subprocess.run(shlex.split(os.environ.get('CC', 'cc')) + [
|
||||
'-std=c11', '-Wall', '-Wextra', '-Werror', '-shared', '-fPIC',
|
||||
'-I', str(tmp), '-I', str(ROOT / 'src'), str(tmp / 'stub.c'),
|
||||
str(ROOT / 'src/web_ui.c'), '-o', str(tmp / 'renderer.so')], check=True)
|
||||
lib = C.CDLL(str(tmp / 'renderer.so'))
|
||||
lib.web_ui_send_response.argtypes = [C.c_void_p, C.c_int]
|
||||
for name in ('header_key', 'header_value', 'body', 'content_type'):
|
||||
getattr(lib, name).restype = C.c_char_p
|
||||
request = C.c_int()
|
||||
send = lambda resource: lib.web_ui_send_response(C.byref(request), resource)
|
||||
lib.reset(0, 0)
|
||||
assert lib.web_ui_send_response(None, 0) == 258
|
||||
assert send(99) == 258 and lib.call_count() == 0
|
||||
rendered = {}
|
||||
for resource in range(6):
|
||||
lib.reset(0, 0)
|
||||
assert send(resource) == 0
|
||||
count, calls = lib.header_count(), lib.call_count()
|
||||
assert count <= 8
|
||||
headers = {lib.header_key(i).decode(): lib.header_value(i).decode() for i in range(count)}
|
||||
assert headers['Cache-Control'] == ('no-store' if resource in (0, 4) else 'private, max-age=604800')
|
||||
assert headers['X-Content-Type-Options'] == 'nosniff'
|
||||
assert headers['Referrer-Policy'] == 'no-referrer'
|
||||
if resource == 0:
|
||||
rendered.update(html=lib.body().decode(), headers=headers)
|
||||
if resource == 4:
|
||||
rendered['script'] = lib.body().decode()
|
||||
for failure in range(1, calls + 1):
|
||||
lib.reset(failure, 0)
|
||||
assert send(resource) == 73 and lib.send_count() == 0
|
||||
lib.reset(0, 91)
|
||||
assert send(resource) == 91
|
||||
scripts = re.findall(r'<script>(.*?)</script>', rendered['html'], re.S)
|
||||
assert len(scripts) == 1
|
||||
rendered['loader'] = scripts[0]
|
||||
digest = base64.b64encode(hashlib.sha256(scripts[0].encode()).digest()).decode()
|
||||
csp = rendered['headers']['Content-Security-Policy']
|
||||
assert csp.count(f"'sha256-{digest}'") == 2, 'loader CSP hash mismatch'
|
||||
assert "frame-ancestors 'none'" in csp and "connect-src 'self'" in csp
|
||||
assert rendered['html'].index('<script>') < rendered['html'].index('/assets/xterm.js')
|
||||
assert '<a href="/login">' in rendered['html']
|
||||
# Keep the mode selector last/rightmost when admin actions are visible.
|
||||
assert (rendered['html'].index('id="terminal-title"') <
|
||||
rendered['html'].index('id="admin-toggle"') <
|
||||
rendered['html'].index('id="terminal-selector"'))
|
||||
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization'):
|
||||
assert forbidden not in rendered['script'] + rendered['loader'], forbidden
|
||||
(tmp / 'rendered.json').write_text(json.dumps(rendered))
|
||||
subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30)
|
||||
print('PASS C/HTML: all resource headers/failures, no-store app/document, exact loader CSP, safe fallback')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
Reference in New Issue
Block a user