feat: add bounded admin WebSocket backend (Phase 8D.5)

- Require current admin cookie sessions, Origin checks and single-use
  tickets
- Reuse the shared console with session-aware authorization and slot
  allocation
- Add HTTPD-owned I/O, bounded buffering and revocation cleanup
- Prevent LRU eviction of serial clients and stale admin socket closure
- Reject unsupported web-shell mutations before side effects
- Add host regressions, a smoke client and resource accounting

Validated by user sign-off after a 15-minute full-client soak at 230400
baud, with a few broker drops under heavy output. Browser UI remains
for Phase 8D.6; numeric memory reserves remain open.
This commit is contained in:
2026-09-06 14:41:41 +02:00
parent e5dce12ed4
commit aeb2043396
37 changed files with 3651 additions and 91 deletions
+7 -3
View File
@@ -98,9 +98,9 @@ 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. `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.
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 `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.
`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 upgrade requires matching cookie/Origin/ticket. Neither role has web administration yet. `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.
`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/`. `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/`.
@@ -114,6 +114,10 @@ Web serial initialization is failure-isolated from the base HTTPS service: if th
`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. `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
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 ### SSH
`ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers. `ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
@@ -157,7 +161,7 @@ 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 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.
Phase 8D.4 retains this module and exposes `admin_ssh_console_open_owned()`: 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 implements its adapter and compatible admission entry point in `ssh_transport.c`, using generation-checked published snapshots and existing control APIs, never wolfSSH from the control task. `SELF_CLOSE` is owner-relative; legacy SSH actions remain SSH-specific and unsupported owner actions are rejected. No browser admin transport is yet implemented. 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. 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.
+8 -1
View File
@@ -74,6 +74,13 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- 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`. - 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. - Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
### 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 ## SSH
**Responsibility:** authenticate SSH, route users to serial and administrators to the command dispatcher, and own wolfSSH lifecycle. **Responsibility:** authenticate SSH, route users to serial and administrators to the command dispatcher, and own wolfSSH lifecycle.
@@ -110,7 +117,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. - 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. - 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. - 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 boundary: `admin_ssh_console_open_owned()` copies transport-qualified identity and retains an immutable firmware-lifetime drain/lifecycle adapter; the existing two slots are shared. SSH compatibility admission/adapter resides in `ssh_transport.c`; no browser admin caller yet. Owners handle liveness/output; completion scratch is nonblockingly serialized. Focused host command: `python3 tests/admin_console_boundary/run.py`. - 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 ## Wi-Fi
+8 -2
View File
@@ -4,6 +4,12 @@ This file is working memory. Update it during active work and before handoff; do
## Development state ## Development state
- **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 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 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.
@@ -60,8 +66,8 @@ Based on checked-in source plus `README.md` and `docs/roadmap.md`:
## Clearly incomplete or transitional areas ## 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. - 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. - Current shipped HTTPS UI gives both roles the same status/serial terminal. The 8D.5 admin backend is implemented without a UI entry; its temporary policy denies all user mutations and unsupported self-affecting lifecycle commands.
- Browser authentication now uses cookie login/logout without Basic fallback. M1 target checkpoint is signed off; browser administrative routes remain future separately requested M2 work. - Browser authentication uses cookie login/logout without Basic fallback. M1 target checkpoint is signed off; 8D.5 is implemented, host-tested and build-verified, with target gates recorded above. Browser selector/lifecycle parity remain future separately requested 8D.6/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. - 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 ## Known inconsistencies
+3 -1
View File
@@ -82,12 +82,14 @@ Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs
**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. **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. 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` **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 ## 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. **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. **Rationale/evidence:** `user_database` issues principals without secrets; web/SSH check currentness during admission and active sessions. Mutating console paths call transport revocation hooks.
+146
View File
@@ -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.
+9 -3
View File
@@ -1,6 +1,6 @@
# Phase 8D — Incremental web administration plan # Phase 8D — Incremental web administration plan
Status: **8D.08D.2 validated by user sign-off on 2026-09-05; 8D.3/M1 validated by explicit user sign-off on 2026-09-06 after post-soak evidence. Live login/logout is implemented, host-tested and build-verified. 8D.4 validated by explicit user sign-off on 2026-09-06, confirming successful empty Enter and soak testing. Numeric reserve gates remain open. 8D.58D.22 remain planned, each requiring a separate implementation request.** See the [8D.4 implementation record](phase8d4_implementation.md), [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md). Status: **8D.08D.5 and M1 validated by explicit user sign-off. 8D.5 closed on 2026-09-06 after settled cold-boot evidence and a reported successful 15-minute full-client-mix soak at 230400 baud, with a few broker drops under heavy output. Numeric reserve gates remain open. 8D.68D.22 remain planned, each requiring a separate implementation request.** See the [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. 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.
@@ -112,6 +112,12 @@ If 8D.3 exceeds the work-unit limit, first split out inert login-page rendering
### 8D.5 — Bounded admin WebSocket backend, no normal UI entry yet ### 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. **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. **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.
@@ -169,11 +175,11 @@ Update the roadmap and user/command documentation to distinguish completed featu
## Progress and next-request template ## Progress and next-request template
Progress: **8D.08D.4 and M1 validated by user sign-off; numeric reserves remain open. 8D.58D.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. Progress: **8D.08D.5 and M1 validated by user sign-off; numeric reserves remain open. 8D.68D.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: Suggested next request:
> M1 and 8D.4 are validated by explicit user sign-off. Work on Phase 8D.5 only: verify the recorded prerequisites, then implement the bounded admin WebSocket backend without a normal UI entry. Preserve the single dispatcher, existing serial ownership, signed-off evidence and open numeric reserve gates. > M1 and 8D.5 are validated by explicit user sign-off. Work on Phase 8D.6 only: implement the browser terminal selector while preserving the serial connection, broker identity and writer lease when hidden. Preserve signed-off evidence and open numeric reserve gates; do not implement 8D.7 lifecycle parity or settings.
For later chunks: For later chunks:
+1 -1
View File
@@ -199,7 +199,7 @@ Implementation sequence:
- Keep SFTP, SCP, `exec`, forwarding, subsystems, and unauthenticated shells disabled. - 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. - 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, staged delivery** 4. **Phase 8D — Integrated web administration — Planned, staged delivery**
- **Implementation checkpoint:** 8D.08D.4 and M1 validated by user sign-off; M1 explicitly closed on 2026-09-06 after post-soak evidence, and [8D.4 console boundary](phase8d4_implementation.md) closed the same day with successful empty Enter and soak testing. [8D.3 live login/logout](phase8d3_implementation.md) is implemented, host-tested and build-verified with cookie sessions and no Basic fallback. Numeric reserves remain open. Browser admin shell/settings remain planned; begin 8D.5 only on a separate implementation request. - **Implementation checkpoint:** 8D.08D.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. - **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.08D.3 establish a measured baseline and reliable login/logout with the existing serial UI; 8D.48D.7 add the shared browser admin shell and verify retained serial ownership; 8D.88D.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. - **Milestones:** 8D.08D.3 establish a measured baseline and reliable login/logout with the existing serial UI; 8D.48D.7 add the shared browser admin shell and verify retained serial ownership; 8D.88D.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. - 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.
+2
View File
@@ -31,6 +31,8 @@ idf_component_register(
"user_console.c" "user_console.c"
"web_security.c" "web_security.c"
"web_serial_transport.c" "web_serial_transport.c"
"web_admin_tickets.c"
"web_admin_transport.c"
"web_assets_data.c" "web_assets_data.c"
"web_ui.c" "web_ui.c"
"web_server.c" "web_server.c"
+139 -39
View File
@@ -148,6 +148,31 @@ static bool token_matches(const admin_session_t *session,
return session->active && token_identity_matches(session, token); 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, static bool append_output_locked(admin_session_t *session,
const uint8_t *data, size_t length) const uint8_t *data, size_t length)
{ {
@@ -308,6 +333,9 @@ esp_err_t admin_ssh_console_dispatch_read_input(
*output_length = 0U; *output_length = 0U;
memset(output, 0, capacity); memset(output, 0, capacity);
(void)xSemaphoreTake(s_prompt_done, 0U); (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); taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[s_dispatch_token.slot_index]; admin_session_t *session = &s_sessions[s_dispatch_token.slot_index];
@@ -330,27 +358,36 @@ esp_err_t admin_ssh_console_dispatch_read_input(
if (!published) { if (!published) {
return ESP_ERR_NO_MEM; return ESP_ERR_NO_MEM;
} }
if (xSemaphoreTake(s_prompt_done, portMAX_DELAY) != pdTRUE) { for (;;) {
return ESP_FAIL; /* 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( esp_err_t admin_ssh_console_dispatch_defer(
@@ -454,6 +491,31 @@ static bool remote_command_allowed(const admin_request_t *request)
(strcmp(argv[1], "bootstrap") == 0 || strcmp(argv[1], "recover") == 0)) { (strcmp(argv[1], "bootstrap") == 0 || strcmp(argv[1], "recover") == 0)) {
allowed = false; 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)); secure_wipe(copy, sizeof(copy));
return allowed; return allowed;
} }
@@ -518,8 +580,11 @@ static void dispatch_registered_command(admin_request_t *request)
} }
int command_result = 0; int command_result = 0;
esp_err_t error = esp_console_run((const char *)request->line, &command_result); if (request->origin == ADMIN_REQUEST_UART0 ||
report_command_result(error, command_result); 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); fflush(stdout);
s_dispatch_remote = false; s_dispatch_remote = false;
@@ -550,35 +615,33 @@ static void worker_task(void *context)
continue; continue;
} }
bool current = false; bool current = session_is_current(&request.token, &request.principal);
esp_err_t auth_error = user_database_principal_is_current(&request.principal, &current);
bool active; bool active;
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[request.token.slot_index]; 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; !session->executing;
if (active) { if (active) {
session->executing = true; session->executing = true;
} }
taskEXIT_CRITICAL(&s_lock); taskEXIT_CRITICAL(&s_lock);
bool authorized = active && auth_error == ESP_OK && current && bool authorized = active && remote_command_allowed(&request);
request.principal.role == USER_ROLE_ADMIN &&
remote_command_allowed(&request);
if (authorized) { if (authorized) {
dispatch_registered_command(&request); dispatch_registered_command(&request);
} else if (active) { } else if (active) {
(void)worker_write(&request.token, (void)worker_write(&request.token,
auth_error == ESP_OK && current request.token.transport == ADMIN_CONSOLE_TRANSPORT_WEB
? "Command is restricted to physical UART0.\r\n" ? "Command is unavailable from the web console; use UART0 or SSH where permitted. Bootstrap/recovery require UART0.\r\n"
: "Administrative authorization is no longer current; closing session.\r\n"); : "Command is restricted to physical UART0.\r\n");
} }
current = session_is_current(&request.token, &request.principal);
bool prompt = false; bool prompt = false;
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
session = &s_sessions[request.token.slot_index]; session = &s_sessions[request.token.slot_index];
if (token_matches(session, &request.token)) { if (token_matches(session, &request.token)) {
session->executing = false; session->executing = false;
session->command_pending = false; session->command_pending = false;
prompt = auth_error == ESP_OK && current && prompt = current &&
request.principal.role == USER_ROLE_ADMIN && request.principal.role == USER_ROLE_ADMIN &&
!session->deferred_action_pending; !session->deferred_action_pending;
} else if (!session->active && session->executing && } else if (!session->active && session->executing &&
@@ -770,12 +833,14 @@ esp_err_t admin_ssh_console_start_uart_frontend(void)
return ESP_OK; return ESP_OK;
} }
esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token, static esp_err_t open_session(admin_ssh_console_token_t *token,
const user_principal_t *principal, const user_principal_t *principal,
const admin_console_owner_t *owner) 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 ||
owner == NULL || owner->drained == NULL || owner->perform == NULL) { (!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; return ESP_ERR_INVALID_ARG;
} }
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
@@ -789,7 +854,19 @@ esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
} }
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index]; 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) { if (session->active || session->executing) {
taskEXIT_CRITICAL(&s_lock); taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE; return ESP_ERR_INVALID_STATE;
@@ -797,6 +874,7 @@ esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
secure_wipe(session, sizeof(*session)); secure_wipe(session, sizeof(*session));
session->active = true; session->active = true;
session->history_position = -1; session->history_position = -1;
token->slot_index = (uint8_t)index;
session->token = *token; session->token = *token;
session->owner = owner; session->owner = owner;
session->principal = *principal; session->principal = *principal;
@@ -811,6 +889,24 @@ esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
return ESP_OK; 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(&copy, principal, owner, false);
}
void admin_ssh_console_close(const admin_ssh_console_token_t *token) void admin_ssh_console_close(const admin_ssh_console_token_t *token)
{ {
if (!token_valid(token)) { if (!token_valid(token)) {
@@ -821,8 +917,10 @@ void admin_ssh_console_close(const admin_ssh_console_token_t *token)
bool matched = token_matches(session, token); bool matched = token_matches(session, token);
bool wake_prompt = false; bool wake_prompt = false;
if (matched) { if (matched) {
if (session->prompt_state == ADMIN_PROMPT_WAITING) { if (session->prompt_state != ADMIN_PROMPT_NONE) {
session->prompt_state = ADMIN_PROMPT_DISCONNECTED; session->prompt_state = ADMIN_PROMPT_DISCONNECTED;
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
session->prompt_length = 0U;
wake_prompt = true; wake_prompt = true;
} }
session->active = false; session->active = false;
@@ -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; first = ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_start;
} }
memcpy(data, session->output + session->output_start, first); memcpy(data, session->output + session->output_start, first);
secure_wipe(session->output + session->output_start, first);
if (copied > first) { if (copied > first) {
memcpy(data + first, session->output, copied - first); memcpy(data + first, session->output, copied - first);
secure_wipe(session->output, copied - first);
} }
session->output_start = (session->output_start + copied) % session->output_start = (session->output_start + copied) %
ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY; ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY;
+23 -3
View File
@@ -17,6 +17,9 @@ extern "C" {
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */ /* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U #define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
#define ADMIN_CONSOLE_TRANSPORT_SSH 0U
#define ADMIN_CONSOLE_TRANSPORT_WEB 1U
typedef struct { typedef struct {
uint8_t slot_index; uint8_t slot_index;
uint32_t session_id; uint32_t session_id;
@@ -36,12 +39,15 @@ typedef enum {
/* Small owner boundary; module/API names are retained for existing SSH callers. /* Small owner boundary; module/API names are retained for existing SSH callers.
* Exactly two shared console slots, not two per transport. slot_index addresses * Exactly two shared console slots, not two per transport. slot_index addresses
* this pool; owners coordinate admission and must not reuse an identity while * 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). * old work can exist. transport is a firmware-assigned namespace (0 = SSH).
* An occupied or still-executing slot cannot be replaced by open_owned(). * An occupied or still-executing slot cannot be replaced by open_owned().
* *
* The immutable adapter lives for firmware lifetime. Callbacks run on the * The immutable adapter lives for firmware lifetime. Callbacks run on the
* control task OUTSIDE console locks, never on the dispatcher or socket owner. * 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 * drained must be nonblocking, validate the full identity and include pending
* owner output. perform must revalidate identity and marshal lifecycle work to * owner output. perform must revalidate identity and marshal lifecycle work to
* its owner, never call socket libraries here. Neither callback may call console * its owner, never call socket libraries here. Neither callback may call console
@@ -53,7 +59,10 @@ typedef enum {
* parallel. Shared completion scratch is nonblocking/serialized by the core. * parallel. Shared completion scratch is nonblocking/serialized by the core.
* The owner alone consumes output, maintains authentication/session liveness, * The owner alone consumes output, maintains authentication/session liveness,
* and calls close on disconnect/revocation. Core copies/rechecks principals at * and calls close on disconnect/revocation. Core copies/rechecks principals at
* admission and dispatch, but does not implement transport-specific expiry. * 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. * Close wakes prompts; executing state is retained until the handler returns.
* Output remains bounded (5s write backpressure); deferred work waits at most * Output remains bounded (5s write backpressure); deferred work waits at most
* 10s for application drain plus 200ms, NOT peer-delivery confirmation. * 10s for application drain plus 200ms, NOT peer-delivery confirmation.
@@ -61,11 +70,22 @@ typedef enum {
*/ */
typedef struct { typedef struct {
uint32_t supported_actions; 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); bool (*drained)(const admin_ssh_console_token_t *token);
esp_err_t (*perform)(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_ssh_deferred_action_type_t action, uint32_t argument);
} admin_console_owner_t; } 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, esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
const user_principal_t *principal, const user_principal_t *principal,
const admin_console_owner_t *owner); const admin_console_owner_t *owner);
+86 -18
View File
@@ -66,6 +66,7 @@ typedef struct {
bool pending_principal_valid; bool pending_principal_valid;
bool authenticated; bool authenticated;
bool shell_requested; bool shell_requested;
uint8_t console_slot_index;
uint8_t authentication_attempts; uint8_t authentication_attempts;
word32 io_read_budget; word32 io_read_budget;
bool writer; bool writer;
@@ -87,6 +88,9 @@ static ssh_slot_t s_slots[SSH_TRANSPORT_MAX_SESSIONS];
static ssh_transport_session_snapshot_t static ssh_transport_session_snapshot_t
s_session_snapshots[SSH_TRANSPORT_MAX_SESSIONS]; s_session_snapshots[SSH_TRANSPORT_MAX_SESSIONS];
static uint32_t s_external_close_id[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 ssh_transport_counters_t s_counters;
static SemaphoreHandle_t s_command_mutex; static SemaphoreHandle_t s_command_mutex;
static bool s_initializing; static bool s_initializing;
@@ -132,8 +136,9 @@ static void notify_task(void)
static admin_ssh_console_token_t admin_console_token(const ssh_slot_t *slot, static admin_ssh_console_token_t admin_console_token(const ssh_slot_t *slot,
size_t slot_index) size_t slot_index)
{ {
(void)slot_index; /* Physical SSH index is not the shared console index. */
return (admin_ssh_console_token_t){ return (admin_ssh_console_token_t){
.slot_index = (uint8_t)slot_index, .slot_index = slot->console_slot_index,
.session_id = slot->session_id, .session_id = slot->session_id,
.slot_generation = slot->generation, .slot_generation = slot->generation,
}; };
@@ -177,19 +182,74 @@ static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
s_session_snapshots[slot_index] = snapshot; 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); taskEXIT_CRITICAL(&s_lock);
} }
/* Control-task adapter: copied snapshots only, no runtime wolfSSH calls. */ /* Caller holds s_lock. Match session identity first, then the assigned console
static bool admin_console_drained(const admin_ssh_console_token_t *token) * 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->transport != 0U || token->slot_index >= SSH_TRANSPORT_MAX_SESSIONS) { 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; return false;
} }
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
const ssh_transport_session_snapshot_t *slot = &s_session_snapshots[token->slot_index]; size_t index = admin_console_snapshot_index_locked(token);
bool drained = slot->active && slot->session_id == token->session_id && if (index == SSH_TRANSPORT_MAX_SESSIONS) {
slot->generation == token->slot_generation && !slot->tx_pending; 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); taskEXIT_CRITICAL(&s_lock);
return drained; return drained;
} }
@@ -220,21 +280,23 @@ static esp_err_t admin_console_perform(const admin_ssh_console_token_t *token,
} }
} }
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, esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal) const user_principal_t *principal)
{ {
static const admin_console_owner_t owner = { if (token == NULL || token->transport != ADMIN_CONSOLE_TRANSPORT_SSH) {
.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,
.perform = admin_console_perform,
};
if (token == NULL || token->transport != 0U) {
return ESP_ERR_INVALID_ARG; return ESP_ERR_INVALID_ARG;
} }
return admin_ssh_console_open_owned(token, principal, &owner); 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) static bool consume_external_close(const ssh_slot_t *slot, size_t slot_index)
@@ -242,6 +304,10 @@ static bool consume_external_close(const ssh_slot_t *slot, size_t slot_index)
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
bool requested = s_external_close_id[slot_index] != 0U && bool requested = s_external_close_id[slot_index] != 0U &&
s_external_close_id[slot_index] == slot->session_id; 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) { if (requested || slot->state == SSH_TRANSPORT_SESSION_FREE) {
s_external_close_id[slot_index] = 0U; s_external_close_id[slot_index] = 0U;
} }
@@ -982,12 +1048,14 @@ static void process_handshake(ssh_slot_t *slot, size_t slot_index)
slot->route = SSH_TRANSPORT_ROUTE_BROKER; slot->route = SSH_TRANSPORT_ROUTE_BROKER;
} else if (slot->principal.role == USER_ROLE_ADMIN) { } else if (slot->principal.role == USER_ROLE_ADMIN) {
admin_ssh_console_token_t token = admin_console_token(slot, slot_index); 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) { if (error != ESP_OK) {
add_counter(&s_counters.admin_console_admission_failures, 1U); add_counter(&s_counters.admin_console_admission_failures, 1U);
request_slot_close(slot, false); request_slot_close(slot, false);
return; return;
} }
slot->console_slot_index = token.slot_index;
slot->route = SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE; slot->route = SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
add_counter(&s_counters.admin_console_admissions, 1U); add_counter(&s_counters.admin_console_admissions, 1U);
} else { } else {
+300
View File
@@ -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, &copy.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(&copy, 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);
}
+44
View File
@@ -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);
+549
View File
@@ -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, &current) != 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, &current);
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);
}
+42
View File
@@ -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);
+29
View File
@@ -15,6 +15,8 @@
#include "web_serial_transport.h" #include "web_serial_transport.h"
#include "web_server.h" #include "web_server.h"
#include "web_cookie_auth.h" #include "web_cookie_auth.h"
#include "web_admin_transport.h"
#include "web_admin_tickets.h"
static void print_usage(void) static void print_usage(void)
{ {
@@ -35,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) static int show_status(void)
{ {
web_server_snapshot_t snapshot; web_server_snapshot_t snapshot;
@@ -61,6 +87,8 @@ static int show_status(void)
} }
printf("Endpoints: GET /, GET /api/status, POST /api/ws-ticket, WSS /ws/serial\n"); 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("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_snapshot_t auth;
web_cookie_auth_get_snapshot(&auth); web_cookie_auth_get_snapshot(&auth);
web_session_store_snapshot_t sessions; web_session_store_snapshot_t sessions;
@@ -122,6 +150,7 @@ static int show_counters(void)
return 1; return 1;
} }
show_admin_transport();
const web_server_counters_t *counter = &snapshot.counters; const web_server_counters_t *counter = &snapshot.counters;
printf("Lifecycle: starts=%" PRIu64 " start-failures=%" PRIu64 printf("Lifecycle: starts=%" PRIu64 " start-failures=%" PRIu64
" stops=%" PRIu64 "\n", " stops=%" PRIu64 "\n",
+4
View File
@@ -16,6 +16,7 @@
#include "serial_service.h" #include "serial_service.h"
#include "web_auth_parse.h" #include "web_auth_parse.h"
#include "web_httpd_adapter.h" #include "web_httpd_adapter.h"
#include "web_admin_transport.h"
#if !defined(CONFIG_HTTPD_WS_SUPPORT) || !CONFIG_HTTPD_WS_SUPPORT #if !defined(CONFIG_HTTPD_WS_SUPPORT) || !CONFIG_HTTPD_WS_SUPPORT
#error "web_serial_transport requires CONFIG_HTTPD_WS_SUPPORT" #error "web_serial_transport requires CONFIG_HTTPD_WS_SUPPORT"
@@ -1843,6 +1844,7 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
} }
web_session_store_invalidate_username(username, username_length); web_session_store_invalidate_username(username, username_length);
web_admin_transport_revoke(0, username, username_length);
bool notify = false; bool notify = false;
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
if (s_ticket_epoch != UINT64_MAX) { if (s_ticket_epoch != UINT64_MAX) {
@@ -1879,6 +1881,7 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
esp_err_t web_serial_transport_revoke_sessions(void) esp_err_t web_serial_transport_revoke_sessions(void)
{ {
web_session_store_invalidate_username(NULL, 0U); web_session_store_invalidate_username(NULL, 0U);
web_admin_transport_revoke(0, NULL, 0);
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
if (s_ticket_epoch != UINT64_MAX) { if (s_ticket_epoch != UINT64_MAX) {
++s_ticket_epoch; ++s_ticket_epoch;
@@ -1907,6 +1910,7 @@ esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id)
return ESP_ERR_INVALID_ARG; return ESP_ERR_INVALID_ARG;
} }
web_session_store_invalidate(id); web_session_store_invalidate(id);
web_admin_transport_revoke(id, NULL, 0);
taskENTER_CRITICAL(&s_lock); taskENTER_CRITICAL(&s_lock);
if (s_ticket_epoch != UINT64_MAX) { if (s_ticket_epoch != UINT64_MAX) {
++s_ticket_epoch; ++s_ticket_epoch;
+48 -3
View File
@@ -23,6 +23,7 @@
#include "user_database.h" #include "user_database.h"
#include "web_security.h" #include "web_security.h"
#include "web_serial_transport.h" #include "web_serial_transport.h"
#include "web_admin_transport.h"
#include "web_session_store.h" #include "web_session_store.h"
#include "web_cookie_auth.h" #include "web_cookie_auth.h"
#include "web_httpd_adapter.h" #include "web_httpd_adapter.h"
@@ -39,6 +40,8 @@ static bool s_transitioning;
static bool s_serial_transport_init_attempted; static bool s_serial_transport_init_attempted;
static bool s_serial_transport_initialized; static bool s_serial_transport_initialized;
static bool s_serial_transport_attached; 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_last_error = ESP_ERR_INVALID_STATE;
static esp_err_t s_serial_transport_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 web_server_counters_t s_counters;
@@ -368,6 +371,19 @@ static const httpd_uri_t s_websocket_uri = {
.handle_ws_control_frames = 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 = { static const httpd_uri_t s_xterm_js_uri = {
.uri = "/assets/xterm.js", .uri = "/assets/xterm.js",
.method = HTTP_GET, .method = HTTP_GET,
@@ -497,12 +513,13 @@ esp_err_t web_server_start(void)
private_key, sizeof(private_key), &private_key_length); private_key, sizeof(private_key), &private_key_length);
if (error == ESP_OK) { if (error == ESP_OK) {
httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT(); 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_open_sockets = 6;
config.httpd.max_uri_handlers = config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) + sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]); sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 2U;
config.httpd.lru_purge_enable = true; /* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1; config.httpd.recv_wait_timeout = 1;
config.httpd.send_wait_timeout = 1; config.httpd.send_wait_timeout = 1;
config.servercert = certificate; config.servercert = certificate;
@@ -535,6 +552,18 @@ esp_err_t web_server_start(void)
attach_error = web_serial_transport_attach_server(server); attach_error = web_serial_transport_attach_server(server);
serial_transport_attached = attach_error == ESP_OK; 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) { if (error != ESP_OK) {
web_cookie_auth_stop(); web_cookie_auth_stop();
} }
@@ -553,6 +582,7 @@ esp_err_t web_server_start(void)
s_last_error = error; s_last_error = error;
s_serial_transport_error = attach_error; s_serial_transport_error = attach_error;
s_serial_transport_attached = serial_transport_attached; s_serial_transport_attached = serial_transport_attached;
s_admin_transport_owned = admin_transport_owned;
if (error == ESP_OK) { if (error == ESP_OK) {
s_server = server; s_server = server;
++s_counters.starts; ++s_counters.starts;
@@ -578,11 +608,24 @@ esp_err_t web_server_stop(void)
} }
httpd_handle_t server = s_server; httpd_handle_t server = s_server;
bool serial_transport_attached = s_serial_transport_attached; 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; esp_err_t serial_transport_error = s_serial_transport_error;
s_transitioning = true; s_transitioning = true;
xSemaphoreGive(s_server_mutex); xSemaphoreGive(s_server_mutex);
web_cookie_auth_stop(); 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) { if (serial_transport_attached) {
esp_err_t detach_error = web_serial_transport_detach_server(server); esp_err_t detach_error = web_serial_transport_detach_server(server);
if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) { if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) {
@@ -597,6 +640,7 @@ esp_err_t web_server_stop(void)
} }
esp_err_t error = httpd_ssl_stop(server); 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) { if (error != ESP_OK && serial_transport_attached) {
/* Stay detached: old HTTPD work may still be reading static TX storage. */ /* Stay detached: old HTTPD work may still be reading static TX storage. */
serial_transport_error = ESP_ERR_INVALID_STATE; serial_transport_error = ESP_ERR_INVALID_STATE;
@@ -609,6 +653,7 @@ esp_err_t web_server_stop(void)
s_serial_transport_attached = false; s_serial_transport_attached = false;
if (error == ESP_OK) { if (error == ESP_OK) {
s_server = NULL; s_server = NULL;
s_admin_transport_owned = false;
++s_counters.stops; ++s_counters.stops;
} }
xSemaphoreGive(s_server_mutex); xSemaphoreGive(s_server_mutex);
+84 -4
View File
@@ -1,29 +1,109 @@
#define SSH_TRANSPORT_MAX_SESSIONS 2U #define SSH_TRANSPORT_MAX_SESSIONS 2U
typedef struct { bool active, tx_pending; uint32_t session_id, generation; } ssh_transport_session_snapshot_t; 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 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 unsigned stopped, disconnected, rotated, reset, restarted;
static esp_err_t ssh_transport_stop(void) { ++stopped; return ESP_OK; } 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_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 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 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_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 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) static void test_adapter(void)
{ {
admin_ssh_console_token_t token={ .slot_index=0, .session_id=7, .slot_generation=3 }; admin_ssh_console_token_t token={ .slot_index=0, .session_id=7, .slot_generation=3 };
user_principal_t admin={ .role=USER_ROLE_ADMIN }; 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_init()==ESP_OK);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK); assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
assert(admin_ssh_console_open(&token,&admin)==ESP_OK); assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
assert(!admin_console_drained(&token)); assert(!admin_console_drained(&token));
s_session_snapshots[0]=(ssh_transport_session_snapshot_t){ .active=true, .session_id=7, .generation=3 }; 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)); 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)); 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); assert(admin_ssh_console_open(&token,&admin)==ESP_ERR_INVALID_ARG);
token.transport=0; token.slot_generation=4; assert(!admin_console_drained(&token)); 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); assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND && stopped==0);
token.slot_generation=3; s_session_snapshots[0].tx_pending=true; 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_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_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_DISCONNECT,99)==ESP_OK && disconnected==99);
@@ -31,5 +111,5 @@ static void test_adapter(void)
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_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_HOST_KEY_RESET,0)==ESP_OK && reset==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_REBOOT,0)==ESP_OK && restarted==1); assert(admin_console_perform(&token,ADMIN_SSH_DEFER_REBOOT,0)==ESP_OK && restarted==1);
puts("PASS: actual SSH adapter identity/drain checks, legacy admission and lifecycle action routing"); puts("PASS: actual SSH snapshot/principal publication and wiping, adapter identity/drain checks, legacy admission and lifecycle action routing");
} }
+11 -5
View File
@@ -10,7 +10,13 @@ typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE, 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 }; ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND };
enum { USER_ROLE_USER, USER_ROLE_ADMIN }; enum { USER_ROLE_USER, USER_ROLE_ADMIN };
typedef struct { int role; } user_principal_t; #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 unsigned TickType_t;
typedef void *TaskHandle_t; typedef void *TaskHandle_t;
typedef int portMUX_TYPE; typedef int portMUX_TYPE;
@@ -38,7 +44,7 @@ static size_t strlcpy(char *d, const char *s, size_t n) {
memcpy(d, s, k); d[k] = 0; } return len; 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) static esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *c)
{ (void)p; *c = principal_current; return ESP_OK; } { (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 const char *esp_err_to_name(int e) { (void)e; return "fake"; }
static TaskHandle_t xTaskGetCurrentTaskHandle(void) { return current_task; } static TaskHandle_t xTaskGetCurrentTaskHandle(void) { return current_task; }
static unsigned xTaskGetTickCount(void) { return ticks; } static unsigned xTaskGetTickCount(void) { return ticks; }
@@ -57,7 +63,8 @@ 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; } { (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 SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *s) { return s; }
static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t) static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t)
{ if (t && prompt_hook) prompt_hook(); int r=*s; *s=0; return r; } { 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 int xSemaphoreGive(SemaphoreHandle_t s) { *s=1; return 1; }
static void linenoiseSetMaxLineLen(unsigned n) { (void)n; } static void linenoiseSetMaxLineLen(unsigned n) { (void)n; }
static char *linenoise(const char *p) { (void)p; return NULL; } static char *linenoise(const char *p) { (void)p; return NULL; }
@@ -67,8 +74,7 @@ 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; } { (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) 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; } { (void)s; *len=strlcpy(d,"help\r\n",n); return true; }
static size_t esp_console_split_argv(char *s, char **v, size_t n) size_t esp_console_split_argv(char *s, char **v, size_t n);
{ (void)s; (void)v; (void)n; return 0; } /* Real parser tested by admin_ssh_policy. */
static esp_err_t esp_console_run(const char *s, int *r) 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; } { (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; typedef struct { const char *command, *help, *hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
+7 -4
View File
@@ -4,10 +4,13 @@
No target scheduler, socket library, or hardware execution is claimed. No target scheduler, socket library, or hardware execution is claimed.
""" """
from pathlib import Path from pathlib import Path
import os
import subprocess import subprocess
import tempfile import tempfile
ROOT = Path(__file__).resolve().parents[2] 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() source = (ROOT / "src/admin_ssh_console.c").read_text()
header = (ROOT / "src/admin_ssh_console.h").read_text() header = (ROOT / "src/admin_ssh_console.h").read_text()
def strip_includes(text): def strip_includes(text):
@@ -21,18 +24,18 @@ with tempfile.TemporaryDirectory(prefix="admin-console-boundary-") as directory:
+ (ROOT / "tests/admin_console_boundary/test.c").read_text()) + (ROOT / "tests/admin_console_boundary/test.c").read_text())
(path / "test.c").write_text(unit) (path / "test.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-g", str(path / "test.c"), "-g", str(path / "test.c"), parser,
"-o", str(path / "test")], check=True, timeout=30) "-o", str(path / "test")], check=True, timeout=30)
subprocess.run([str(path / "test")], check=True, timeout=10) subprocess.run([str(path / "test")], check=True, timeout=10)
ssh = (ROOT / "src/ssh_transport.c").read_text() ssh = (ROOT / "src/ssh_transport.c").read_text()
adapter = ssh[ssh.index("static bool admin_console_drained("): adapter = ssh[ssh.index("static admin_ssh_console_token_t admin_console_token("):
ssh.index("static bool consume_external_close(")] ssh.index("static void *ssh_malloc(")]
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text() unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
+ strip_includes(header) + "\n" + strip_includes(source) + strip_includes(header) + "\n" + strip_includes(source)
+ (ROOT / "tests/admin_console_boundary/adapter.c").read_text() + (ROOT / "tests/admin_console_boundary/adapter.c").read_text()
+ adapter + "\nint main(void) { test_adapter(); }\n") + adapter + "\nint main(void) { test_adapter(); }\n")
(path / "adapter.c").write_text(unit) (path / "adapter.c").write_text(unit)
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-variable", str(path / "adapter.c"), "-Wno-unused-variable", str(path / "adapter.c"), parser,
"-o", str(path / "adapter")], check=True, timeout=30) "-o", str(path / "adapter")], check=True, timeout=30)
subprocess.run([str(path / "adapter")], check=True, timeout=10) subprocess.run([str(path / "adapter")], check=True, timeout=10)
+141 -1
View File
@@ -2,6 +2,14 @@
static admin_ssh_console_token_t a = { .slot_index=0, .session_id=7, .slot_generation=1 }; 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 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 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) static bool drained(const admin_ssh_console_token_t *t)
{ assert(!lock_depth); assert(t->session_id==7); return owner_drained; } { assert(!lock_depth); assert(t->session_id==7); return owner_drained; }
static esp_err_t perform(const admin_ssh_console_token_t *t, static esp_err_t perform(const admin_ssh_console_token_t *t,
@@ -9,6 +17,7 @@ static esp_err_t perform(const admin_ssh_console_token_t *t,
{ (void)t; (void)arg; assert(!lock_depth); assert(action==ADMIN_CONSOLE_DEFER_SELF_CLOSE); ++actions; return ESP_OK; } { (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 = { static const admin_console_owner_t owner = {
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE, .drained=drained, .perform=perform, .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 pump(void (*task)(void *)) { if (!setjmp(loop_done)) task(NULL); }
static void feed(const admin_ssh_console_token_t *t, const char *s) static void feed(const admin_ssh_console_token_t *t, const char *s)
@@ -40,12 +49,141 @@ static void close_during_command(void)
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE); assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
} }
static void setup_dispatch(void) static void setup_dispatch(void)
{ s_dispatch_remote=true; s_dispatch_token=a; s_sessions[0].executing=true; s_sessions[0].command_pending=true; } { 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) int main(void)
{ {
assert(admin_ssh_console_init()==ESP_OK); assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE); assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK); assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
test_shared_admission();
principal_current=false; principal_current=false;
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE); assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
principal_current=true; principal_current=true;
@@ -74,6 +212,7 @@ int main(void)
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK); ++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
pump(worker_task); assert(runs==1); pump(worker_task); assert(runs==1);
feed(&a,"revoked\r"); principal_current=false; pump(worker_task); assert(runs==1); principal_current=true; 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 }; admin_request_t uart={ .origin=ADMIN_REQUEST_UART0 };
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==2); 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; feed(&a,"close\r"); command_hook=close_during_command; pump(worker_task); command_hook=NULL;
@@ -128,5 +267,6 @@ int main(void)
admin_ssh_console_close(&a); admin_ssh_console_close(&a);
assert(ssh_output_write(&a,"x",1)==-1 && errno==EPIPE); assert(ssh_output_write(&a,"x",1)==-1 && errno==EPIPE);
assert(!lock_depth); 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"); 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");
} }
+47 -2
View File
@@ -23,7 +23,11 @@ prelude = r'''
#include <stdio.h> #include <stdio.h>
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U #define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
#define ADMIN_SSH_CONSOLE_MAX_ARGUMENTS 10U #define ADMIN_SSH_CONSOLE_MAX_ARGUMENTS 10U
typedef struct { char line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; } admin_request_t; #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); size_t esp_console_split_argv(char *, char **, size_t);
static void secure_wipe(void *p, size_t n) { static void secure_wipe(void *p, size_t n) {
volatile unsigned char *bytes = p; volatile unsigned char *bytes = p;
@@ -48,7 +52,48 @@ int main(void) {
assert(remote_command_allowed(&request) == cases[i].allowed); assert(remote_command_allowed(&request) == cases[i].allowed);
assert(!strcmp(request.line, cases[i].line)); assert(!strcmp(request.line, cases[i].line));
} }
puts("PASS: empty input/ordinary commands allowed; physical-only commands (including quoted forms) remain denied"); 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: with tempfile.TemporaryDirectory(prefix="admin-ssh-policy-") as directory:
+87
View File
@@ -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.
+26
View File
@@ -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)
+285
View File
@@ -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);
}
+193
View File
@@ -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.
+308
View File
@@ -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())
+136
View File
@@ -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; }
+52
View File
@@ -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;
}
+39
View File
@@ -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(&registered[i], &registered[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.')
+234
View File
@@ -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;
}
+139
View File
@@ -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");
}
+21
View File
@@ -41,6 +41,24 @@ struct httpd_data { struct { unsigned max_resp_headers; } config; };
esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *); 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): def function(source, name):
start = source.index(name + "(") start = source.index(name + "(")
start = source.rfind("\n", 0, start) + 1 start = source.rfind("\n", 0, start) + 1
@@ -79,7 +97,10 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
(tmp / "installed_httpd.c").write_text(extracted) (tmp / "installed_httpd.c").write_text(extracted)
sources = [HERE / "test.c", tmp / "installed_httpd.c"] 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"]] 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", 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", "-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
"-o", str(tmp / "test")], check=True, timeout=30) "-o", str(tmp / "test")], check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=20) subprocess.run([str(tmp / "test")], check=True, timeout=20)
+15 -1
View File
@@ -5,6 +5,9 @@
#include "web_cookie_auth.h" #include "web_cookie_auth.h"
#include "web_httpd_adapter.h" #include "web_httpd_adapter.h"
#include "esp_httpd_priv.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 httpd_data server = {.config.max_resp_headers = 8};
static struct sock_db socket_state; static struct sock_db socket_state;
@@ -49,7 +52,11 @@ int httpd_req_recv(httpd_req_t *r, char *out, size_t size) {
} }
esp_err_t web_login_ui_send_response(httpd_req_t *r) { return httpd_resp_sendstr(r, "login document"); } 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) { esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id) {
web_session_store_invalidate(id); return ESP_OK; 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) { esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *r, const char *protocol) {
(void)r; (void)protocol; ++upgrades; return ESP_OK; (void)r; (void)protocol; ++upgrades; return ESP_OK;
@@ -109,6 +116,10 @@ static void auth_reset(void) {
password_calls = 0; password_hook = NULL; password_calls = 0; password_hook = NULL;
} }
#ifdef HOST_ADMIN
#include "admin_test.c"
#endif
int main(void) { int main(void) {
assert(store_tests() == 0); auth_reset(); assert(store_tests() == 0); auth_reset();
char token[65], csrf[65], session[65], cookies[200]; char token[65], csrf[65], session[65], cookies[200];
@@ -253,5 +264,8 @@ int main(void) {
server.config.max_resp_headers = 6; expect("200 OK"); assert(cookie_count == 2); server.config.max_resp_headers = 6; expect("200 OK"); assert(cookie_count == 2);
server.config.max_resp_headers = 8; server.config.max_resp_headers = 8;
puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login"); puts("PASS: exact six-header successful login budget; all smaller header capacities invalidate unpublished login");
#ifdef HOST_ADMIN
admin_tests();
#endif
return 0; return 0;
} }
+24
View File
@@ -7,6 +7,21 @@
static char query[48]; static char query[48];
static unsigned broker_connections, broker_disconnects, writes, closes; 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, &current) == ESP_ERR_NOT_FOUND && !current);
expected_invalidated_id = 0;
}
}
static esp_err_t close_result = ESP_OK; static esp_err_t close_result = ESP_OK;
static httpd_req_t request = { .handle = (void *)1 }; static httpd_req_t request = { .handle = (void *)1 };
static void (*connect_hook)(void); static void (*connect_hook)(void);
@@ -96,7 +111,9 @@ int main(void) {
assert(web_serial_transport_mint_ticket(&bob, a.view.id, ta, sizeof(ta)) != ESP_OK); 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); web_serial_slot_t *sa = connect_session(&a), *sb = connect_session(&b);
ticket_for(&a, ta); ticket_for(&a, ta);
expected_invalidated_id = a.view.id;
assert(web_serial_transport_revoke_web_session(a.view.id) == ESP_OK); 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(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(ta, a.view.id, &p, &consumed) == ESP_OK && !consumed);
assert(consume_ticket(tb, b.view.id, &p, &consumed) == ESP_OK && consumed); assert(consume_ticket(tb, b.view.id, &p, &consumed) == ESP_OK && consumed);
@@ -107,7 +124,9 @@ int main(void) {
issued_t d = mint(&alice); sa = connect_session(&d); issued_t d = mint(&alice); sa = connect_session(&d);
assert(web_serial_transport_revoke_web_session(a.view.id) == ESP_OK && !sa->close_requested); assert(web_serial_transport_revoke_web_session(a.view.id) == ESP_OK && !sa->close_requested);
ticket_for(&b, tb); ticket_for(&d, ta); 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(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(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(ta, d.view.id, &p, &consumed) == ESP_OK && !consumed);
assert(consume_ticket(tb, b.view.id, &p, &consumed) == ESP_OK && !consumed); assert(consume_ticket(tb, b.view.id, &p, &consumed) == ESP_OK && !consumed);
@@ -144,10 +163,15 @@ int main(void) {
serial_reset(); web_session_store_stop(); serial_reset(); web_session_store_stop();
assert(web_serial_transport_mint_ticket(&alice, 0, ta, sizeof(ta)) != ESP_OK); assert(web_serial_transport_mint_ticket(&alice, 0, ta, sizeof(ta)) != ESP_OK);
serial_reset(); a = mint(&alice); b = mint(&bob); 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(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); assert(snapshot().initialized); assert(!host_lock_depth && closes > 0);
serial_reset(); a = mint(&alice); b = mint(&bob); s_initialized = false; 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(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); absent(&a); present(&b);
serial_reset(); a = mint(&alice); serial_reset(); a = mint(&alice);
for (unsigned field = 0; field < 6; ++field) { for (unsigned field = 0; field < 6; ++field) {