Compare commits
8
Commits
29a4953df0
...
8df1d2218b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8df1d2218b | ||
|
|
aa4bbc2c8c | ||
|
|
36e80811e8 | ||
|
|
737bd29f9e | ||
|
|
7ccc8799e9 | ||
|
|
9f6ebf2053 | ||
|
|
add399908a | ||
|
|
fa12440606 |
@@ -24,6 +24,14 @@ Apply changes RAM; **Save** explicitly persists device working state, not unsent
|
||||
|
||||
Confirm disruptive actions only with a recovery route ready. `accepted` does not mean online or verified DNS, and HTTPS/SSH/both browser terminals may disconnect before acknowledgement. Never automatically replay uncertain operations: reconnect via STA/AP, use Check Result/Refresh and inspect state. UART0 remains administrative recovery and native USB remains network-independent UART1 access. Changed hostnames require client DNS/trust/login checks. Browser-shell command restrictions are unchanged. See the [full bounded API, implementation evidence and pending target checklist](docs/phase8d12_13_implementation.md); no new commands or generated assets are introduced.
|
||||
|
||||
### Browser HTTPS identity (8D.21 HTTPS-only slice)
|
||||
|
||||
Administrators can use **Settings → HTTPS / Reboot → Refresh** to inspect the stored HTTPS certificate's public SHA-256 fingerprint and identity/service generations, then explicitly confirm **Rotate HTTPS identity**. Rotation persists a new certificate and private key and restarts HTTPS, invalidating all web logins and both browser terminal routes. Save browser drafts first. User accounts, SSH identity, Wi-Fi and device configuration are unchanged; UART0 administration and native USB UART1 access remain independent.
|
||||
|
||||
A lost acknowledgement, expired login or failed stop/start does **not** prove that the old identity remains: the new identity may already be persisted, with an old certificate still served if shutdown failed. There is no rollback or automatic retry. Inspect the result through trusted UART0 using `web certificate info`; verify that fingerprint before renewing browser trust, then reload and sign in freshly. Accepting a certificate warning alone is not trusted verification. Use canonical `web stop` / `web start` for retained-server recovery when necessary. The existing CLI `web reset --force` retains TLS-only replacement/recovery semantics and starts a stopped service; the browser has no duplicate reset or unavailable-material recovery action. No certificate/private-key export is provided.
|
||||
|
||||
The [HTTPS slice is implemented and host/build verified](docs/phase8d21_implementation.md); independent parent review and target trust/persistence/full-mix checks remain pending. SSH identity work requires a separate request, so full 8D.21 remains incomplete.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Hardware wiring](docs/wiring.md): hardware profile, GPIO assignments, connector guidance, and safety notes.
|
||||
|
||||
@@ -110,6 +110,12 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li
|
||||
|
||||
### HTTPS, WebSocket, and web serial
|
||||
|
||||
**8D.21 HTTPS identity extension:** The existing 8D.20 lifecycle routes/slot/controller additionally expose public stored SHA-256 fingerprint/identity generation and confirmed `rotate` with both expected generations. `web_server_replace_identity()` reserves the canonical service transition before acquiring the security identity reservation, then retains it across generation/commit and reserved stop/start. CLI rotate/reset and the deferred browser-shell owner use this same composition; direct security mutations acquire the same nonreused identity reservation. `web_security` holds its normal mutex only for admission/copies/publication during replacement, never over crypto/NVS; the reservation remains live while start copies the committed TLS pair. Generation/storage failure leaves identity and HTTPD unchanged; committed identity is never rolled back on lifecycle failure, so stored and served fingerprints can differ. Reset remains canonical CLI TLS-only recovery and starts a stopped service; ordinary stopped rotation stays stopped. No new browser reset, secrets, SSH identity action, route, task, timer or queue. Seven-field lifecycle snapshot is bounded to320 bytes; existing256-byte input/96-byte result and ACK deadlines remain. UI confirmation names the old public fingerprint and both generations, demands UART0 `web certificate info` verification of changed trust and fresh login, and shares the pending/no-replay gate with ordinary lifecycle controls. Full contracts/tests/target limits: `docs/phase8d21_implementation.md`. This extends, rather than replaces,8D.20 below.
|
||||
|
||||
**8D.20 integrated lifecycle (supersedes prerequisite-only status below):** `web_lifecycle_settings` admits strict current-admin cookie/Origin/CSRF stop/restart/reboot through three optional routes. One original-login operation slot and nonreused ID,256-byte/four-receive request,128-byte snapshot/96-byte result. Only after synchronous response send succeeds does HTTPD queue an ID callback; it hands that ID without waiting to the existing four-entry dispatcher. No request/fd/socket/operation pointer escapes the handler. Two-second ACK and30-second post-validation dequeue deadlines precede owner admission, not lifecycle completion. Lost HTTPD work retains one reservation until callback or successful destruction, avoiding late-work accumulation; failed destruction preserves it. Dispatcher reserves execution, revalidates original session/current admin and calls canonical generation-conditional owner APIs outside locks. Restart invalidates logins but retains its owner reservation through start; later revocation is not cancellation. Conditional reboot reserves the HTTPS generation then invokes the shared canonical `esp_restart()` primitive outside locks, with no console self-cleanup or HTTPD stop wait. UI explicit confirmation/manual results/15-second whole request fences and no replay/restore; existing Network reused.39 handlers/six sockets and unchanged tasks/stacks/timers/queue depth. Full API, uncertainty, SDK/reset audit and resource limits: `docs/phase8d20_implementation.md`.
|
||||
|
||||
**Historical 8D.20 internal owner prerequisite (superseded above):** `web_server` exposes a zero-wait scalar management snapshot and off-HTTPD generation-conditional stop/restart APIs. A firmware-lifetime saturated generation advances under the existing lifecycle mutex on every admitted canonical start/stop, including failed attempts; counter clear and repeated init do not restore old tokens. Conditional admission rejects failed cleanup, transition, absent server or exhausted generation. Reserved restart keeps `s_transitioning` true across successful stop into private start, with no unreserved stopped gap; external lifecycle calls remain outside the mutex. Stop failure skips start, partial-start cleanup failure retains ownership, and canonical stop/start remain recovery after saturation/failure. Repeated init preserves retained lifecycle errors. These APIs have no production caller yet and perform no authorization or ACK handoff; HTTP routes/UI/typed reboot are unimplemented. A future dispatcher must validate the original login and bound ACK handling before admission, never call shutdown from HTTPD or describe admitted work as cancelled by later revocation. Contract and incomplete scope: `docs/phase8d20_implementation.md`.
|
||||
|
||||
`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. No legacy credential is imported, synchronized or consulted for authentication. Both roles retain the same shipped web status/serial UI. 8D.5 adds an admin-only backend without a normal UI entry.
|
||||
@@ -134,6 +140,8 @@ Web serial initialization is failure-isolated from the base HTTPS service: if th
|
||||
|
||||
### Browser admin backend
|
||||
|
||||
8D.17 adds UI-only Serial/Wi-Fi quick presentation in `web_ui.c`: one existing settings DOM host and the same validators/request controllers, never parallel quick/full editors. Quick mode logically selects Settings (terminal input disabled) while showing the previous receiving terminal behind a bounded nonmodal panel. Promotion retains the single draft/controller; dismissal clears drafts/fences reads and automatic checks, retains pending uncertainty and never replays a mutation. Full-page hover is non-destructive; explicit activation navigates the full page instead. Network quick mode hides/disables password controls and clears transient secrets, with only existing secret-free snapshots. No backend/transport responsibility changes. Contracts and validation limits: `docs/phase8d17_implementation.md`.
|
||||
|
||||
8D.8 adds an in-document admin-only Settings/Serial view and optional `GET /api/settings/serial`. It requires current cookie/principal/admin authorization, rejects bodies/queries and inherits ordinary-GET Origin/no-store policy. Eight working config/running fields fit a 256-byte response; `serial_service_get_snapshot()` takes the existing state mutex with zero wait, releasing it before encoding/send and returning unavailable on contention. No mutation, NVS, broker or socket-lifecycle operation occurs. Both hidden terminals continue draining; Settings input is disabled, refresh is explicit/single-flight with session identity checks that cannot supersede serial admission, and cancellation clears/fences the settings view. URI budget is now 17, sockets remain six/no LRU. The private adapter's startup-only exact-GET registration stages descriptor/name allocation before table publication, unlike installed IDF's public failure path; HTTPD retains normal free ownership. Only Settings uses that helper; existing registration callers remain unchanged. See `docs/phase8d8_implementation.md` for bounds, tests and target-pending evidence.
|
||||
|
||||
The 8D.6 document binds retained terminal state to its first validated username/role/session-stable CSRF tuple. Every later session adoption must match, otherwise both terminal hosts are hidden, both sockets/work are closed/fenced and a clean `/` document is required. Pagehide hides scrollback until same-session revalidation; no clearing is needed for unchanged-session restore or mode changes. Terminal-fit readiness uses successful-bounds caching and at most three generation-fenced animation-frame retries per external request, never unbounded polling.
|
||||
@@ -144,6 +152,10 @@ The 8D.6 document binds retained terminal state to its first validated username/
|
||||
|
||||
### SSH
|
||||
|
||||
**8D.21 identity extension:** Existing SSH settings routes/controller/ID dispatcher slot expose atomic public stored fingerprint/generation and fixed supported ECDSA P-256 algorithm, plus rotation confirming service and identity generations. `ssh_transport_replace_identity()` takes existing command mutex with zero wait, compares service state then obtains task-bound nonreused security reservation before any stop/crypto/NVS. Canonical UART0/deferred SSH wrapper and direct security rotate/reset share admission; command mutex and identity reservation span stop→commit/publish→conditional restart. Crypto/NVS run outside security locks/spinlocks. Failed stop skips mutation/start; persistence failure can follow disconnection and attempts old-identity service recovery; committed replacement is never rolled back after restart failure. Stopped rotate stays stopped; canonical reset can recover unavailable material and start stopped SSH, without a browser Reset/recovery endpoint. Runtime owner retains context until all slots retire, frees it before clearing cleanup admission, and rejects orphan overwrites at start. wolfSSH copies caller DER; stack/candidate/live superseded key wiping retained. Public service/security snapshots are separate observations; admission compares both. Existing bounds256/768/96 bytes, login-isolated/manual15-second/no-replay result flow and shell policy unchanged; no HTTPS self-cutting ACK gate needed because HTTPS stays accessible. Changed-known_hosts verification requires trusted UART0 `ssh host-key info`. Full contract/evidence/parent-target limits: `docs/phase8d21_implementation.md`.
|
||||
|
||||
**Typed ordinary SSH controls (8D.19 first service slice):** `web_ssh_settings` owns one session-bound operation/result slot, with256-byte/four-receive JSON,768-byte safe two-row projection and96-byte result. Three optional current-admin routes use canonical cookie/Origin/CSRF protection and the existing four-entry dispatcher (IDs only), never lifecycle work on HTTPD. The dispatcher checks login/principal currentness and30-second dequeue deadline. `ssh_transport` copies only published state under its short lock, and conditional actions take the existing command mutex with zero wait before checking a saturated lifecycle generation; lifecycle comparison/admission shares that mutex with CLI. Disconnect publishes an exact SSH-ID close request under the existing SSH lock; only the owner closes sockets/wolfSSH. SSH session generations now retire exhausted slots, while a separate lifecycle generation fences stop/start ABA and survives counter clear. Failed/pending cleanup gates typed controls, retaining canonical UART0 recovery. UI confirms SSH/all-SSH/one-SSH scope, preserves stale selection without rebasing, and uses15-second bounded requests with manual result/refresh recovery, no automatic replay.36 handlers/six sockets and unchanged tasks/stacks/timers/queue depth. No invoking HTTPS-session-cutting action, web-session/USB/Wi-Fi controls or identity mutation. Full contracts, admitted-work/timeout limits and pending target checks: `docs/phase8d19_implementation.md`.
|
||||
|
||||
`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.
|
||||
|
||||
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`.
|
||||
@@ -237,6 +249,14 @@ Configuration modules generally choose RAM defaults without erasing incompatible
|
||||
|
||||
Bounds:256-byte/four-receive request,128-byte snapshot,96-byte result, one slot;30 URI handlers/six sockets, no new task/timer/stack-size/queue/schema expansion. Optional staged allocation failures retain other routes. Host tests/build pass; target and memory/stack margins pending. Full contract, resource evidence and checklist: `docs/phase8d14_implementation.md`.
|
||||
|
||||
## Typed Broker management (8D.16)
|
||||
|
||||
`web_broker_settings` provides admin-only bodyless GET management snapshot and GET/POST assignment/result routes, using Display-style bounded parsing, one login-isolated result slot and the existing typed dispatcher. Only a numeric ID is queued. Original principal/session and30-second dequeue deadline are checked before owner admission; already-admitted work can finish after logout. Optional staged registration preserves other services. Bounds:256-byte/four-receive request,2048-byte snapshot,96-byte result;33 handlers/six sockets, no new timer/task/queue/stack-size/assets changes.
|
||||
|
||||
The broker copies compact client rows and writer/lease generation together under its existing mutex with zero wait. Conditional assignment compares generation and connected non-reused target under the same force-writer lock before any effect. Lease grant/release/revoke events advance a separate saturating32-bit generation before delivery attempts, including same-writer ABA; counter clears and dropped events cannot invalidate this fence. UINT32_MAX disables conditional assignment, not ordinary transport/console recovery. Client IDs retain three slot/29 generation bits, but exhausted slots now retire until reboot rather than reusing IDs. Reboot invalidates originating web sessions. Existing unconditional force APIs and one-writer/multiple-observer policy remain unchanged.
|
||||
|
||||
Browser Settings/Broker shows safe ID/type/name/role/pending/HWM/drop rows. **8D.18** reuses this controller in8D.17's sole nonmodal quick host for admin-only Broker clients/Active writer entrances; no second DOM/draft/backend. Contextual snapshots refresh5seconds after successful completion with one active Broker controller and5-second whole-read deadline, stopping on error/uncertainty/departure. Explicit selection retains its identity and lease version across refresh; stale/absent latches cannot be cleared by later matching snapshots, only deliberate reselection. Native confirmation captures exact target/generation before session revalidation. Viewing/selecting never mutates; completion checks remain bounded to10 one-second GETs/15seconds, no mutation replay. New contextual triggers never navigate away from full-page drafts; promotion preserves selection/controller, stops live scheduling. Both terminals receive with input isolated; focused controls use guarded aria-disabled state to avoid focus loss during refresh. No diagnostic/service controls. Backend/wrap contract: `docs/phase8d16_implementation.md`; current UI/test/target limits: `docs/phase8d18_implementation.md`.
|
||||
|
||||
## Local UI and hardware boundaries
|
||||
|
||||
`board_pins.h` centralizes project-assigned RS-232, diagnostic, RGB LED, and local-UI hardware resources; UART0 GPIOs remain local to `main.c`, and native USB uses platform wiring. `local_display` solely owns I2C0, the SSD1315-compatible OLED, its static framebuffer, and display mutex. Display frames belong to the initiating task. Dirty-page commits and I2C transactions are bounded.
|
||||
|
||||
+19
-1
@@ -34,6 +34,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
|
||||
## Session broker
|
||||
|
||||
- **8D.16 management:** `session_broker_get_management_snapshot()` copies compact eight-client rows plus writer/lease generation atomically with zero wait; `session_broker_assign_writer_current()` compares generation and target under the force-writer lock. 29-bit client generations retire slots instead of wrapping; saturating lease generation fences ABA, survives counter clear, and leaves ordinary recovery available. Actual canonical regressions extend `tests/session_broker_diagnostics/run.py`. Full contracts/wrap analysis: `docs/phase8d16_implementation.md`.
|
||||
|
||||
**Responsibility:** mediate all transport access to the serial service; provide one writer lease and multiple isolated observers.
|
||||
|
||||
- Files: `src/session_broker.{h,c}`, `src/session_console.{h,c}`
|
||||
@@ -55,10 +57,22 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
- Dependencies: TinyUSB, broker, serial service
|
||||
- Flow: `USB host <-> USB task <-> broker`
|
||||
- Lifecycle: permanent owner task; broker client exists only while attached with host DTR asserted.
|
||||
- Constraint: callbacks retain the latest host line coding only for diagnostics; it never reconfigures UART1. UART1 framing and speed remain controlled by the serial configuration and its explicit persistence commands.
|
||||
- Constraint: callbacks retain the latest host line coding only for diagnostics; it never reconfigures UART1. UART1 framing and speed remain controlled by the serial configuration and its explicit persistence commands.
|
||||
|
||||
## Web and WebSocket serial
|
||||
|
||||
- **8D.21 HTTPS-only slice (2026-09-13):** `web_security.{c,h}` owns zero-wait fingerprint/generation projection and nonreused identity reservation shared by canonical rotate/reset; replacement generates/commits outside locks, publishes only after commit. `web_server_replace_identity()` reserves service before identity checks and retains both through commit/stop/start, shared by `web_console` and browser-shell owner; no mutation before stale service rejection, no rollback after commit. `web_lifecycle_settings.c`/`web_ui.c` extend the existing 8D.20 slot/routes/controller with confirmed `rotate` plus required identity generation and seven-field public metadata snapshot (320-byte bound); ACK/original-login and pending/no-replay rules unchanged. No new route/task/timer/queue/assets/config/SSH identity work. PASS security17, lifecycle44+two real-crypto/NVS integration groups, cookie lifecycle8+shared/all variants, UI156+HTML/CSP and broad regressions. Final pio100,532 RAM/1,825,073 flash (+24/+3,568 vs audited baseline), CPU160 verified. Chosen HTTPS slice implemented; parent review/target pending, full21 incomplete pending separately requested SSH identity work. Exact ownership/API/failure/wrap/test/resource contracts: `docs/phase8d21_implementation.md`.
|
||||
|
||||
- **8D.20 end-to-end (supersedes prerequisite-only entry below):** `web_lifecycle_settings.{c,h}` owns three optional current-admin lifecycle snapshot/operation routes,256-byte typed stop/restart/reboot, one original-login slot and bounded send-return→HTTPD ID callback→existing dispatcher handoff. One lost ACK reservation survives until callback/successful HTTPD destruction; nonreused IDs and owner generation prevent late/same-pointer ABA. `web_server` conditional reboot uses canonical `esp_restart()` outside locks, stop/restart retains owner reservation. `web_ui.c` adds confirmed HTTPS/Reboot,15-second requests/manual results, pending uncertainty/no replay/relogin, link to existing Network with accurate UART0/SSH/USB recovery.39 handlers/six sockets; no tasks/timers/queue growth/assets/config/identity work. PASS lifecycle41, cookie lifecycle8+shared, UI153+HTML/CSP, dispatcher and broad regressions. Final pio100,508 RAM/1,821,505 flash (+104/+13,064 vs pre-phase100,404/1,808,441); CPU160 preserved. Host/build complete, independent parent review and target/runtime checks pending. Exact bounds/failure/reboot audit/resources/checklist: `docs/phase8d20_implementation.md`.
|
||||
|
||||
- **Historical 8D.20 internal HTTPS owner prerequisite (superseded by integration above):** `web_server.{c,h}` owns zero-wait `web_server_get_management_snapshot()` plus off-HTTPD `web_server_stop_current()`/`web_server_restart_current()`. Canonical mutex compares saturated lifecycle generation; restart retains transition reservation across stop/start, failures preserve canonical cleanup/recovery. Repeated init preserves lifecycle failure. No HTTP/UI/ACK/reboot integration or new production caller yet. Existing lifecycle harness now34 groups including ABA, saturation, gap interleavings and failure recovery. Baseline100,404/1,808,441 →100,404/1,808,589 B (+0/+148); unused public entry points linker-discarded. Parent/target review pending. Contract/split/remaining authorized20 and target checklist: `docs/phase8d20_implementation.md`. Do not implement unrelated19 or duplicate existing Network controls.
|
||||
|
||||
- **8D.18 client/writer contexts (2026-09-13):** `web_ui.c` extends8D.17's sole quick host with admin-only Broker clients/Active writer entrances to the existing Broker controller/native confirmation. One5-second-after-success live timer and5-second whole-read deadline; explicit identity/lease token retained across refresh, sticky stale/absence latches prevent rebasing/resurrection. Full-page drafts untouched by new triggers; focused controls retain focus with guarded aria-disabled state. No backend/policy/assets/CPU/transport changes. `tests/web_ui_session/{broker.cjs,layout.py}`:135 UI groups+renderer/HTML/CSP; broad broker/auth/lifecycle/transport regressions PASS. Baseline100,300/1,788,661 → final100,300/1,793,301 RAM/flash (+0/+4,640 B), CPU160 verified. Independent parent review and target sign-off pending. Exact contracts/tests/limits/checklist: `docs/phase8d18_implementation.md`.
|
||||
|
||||
- **8D.17 quick settings (2026-09-13):** `web_ui.c` owns Serial/Wi-Fi status-trigger nonmodal popovers using the single existing settings DOM/controller, no parallel drafts/endpoints. Secret-free Network quick mode excludes password controls; full-page promotion preserves controller/nonsecret draft, dismissal fences reads/polling without replay. Hover/focus/click/tap, Escape/outside dismiss; full-page drafts protected from hover. Tests `tests/web_ui_session/{browser,network}.cjs`, `layout.py`:126 UI groups + renderer/HTML/CSP PASS; optional Chromium geometry blocked by sandbox, target/independent parent review pending. Baseline100,300/1,782,613 → final100,300/1,788,629 RAM/flash (+0/+6,016 B). CPU160/combined WS send/Broker/Display unchanged. Contract, exact tests and checklist: `docs/phase8d17_implementation.md`.
|
||||
|
||||
- **8D.16 Broker (2026-09-13):** `web_broker_settings.{c,h}` owns optional admin-only GET `/api/settings/broker`, GET/POST `/api/settings/broker-operation`; existing dispatcher queues only IDs. `web_ui.c` adds Serial/Display-style full-page rows and explicit confirmed assignment, no mutation on view/selection. 256-byte/four-receive request,2048-byte snapshot,96-byte result, one login-bound slot/no timer;33 handlers/six sockets, unchanged tasks/stacks/queue/assets/CPU160/combined WS send. Tests: cookie `--broker`6+shared, broker management/wrap, dispatcher, lifecycle25, UI119+HTML/CSP and broad regressions. Baseline100,196/1,765,233 B → final100,300/1,782,613 RAM/flash. Independent parent review and target sign-off pending. Contracts/resources/checklist: `docs/phase8d16_implementation.md`.
|
||||
|
||||
- **8D.14 Display (2026-09-09):** `web_display_settings.{c,h}` owns optional admin-only GET `/api/settings/display`, GET/POST `/api/settings/display-operation`; `web_ui.c` supplies Serial-style dim/off settings and bounded completion checks. `local_status_ui` owns generation-safe config/storage reservation shared with CLI; buttons do not edit timeouts. No I2C changes. 256-byte/four-receive request,128-byte snapshot,96-byte result, one slot/no timer;30 handlers/six sockets, unchanged tasks/stacks/queue/schema. Tests: cookie `--display` (7+shared), UI111, lifecycle23, dispatcher and broad regressions. Actual baseline100,100/1,748,513 B → final100,196 RAM/1,765,233 flash at160MHz. Target pending; exact API, reset ordering, deadlines, resource/validation limits: `docs/phase8d14_implementation.md`.
|
||||
|
||||
- **Current 8D.12/8D.13 — user functional sign-off 2026-09-08, including Settings presentation:** `web_network_settings.{c,h}` owns optional admin-only GET `/api/settings/network` and GET/POST `/api/settings/network-operation`; `web_ui.c` supplies Network, UTF-8/hex SSID editing and explicit transient-secret/connection controls. `wifi_manager` owns generation-checked secret-free snapshots/patch/save/stored-only load and radio transitions; `mdns_service` owns independent conditional hostname persistence, with manager reannouncement. Existing dispatcher receives IDs only. 768-byte request/2,048-byte snapshot/128-byte result, one slot/one-second timer with 30-second queued expiry plus scheduling latency; no hard cancellation. 27 handlers/six sockets, no task/stack/queue/schema growth. Parent integrated tests/build PASS; latest styling UI100 + renderer/CSP/Chromium checks, 99,548 B RAM / 1,744,325 B flash. User full-mix evidence accepted; loaded internal/DMA minima2,276/156 B remain resource follow-ups, not reserve approval. Full contract/exclusions/checklist: `docs/phase8d12_13_implementation.md`. Both phases user-authorized together; no 8D.14/M3 claim. Older next-phase statements below are historical.
|
||||
@@ -110,6 +124,10 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
|
||||
## SSH
|
||||
|
||||
- **8D.21 SSH identity extension:** `ssh_security.{c,h}` owns zero-wait atomic public metadata and task-bound/nonreused identity reservations shared by direct rotate/reset, crypto/NVS outside security locks. `ssh_transport_replace_identity()` shares canonical CLI/deferred SSH combined service-before-identity admission, existing command-mutex reservation and stop→commit→conditional restart; failed stop skips mutation/start, context retained until owner retires every slot, start rejects orphan handles. `web_ssh_settings.c`/existing `web_ui.c` SSH controller/routes add P-256 fingerprint/algorithm/both generations and confirmed rotation (same256/768/96-byte bounds, manual15-second/no-replay result flow); no browser Reset/recovery/export or shell-policy change. PASS real-security+combined-owner5, runtime-retention, management5, cookie SSH7+shared/all variants, UI158 and broad HTTPS/console/transport regressions. Final100,556 RAM/1,828,573 flash (+24/+3,500), CPU160 unchanged. Full21 allowed HTTPS+SSH implementation complete; independent SSH parent review and target acceptance pending. Contracts/tests/lifetime/failure/limits: `docs/phase8d21_implementation.md`.
|
||||
|
||||
- **8D.19 first service slice:** `web_ssh_settings.{c,h}` adds optional admin-only GET `/api/settings/ssh`, GET/POST `/api/settings/ssh-operation`; existing dispatcher queues only IDs to one login-bound slot. `ssh_transport_get_management_snapshot()` copies published state without owner wait/stack scan; `ssh_transport_manage_current()` checks saturated service generation under existing command mutex and exact session ID under SSH lock before canonical lifecycle/external-close admission. Exhausted session slots retire instead of wrapping. `web_ui.c` adds confirmed SSH-only Settings, sticky stale selection,15-second requests/manual Check Result/Refresh.36 handlers/six sockets/no new tasks/timers/depth/stacks/assets; CPU160 and8D.18 preserved. Tests `tests/ssh_management/run.py`, cookie `--ssh`, dispatcher, lifecycle27 and UI143. Contracts/resources/remaining8D.19 service audit/target checks: `docs/phase8d19_implementation.md`. SSH slice implemented/host/build verified; parent review/target sign-off pending, not full8D.19.
|
||||
|
||||
**Responsibility:** authenticate SSH, route users to serial and administrators to the command dispatcher, and own wolfSSH lifecycle.
|
||||
|
||||
- Files: `src/ssh_transport.{h,c}`, `src/ssh_security.{h,c}`, `src/ssh_console.{h,c}`
|
||||
|
||||
@@ -4,6 +4,26 @@ This file is working memory. Update it during active work and before handoff; do
|
||||
|
||||
## Development state
|
||||
|
||||
- **8D.21 remaining SSH identity slice implemented end-to-end (2026-09-13), host/build verified, independently reviewed; target acceptance pending:** Clean initial worktree; completed HTTPS history/shared reservations preserved. `ssh_security` adds atomic zero-wait public fingerprint/generation and task-bound nonreused reservation shared by direct rotate/reset; crypto/NVS outside security locks, commit-before-publication/wipe. `ssh_transport_replace_identity()` holds existing command mutex across service-before-identity comparison/reservation and canonical stop→persist→conditional restart, fails stop without mutation/new start, preserves stopped rotate/reset behavior and partial effects. Runtime start refuses orphan handles; failed stop retains context until owner retires all slots. Existing cleanup already freed wolfSSH sessions before broker failure—no preexisting UAF demonstrated. Existing19 routes/256-byte request/768-byte snapshot/96-byte result/ID dispatcher slot and UI now expose P-256 metadata and confirmed rotation with both generations, all-SSH/known_hosts/trusted UART0 `ssh host-key info`/partial-effect warnings,15-second/manual result/no replay; HTTPS stays accessible. No browser Reset/recovery/export, user authorized-key, deprecated shell-policy, task/timer/queue/route/assets/config/SDK/dependency changes. PASS SSH security5 including real-mbedTLS/NVS+actual combined-owner integration, runtime retention, management5, cookie SSH7+all prior variants/shared, UI158+HTML/CSP, HTTPS lifecycle44+2/security17, dispatcher/policy and broad regressions. Final production pio21.98s **100,556 RAM/1,828,573 flash (+24/+3,500 vs SSH baseline100,532/1,825,073)**; CPU160 defaults/active/generated verified, combined WS send untouched. Full21 allowed HTTPS+SSH identity implementation scope complete, not target/M3/8D.22 acceptance. Independent review found no confirmed actionable defects; installed wolfSSH copy/lifetime, owner failures and auth/UI scope audited. Reviewer reran SSH security5/retention/management/cookie SSH7/UI158/console suites/HTTPS lifecycle+security17/diff PASS. Parent final pio confirmation PASS7.15s100,556/1,828,573 B; diff check PASS. No target sign-off inferred. Full contracts/tests/resources/exclusions and hardware/runtime limits: `docs/phase8d21_implementation.md`. No upload/erase/commit/branch.
|
||||
|
||||
- **8D.21 HTTPS-first slice implemented end-to-end (2026-09-13), host/build verified, independently reviewed; target sign-off pending:** Preserved initial three-file audit documentation work, then completed shared `web_server_replace_identity()` service-before-identity reservation across generation/commit/stop/start, zero-wait public `web_security` fingerprint/generation projection and nonreused reservation shared by direct canonical rotate/reset. Crypto/NVS outside locks; no identity mutation before stale service rejection, no CLI/browser-shell bypass, no rollback after commit. CLI reset/recovery starts a stopped service; stopped ordinary rotation stays stopped. Existing8D.20 lifecycle routes/slot/ACK/original-login dispatcher and shared UI controller extend with confirmed `rotate`, required identity generation and320-byte seven-field snapshot. Fingerprint/both generations confirmed; trusted UART0 `web certificate info`, changed trust/fresh login, all-web-session disruption, partial-effect uncertainty,15-second UI bounds/manual results/no replay retained. No browser reset/export/recovery secrets, SSH identity work, new tasks/timers/routes/queues/assets/config/SDK/dependencies. PASS security17, lifecycle44+two integrated production-owner/real-mbedTLS/NVS fault groups, cookie lifecycle8+shared/all variants, UI156+renderer/HTML/CSP, dispatcher/console self-detach and broad regressions. Final pio22.78s **100,532 RAM/1,825,073 flash (+24/+3,568 vs audited100,508/1,821,505)**; defaults/active/generated CPU160 verified, combined WS send and prior20/18/19/throughput work preserved. Independent reviewer found no confirmed actionable findings, reran security17/lifecycle44+2/cookie lifecycle/UI/dispatcher/console lifecycle/diff PASS. Parent final pio confirmation PASS6.98s100,532/1,825,073 B; diff check PASS. Final expanded integration rerun PASS; no hardware/upload/erase/commit/branch or runtime-reserve/M3 claim. Exact ownership/wrap/failure/API/tests/resources and pending parent/target checklist: `docs/phase8d21_implementation.md`. **Chosen HTTPS slice complete; full21 incomplete, remaining SSH identity work separately requested.**
|
||||
|
||||
- **Historical 8D.21 audit-only handoff (2026-09-13), superseded by implemented HTTPS slice above:** Read exact plan and relevant memory/source; enumerated HTTPS-first public metadata + confirmed rotation, excluded redundant healthy-material reset UI, all SSH identity work and recovery secrets/database recovery. Verified TLS-only reset additionally recovers unavailable material and starts a stopped HTTPS service at the CLI; preserve those semantics. Existing security mutex protects generation/commit but not restart; CLI and browser-shell callers commit then stop/start separately. Completion requires combined service/identity generation admission and reservation shared with canonical callers,8D.20 ACK path, complete typed API/UI and fault/concurrency/auth/no-replay tests—not unused prerequisite interfaces. Baseline `pio run` PASS7.17s100,508 RAM/1,821,505 flash; initial worktree clean. No source/test/device/config changes or regression/final-build/review/sign-off claim. Only audit handoff documentation delivered; chosen HTTPS slice and full21 both incomplete. Exact findings, exclusions and continuation checklist: `docs/phase8d21_implementation.md`. Do not mistake this entry for feature delivery; durable architecture/code-map/decisions remain unchanged because no owner contract changed.
|
||||
|
||||
- **8D.20 end-to-end integration complete (2026-09-13), host/build verified, independently reviewed; target sign-off pending:** Continuation found/preserved uncommitted backend/API/UI/tests beyond the prerequisite-only memory. Inspected and validated that integration, added send-return-at-deadline/request-storage-reuse/accepted-restart-expired-login regressions and explicit post-restart login wording. `web_lifecycle_settings.{c,h}` provides three optional current-admin routes, strict256-byte/four-receive typed stop/restart/reboot,128-byte snapshot/96-byte result, one login-bound slot/nonreused ID and one2-second ACK reservation. Synchronous send return then HTTPD ID callback then existing four-entry dispatcher, original-login/current-admin/post-validation30-second deadline, canonical generation/reserved stop/restart and narrow `esp_restart()` admission. No request/fd capture, no lifecycle on HTTPD, no lock/self-console-cleanup wait at reset. Lost HTTPD work stays singly reserved until callback or successful destruction; late/ABA work inert; failed stop retains canonical recovery. UI HTTPS/Reboot explicit confirmations,15-second whole requests, manual results, pending/unknown duplicate gate and no replay/restore; existing Network link/controller, corrected USB-vs-admin recovery wording. PASS lifecycle41, cookie lifecycle8+shared/all prior variants, dispatcher and broad console/auth/transport/store/idle/performance/SSH/Network/broker/security/parser/login/diagnostics; UI153+seven-view HTML/CSP. Final focused suites rerun after final wording. `pio run` first21.86s100,508/1,821,441; final22.85s100,508/1,821,505 B. Final delta vs pre-phase100,404/1,808,441: **+104 RAM/+13,064 flash**; vs prerequisite100,404/1,808,589: +104/+12,916. Module mutable object108 B (slot88,lock8,IDs/handle12); linked placement differs.39 handlers (36+3), six sockets; no task/timer/queue-depth/item/stack/session/ticket/heap/PSRAM/schema/config/assets/SDK/dependency/identity changes. CPU160/nonblocking HTTPD queue verified; UI18/SSH19/combined WS preserved. No device/upload/erase/commit or runtime reserve/full-M3 claim. SDK sequencing/reset inspected, not real scheduler/reset tested. Independent reviewer found no confirmed actionable defects after installed-IDF callback/reset and owner/UI audit; independently reran lifecycle41, cookie lifecycle8+shared, dispatcher, UI153+renderer/HTML/CSP and diff checks PASS. Parent final pio confirmation PASS6.82s100,508/1,821,505 B; diff check PASS. Full exact contracts, test commands/evidence, limits and pending target checklist: `docs/phase8d20_implementation.md`. The prerequisite-only entry below is historical and superseded by this integration.
|
||||
|
||||
- **Historical 8D.20 lifecycle-owner prerequisite (2026-09-13), superseded by integration above:** Scope split before editing under plan600–800-line rule after finding missing HTTPS generation admission and stop/start reservation. `web_server.{c,h}` adds zero-wait secret-free management snapshot, conditional stop/restart under canonical mutex, saturated lifecycle generation across CLI ABA/counter clear and continuously reserved restart. Failed stop skips start; partial cleanup retains ownership and canonical recovery. Repeated init no longer clears retained invalid-state lifecycle failure. No new HTTP routes/UI/ACK handoff/typed reboot; these remain next authorized8D.20 integration, not complete phase delivery. Existing Network Wi-Fi controls must be reused; unrelated19 other-service and21 excluded. Initial worktree clean. PASS lifecycle34 (27+7 owner groups), broad console/auth all variants/UI143/SSH/Network/broker/store/transports/idle/performance/security/login/parser/diagnostics and diff check. Baseline pio21.10s100,404 RAM/1,808,441 flash → post-production35.05s100,404/1,808,589 (+0/+148 B); final pio20.02s same sizes, lifecycle34/diff confirmation PASS. Object generation4 B; unused public management entry points linker-discarded, so not full feature cost. CPU160 active/default/generated verified; UI18/SSH19 and combined WS source unchanged. No new task/timer/queue/depth/stack/socket/route/heap/assets/SDK/dependency/config/secret/device/upload/erase/commit changes. Self-review only; no independent reviewer tool, parent review pending. Target/runtime reserves/M3 pending. Exact owner contract, seven new fault groups, split rationale, remaining ACK/API/UI/reboot scope and target checklist: `docs/phase8d20_implementation.md`. Earlier no8D.20 wording below is historical;19 remains SSH-only completed/reviewed, other-service slice unimplemented.
|
||||
|
||||
- **8D.19 first service slice — SSH (2026-09-13), user-authorized; implemented/host/build verified, independently reviewed; target sign-off pending:** Admin Settings/SSH provides safe two-row status and confirmed SSH start/stop/one-session disconnect only. `web_ssh_settings` owns256-byte/four-receive request,768-byte snapshot,96-byte result and one login-bound slot/30-second dequeue deadline; existing four-entry dispatcher receives IDs only. SSH owns zero-wait published snapshot, command-mutex conditional lifecycle admission with saturated service generation (CLI stop/start ABA included), exact-ID external close and retired exhausted session slots. HTTPD never waits for SSH/wolfSSH; no new task/timer/stack/depth/socket/asset/config/dependency.36 handlers/six sockets. UI uses existing Settings styles/native confirmations, sticky stale target selection,15-second whole-request deadline and explicit manual Check Result then Refresh (no polling/replay). Invoking HTTPS login/browser terminals/Wi-Fi/USB/UART0 are not stopped. Canonical browser-shell restrictions unchanged. All web-session/HTTPS/USB actions excluded; other-service8D.19 requires separate owner/scope audit, phase incomplete; no8D.20/21. Initial worktree clean. PASS canonical SSH4, cookie SSH6+shared/all existing variants, dispatcher, lifecycle27, UI143+six-view HTML/CSP, broad accounts/Network/broker/policy/auth/transport/store/idle/diagnostics/performance/security/login and diff check. Independent reviewer found no confirmed actionable defects; canonical SSH4, cookie SSH6+shared, dispatcher, lifecycle27, UI143+renderer/HTML/CSP and diff checks independently PASS. Parent final pio confirmation PASS19.95s100,404/1,808,441 B; diff check PASS. Baseline pio6.87s100,300 RAM/1,793,301 flash; final35.31s100,404/1,808,441 (+104/+15,140 B). CPU160 defaults/active/generated confirmed;8D.18 contexts/combined binary WS path preserved. No hardware/real-browser geometry/heap/stack validation, upload/erase/commit or target/reserve/M3 sign-off. Exact API/owner race/uncertainty/resource/test limits and pending checks: `docs/phase8d19_implementation.md`.
|
||||
|
||||
- **8D.18 implemented (2026-09-13), user-authorized; host/build verified, independently reviewed; target sign-off pending:** UI-only Broker clients/Active writer contexts reuse8D.16 snapshot/lease fence/operation controller and8D.17 single host/native confirmation/dismissal focus fix.5-second-after-success single-flight live refresh with5-second whole-read deadline; errors/timeouts stop, no auto error/mutation retry. Explicit selection identity+lease token is never rebased; stale/absent latches prevent resurrection, consumed selection requires explicit reselection. New triggers preserve full-page Serial/account/Network drafts even on activation; quick promotion preserves selection/controller and stops live timer. Focused controls use aria-disabled plus existing handler guards rather than native focus loss; previous terminals keep receiving with input isolated. Initial worktree clean. PASS135 UI groups+renderer/HTML/CSP, broker management/wrap, cookie Broker+base/all existing variants, lifecycle25, dispatcher/canonical accounts/Network, parser294, transports/tickets/store, idle/diagnostics/performance/security/login and diff check. Broad tests preceded final focus refinement; full UI/build rerun afterward. Independent reviewer found no confirmed actionable defects and reran UI135+renderer/HTML/CSP, canonical broker management/wrap, cookie Broker and diff checks PASS. Parent final pio confirmation PASS6.89s100,300/1,793,301 B; diff check PASS. Native picker during live updates and actual blank/reselect remain browser-validation gaps, not proven defects. Baseline pio7.02s100,300 RAM/1,788,661 flash; final21.52s100,300/1,793,301 (+0/+4,640 B). CPU160 defaults/active/generated verified, combined WS send unchanged. No backend/API/policy/assets/config/dependencies/icons/8D.15/19/later/upload/erase/commit/SDK changes. No real-browser geometry/accessibility or device tests this phase; no reserve/M3 claim. Exact contract, test-development corrections and pending parent/target checklist: `docs/phase8d18_implementation.md`. Stop before any separately authorized next phase.
|
||||
|
||||
- **8D.17 independent-review P2 fixed (2026-09-13), independently confirmed resolved:** Quick dismissal now restores Serial/Admin state/input without xterm focus; automatic departure and adjacent-trigger switches preserve focus, Escape/Close focus only trigger. Observable Terminal.focus harness reproduced adjacent-trigger failure before fix;128 UI groups+renderer/HTML/CSP PASS, pio21.90s100,300 RAM/1,788,661 flash (+0/+32 B review delta; phase+0/+6,048 B), diff check PASS. Two new groups cover adjacent keyboard triggers and timed/outside-pointer dismissal/explicit focus for both terminal backgrounds. Existing uncommitted work preserved; no unrelated source/device/assets changes. Reviewer confirmed no new actionable findings and independently reran128 UI groups+renderer/HTML/CSP and diff checks PASS. Parent final build confirmation PASS6.98s,100,300/1,788,661 B; diff check PASS. Exact correction in `docs/phase8d17_implementation.md`; target/geometry limits remain.
|
||||
|
||||
- **8D.17 implemented (2026-09-13), user-authorized; host/build verified, independent parent review and target sign-off pending:** UI-only Serial/Wi-Fi quick popovers reuse the sole settings DOM/validators/controllers and typed endpoints. Hover/focus/click/tap, Escape/outside dismissal, bounded scrollable panel, full-page promotion, explicit Apply/Save; Network excludes password controls and clears transient input. Full-page hover cannot steal drafts; focused quick editor resists unrelated hover. Close fences reads/checks, preserves pending uncertainty/no replay; both terminals drain with selected input disabled, previous terminal restored on close. Worktree initially clean. PASS126 UI groups+renderer/HTML/CSP, cookie base/all existing variants, lifecycle25, dispatcher/canonical accounts/Network/broker, parser, transports/tickets/store, idle/diagnostics/performance/security/login regressions and diff check. Optional Chromium18-case geometry attempt failed before measurement (sandbox process-singleton socket/Crash Reports), no geometry pass. New test Serial ack fixture schema fixed and final suite/build passed. Baseline pio6.88s100,300 RAM/1,782,613 flash; final21.74s100,300/1,788,629 (+0/+6,016 B). CPU160 defaults/active/generated confirmed; signed-off combined WS/Broker8D.16/Display unchanged. No backend/API/assets/config/dependencies/icons/8D.18/8D.15/upload/erase/commit. Exact ownership, tests/limits/resources and target checklist: `docs/phase8d17_implementation.md`. Parent will independently review; no target/reserve/M3 sign-off.
|
||||
|
||||
- **8D.16 implemented (2026-09-13), user-authorized; host/build verified, independently reviewed; target sign-off pending:** Admin Settings/Broker has bounded detailed client rows and explicit confirmed writer assignment, no view/selection mutation, no8D.17/18 popovers/icons. `web_broker_settings` uses256-byte/four-receive requests,2048-byte snapshot,96-byte result, one login-isolated slot and existing typed dispatcher IDs/30-second dequeue deadline; no new timer/task/queue/stack/assets. Broker owns atomic zero-wait client/writer/lease snapshot and conditional generation+target check under force lock;29-bit client generations retire slots rather than wrap, separate saturated32-bit lease generation fences ABA/counter clears/dropped events, ordinary recovery retained.33 handlers/six sockets. PASS cookie Broker6+shared, canonical broker7+2 management/wrap, dispatcher, lifecycle25, UI119+five-view HTML/CSP, all existing cookie variants, canonical Network/accounts, parser294, console lifecycle/policy, transport/tickets/store, idle/diagnostics/performance/login and diff checks. Baseline pio7.14s100,196 RAM/1,765,233 flash; substantive-source final20.54s100,300/1,782,613 (+104/+17,380 B); final post-review confirmation21.20s unchanged. Initial worktree clean; CPU160 confirmed defaults/active/generated, signed-off combined WS send untouched. Tests fixed row-schema count and harness extraction/capacity/count issues. Independent final review found no actionable findings; reviewer reran broker7+2, cookie Broker/Display, dispatcher, lifecycle25, UI119+C/HTML/CSP, throughput and diff checks PASS. Parent final pio confirmation PASS6.93s at100,300/1,782,613 B; diff check PASS. Target/full-mix/HTTPD-dispatcher margins and user sign-off pending; no reserve/M3 claim. Exact API/wrap/resources/test limits and target checklist: `docs/phase8d16_implementation.md`. No upload/erase/commit/SDK changes. Older next8D.16 authorization wording below is historical.
|
||||
|
||||
- **8D.15 removed from plan (2026-09-09), explicit user scope decision:** Network diagnostics stay exclusive to the admin shell; no dedicated typed diagnostics endpoints/settings UI. Removed the planned deliverable from `docs/phase8d_plan.md`; retain later phase numbers, so next planned chunk after8D.14 is8D.16, only on separate request. Existing shell permissions and implemented Network settings/status unchanged. Documentation only; no firmware/build/device action or8D.14 target sign-off implied.
|
||||
|
||||
- **8D.14 Display implementation complete (2026-09-09), user-authorized; host-tested/build-verified, target pending:** Admin Settings → Display supplies typed dim/off0–86400s, Apply/Save/Load/Defaults/Reset, Serial-style label/value presentation and bounded result polling. `web_display_settings` owns one secret-free login-bound slot, 256-byte/four-receive requests,128-byte snapshot/96-byte results; IDs use the existing dispatcher. `local_status_ui` owns nonwrapping config generation and zero-wait reservation across NVS outside critical sections; CLI shares the gate, buttons retain independent activity/reprobe behavior. Reset now commits defaults before RAM publication (CLI too), eliminating rollback overwrite. Absent panel does not gate config if UI task is available; no I2C/renderer ownership change.30 handlers/six sockets, no new task/timer/stack size/queue/schema/assets changes.30-second dequeue admission deadline, not timed cancellation; admitted work may finish after logout. PASS Display7+shared auth, UI111+C/HTML/CSP, lifecycle23, dispatcher/accounts/policy, cookie all variants, canonical Network, parser294, transports/tickets/store/idle/diagnostics/throughput/broker/login regressions and diff check. Separate self-review fixed strict result status/action/replacement handling. Independent final review found no actionable findings and independently reran Display7+shared auth, dispatcher boundary, lifecycle23, UI111+C/HTML/CSP and diff checks PASS. Parent final pio confirmation PASS9.08s, unchanged100,196/1,765,233 B; diff check PASS. Actual pre-edit pio12.10s100,100 RAM/1,748,513 flash; final pio26.09s100,196/1,765,233 (+96/+16,720 B). CPU160 confirmed in defaults/active/generated configuration; throughput fix and user sign-off preserved. Initial worktree reported clean. Optional Chromium geometry attempt blocked by sandbox socket/crash-report restrictions; no geometry pass. Target display/save-reboot/absent-panel/buttons-concurrency/full-mix/heap/HTTPD-dispatcher margins and sign-off pending, no reserve approval or full M3 claim. Exact contracts/tests/resources/checklist: `docs/phase8d14_implementation.md`. No upload/erase/commit or later phase; older no-8D.14 authorization statements below are historical.
|
||||
|
||||
@@ -50,6 +50,8 @@ Only constraints supported by implementation or current project documentation be
|
||||
|
||||
## Resource IDs are generation-safe
|
||||
|
||||
**8D.19 SSH ordinary-control decision:** A transport disconnect must use the owning transport's handle, never an arbitrary broker client ID or socket fd. SSH retains its encoded session ID but retires exhausted generations instead of wrapping, preserving exact-ID owner-close consumption across reuse. Typed lifecycle confirmation adds a distinct saturated service generation advanced by canonical lifecycle admission, including CLI; compare and start/stop admission retain the existing command mutex. Published zero-wait snapshots do not scan owner/task state. HTTPD only admits current-admin bounded typed work to the existing dispatcher; it never waits for SSH or invokes wolfSSH. Disconnect success is an owner request, lifecycle timeout is not cancellation, and admitted work may finish after revocation. All-SSH stop explicitly includes new admissions before execution. Existing browser-shell SSH deferral restrictions stay intact. SSH-only first slice, not all-service8D.19 or8D.20; exact bounds/contracts: `docs/phase8d19_implementation.md`.
|
||||
|
||||
**Decision:** Broker clients, SSH/WebSocket slots, queued admin work, and user principals carry generations or random stable IDs to reject stale references and slot reuse.
|
||||
|
||||
**Rationale/evidence:** Broker IDs encode slot generation; transports track slot generations; admin tokens include session/slot generation; user principal currentness includes account ID and authentication generation.
|
||||
@@ -60,6 +62,16 @@ Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs
|
||||
|
||||
**Relevant files:** `src/session_broker.{h,c}`, `src/ssh_transport.c`, `src/web_serial_transport.c`, `src/admin_ssh_console.c`, `src/user_database.{h,c}`
|
||||
|
||||
## Confirmed writer transfer compares a lease version inside the broker lock
|
||||
|
||||
**8D.16 decision:** A client ID alone cannot authorize a stale browser confirmation: the writer may release and reacquire while a dialog is open. The compact management projection copies clients, writer and a separate lease generation under one zero-wait mutex acquisition. Conditional assignment validates target and lease generation in the same force-writer lock before any changes; existing unconditional console/recovery APIs remain available.
|
||||
|
||||
**Wrap contract:** Three-slot-bit/29-generation-bit broker IDs now retire exhausted slots instead of wrapping. A32-bit lease generation saturates at UINT32_MAX, survives counter clear, and advances for each grant/release/revoke emission before advisory queue delivery. Forced transfer can advance twice; it is an opaque version, not a count. Saturation rejects typed assignment but never prevents normal release/disconnect/request or recovery force. Future writer transitions must preserve the central event-emission invariant. Reboot resets broker state but invalidates web sessions, so authorized old browser work cannot span boots.
|
||||
|
||||
**8D.18 browser selection contract:** Live refresh preserves explicit target identity and its original lease token, not just the select value. Lease/identity mismatch, absence or failed read latches invalidation; later matching snapshots cannot resurrect validity or silently reselect a missing target. Only explicit reselection captures a new token, and transfer still requires separate confirmation. One shared quick/full controller fences reads/operations; contextual activation never destroys full-page drafts. Focused controls remain focusable with aria-disabled while independent action guards reject unavailable work. See `docs/phase8d18_implementation.md` for bounds and test limits.
|
||||
|
||||
**Consequence:** Never implement snapshot-check-unlock-force, compare only current writer ID, or renew a confirmation implicitly during Refresh. UI must require explicit target selection and confirmation, retain uncertain-outcome handling and never retry mutations automatically. Bounded login-isolated result slots and the existing dispatcher remain the typed HTTP boundary. Details/tests: `docs/phase8d16_implementation.md`, `src/session_broker.{c,h}`, `src/web_broker_settings.{c,h}`.
|
||||
|
||||
## UART0 is the physical recovery authority
|
||||
|
||||
**Decision:** UART0 remains independent of UART1 and networking. The first administrator is created with normal `user add` on UART0; explicit unavailable-user-database recovery to empty is UART0-only and refuses healthy storage. No bootstrap command/API remains.
|
||||
@@ -92,6 +104,12 @@ Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs
|
||||
|
||||
## Selected self-affecting admin SSH actions use bounded deferred control
|
||||
|
||||
**8D.21 SSH host identity decision:** Reuse canonical deferred SSH self-rotation and typed19 SSH routes/dispatcher, not command strings or another executor. Compare/reserve service then identity before stop or storage; retain the existing command mutex and task-bound nonreused security token through canonical stop→commit→conditional restart. Direct security rotate/reset share identity exclusion. Crypto/NVS run outside security locks/spinlocks; failed stop must not mutate identity or attempt start, and failed persistence may already have disconnected SSH. Never roll back committed identity after restart failure. Retain wolfSSH context until every slot retires; only owner frees it before clearing cleanup admission, and start refuses orphan handles. Public metadata is zero-wait/atomic within security, not a cross-owner authorization; UI confirms both generations and warns changed known_hosts/trusted UART0 `ssh host-key info`, partial effects and no replay. HTTPS remains accessible, so existing SSH queue/response order is retained without20's self-cutting ACK gate. Canonical reset/recovery preserved but excluded from browser as duplicate healthy rotation plus distinct recovery semantics. Details and test limits: `docs/phase8d21_implementation.md`.
|
||||
|
||||
**8D.20 integrated typed HTTPS/reboot decision (supersedes prerequisite-only status below):** A successful synchronous HTTP response send return is the ACK boundary, not peer receipt. Queue exactly one nonreused-ID HTTPD callback after sending; that callback only submits an ID to the existing dispatcher, never waits or runs lifecycle. No captured request/fd/reusable slot pointer. A lost callback remains reserved even after its two-second admission deadline; only its arrival or successful HTTPD destruction releases that reservation. Do not retry queue submission or permit callback accumulation. Original-login/current-admin/post-validation30-second deadline precede canonical generation-conditional owner admission; no cancellation claim after admission, including detach failures and deliberately login-invalidating reserved restart. Typed reboot uses shared `esp_restart()` outside locks after HTTPS generation reservation, not a console string/self-cleanup wait or new runner. UI requires explicit confirmation, fences15-second whole requests and late results, retains unknown/duplicate gates across navigation, never restores/retries mutations and requires fresh login after HTTPS restart. Network controls remain the sole Wi-Fi domain; USB is UART1 serial recovery, not administration or uninterrupted whole-device reboot. Exact contracts and target limits: `docs/phase8d20_implementation.md`.
|
||||
|
||||
**Historical 8D.20 HTTPS owner prerequisite (superseded above):** Conditional HTTPS stop/restart compares an expected saturated lifecycle generation under the canonical server mutex, not snapshot-check-unlock-unconditional-stop. Restart retains transition ownership through stop and start; a failed stop never admits start, and failed cleanup requires canonical recovery. Repeated init must not clear the retained lifecycle failure; counter clear must not reset generation. The zero-wait management projection does not authorize a request or prove reachability. All lifecycle execution remains off HTTPD and outside the server mutex during owner waits. Future typed ACK handling must precede admission on the existing dispatcher, with original-login currentness; later revocation is not cancellation of an admitted restart. No ACK/API/UI/reboot integration exists in this prerequisite, and the full phase remains incomplete. `src/web_server.{c,h}`, `docs/phase8d20_implementation.md`.
|
||||
|
||||
**Decision:** Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset are deferred until command state and administration/transport application buffers appear drained, with a ten-second limit and short final delay.
|
||||
|
||||
**Rationale/evidence:** `admin_ssh_console` has a separate bounded control task and pending-action state. The check is a best-effort application-buffer heuristic, not peer-delivery confirmation. User account mutations and their immediate revocation calls do not use this path.
|
||||
@@ -152,6 +170,8 @@ The following first-slice exclusions are historical and superseded by slice 2:
|
||||
|
||||
## Security material and configuration use bounded, versioned NVS records
|
||||
|
||||
**8D.21 HTTPS identity owner decision:** Reserve the service transition BEFORE conditional identity admission or generation, not rotate-then-conditional-restart. `web_server_replace_identity()` shares this composition with CLI reset/rotate and deferred browser-shell rotation; `web_security` reserves a nonreused token across generate/commit and service stop/start, excluding direct canonical security mutation too. Short normal-mutex admission/publication protects state, but crypto/NVS and HTTPD waits run without held locks. Failed generation/storage never publishes or stops HTTPD; committed material is not rolled back if later stop/start fails. Identity generation increments only on successful commit; service generation advances on admitted replacement and canonical stop/start, saturating without reuse. Reservation-token exhaustion rejects mutations until reboot; persisted identity exhaustion retains the existing fail-closed behavior. Zero-wait public metadata is the stored identity, not proof of the retained HTTPD certificate after failure. Extend only the existing ACK-safe typed lifecycle slot/controller with `rotate` and required identity generation; keep UART0 unavailable-material recovery and duplicate healthy Reset out of browser UI. Explicit trust/fingerprint verification through trusted UART0 and fresh login, uncertainty and no replay are correctness requirements. SSH identity work remains separately requested. See `docs/phase8d21_implementation.md` for exact API, bounds and host-versus-target evidence.
|
||||
|
||||
**Decision:** Application settings, users, and identities use separate fixed/versioned NVS blobs. Serial, Wi-Fi, mDNS-hostname, and local-UI working edits are RAM-only until explicitly saved. User mutations and HTTPS/SSH identity changes commit directly as part of the operation. Invalid ordinary configuration generally selects RAM defaults without erasing storage; malformed security material fails closed and needs explicit reset.
|
||||
|
||||
**Rationale/evidence:** Serial, Wi-Fi, local UI, web security, users, and SSH security each validate schema/size and own their namespace. User/security mutations build and validate candidate state before committing it; security modules avoid silently replacing an established identity. The live user database remains internal while its 5,360-byte candidate is a persistent PSRAM-preferred allocation with internal fallback and is wiped after every transaction.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Phase 8D.16 — Broker visibility and confirmed writer assignment
|
||||
|
||||
## Status and scope (2026-09-13)
|
||||
|
||||
User-authorized implementation complete; focused host regressions, broader regressions and production build pass. **Independent review complete with no actionable findings; target validation and user phase sign-off remain pending.** Reviewer independently reran broker management/wrap, cookie Broker and Display, dispatcher, lifecycle25, UI119+C/HTML/CSP, throughput and diff checks, all PASS. Parent final `pio run` confirmation PASS6.93s,100,300 B RAM/1,782,613 B flash; diff check PASS. Host doubles do not establish real multicore timing or target margins.
|
||||
|
||||
Settings → Broker is admin-only and uses the existing Serial/Display label/value and action styles. It shows up to eight connected clients with ID, type, name, writer/observer role, pending output bytes, output high-water and dropped bytes. Selection is explicit and Refresh clears it. Only Assign writer followed by confirmation submits a mutation. Viewing, selecting, refreshing, switching settings/terminals and reading results do not change the lease or close either terminal.
|
||||
|
||||
The initial Git status was clean. Preserve the previously signed-off combined binary WebSocket send and CPU160 configuration; neither was edited. No 8D.15 diagnostics UI (diagnostics remain shell-only), 8D.17/18 popovers/icon changes, disconnect/revoke-only UI, serial persistence/configuration change, new generic runner/task/timer/mutex allocation, queue depth/item or stack-size increase, generated assets, SDK patches, upload, erase, branch or commit. No full M3 or resource-reserve acceptance claim.
|
||||
|
||||
## Production files and ownership
|
||||
|
||||
- `src/session_broker.{c,h}` owns the atomic non-consuming compact management projection, conditional writer assignment and non-reused client IDs. All serial mediation, event queues and data buffers remain broker-owned.
|
||||
- `src/web_broker_settings.{c,h}` owns HTTP authorization/parsing/encoding, one originating-login-bound operation/result slot and typed execution adapter.
|
||||
- `src/admin_ssh_console.{c,h}` queues only a numeric operation ID on the existing four-entry request queue. The same dispatcher executes the broker API outside its critical sections, never through a constructed command string.
|
||||
- `src/web_server.c`, `src/CMakeLists.txt` compose three optional routes and the new source.
|
||||
- `src/web_ui.c` supplies authored HTML/JS, native select/confirmation, bounded result polling and uncertainty recovery. Loader/CSP and generated/vendored assets are unchanged.
|
||||
|
||||
## Authoritative broker contract and wrap analysis
|
||||
|
||||
`session_broker_get_management_snapshot()` takes the existing mutex with **zero wait**, copying writer ID, lease generation and all active compact rows in one lock acquisition. It never reads UART payloads or pops events. Busy/uninitialized snapshots are unavailable, not empty-success snapshots. Pending/HWM/drop fields describe broker output, not peer receipt; counters cover the connection or last counter clear. Disconnected clients disappear; global historical accounting remains unchanged.
|
||||
|
||||
`session_broker_assign_writer_current(target, generation)` requires a nonzero target and generation. It shares the existing force-writer implementation, with the generation comparison and connected-target lookup **inside the same broker mutex, before any lease/counter/event effects**. Stale generations return `ESP_ERR_INVALID_STATE`; absent/reused targets return `ESP_ERR_NOT_FOUND` when the generation is otherwise current. Both map to a typed `conflict` result. Assignment to the already-current target is a no-op only after validation; UI disables this redundant action. There is no compare-unlock-force race.
|
||||
|
||||
The existing unconditional force/release APIs and transport request/release policy remain available. At most one connected client owns the lease; assignment makes the former writer an observer. Already-accepted serial TX bytes are not recalled or discarded. Events remain advisory: transport reconciliation still uses authoritative broker state.
|
||||
|
||||
Two independent stale-reference problems required small broker changes:
|
||||
|
||||
1. **Client IDs:** three slot bits plus 29 generation bits; generations previously wrapped from `536870911` to `1`. Free slots at their maximum generation are now skipped/retired until reboot. The final generation is still usable, including ID `UINT32_MAX` in slot 7. Exhausting all eight slots returns the existing `ESP_ERR_NO_MEM` without DTR/client publication. No previously-issued ID is reused within a boot; no wider transport protocol ID or per-client allocation is introduced. Reboot resets broker IDs but invalidates all originating web sessions, so an old authorized confirmation cannot span boots.
|
||||
2. **Lease confirmation:** a separate 32-bit generation starts at 1, survives counter clear and advances under the mutex for each granted/released/revoked event, **before attempting advisory delivery**. All current lease transitions emit these events; denied requests and same-writer no-ops do not advance it. A forced transfer can advance twice (revoke plus grant), so this is an opaque version, not a transition count. Release/reacquire by the same writer invalidates old confirmation (ABA). At `UINT32_MAX` it saturates permanently; management snapshots remain readable but conditional assignment rejects and UI disables assignment. Ordinary transport requests, release/disconnect, console force and UART0/USB recovery remain available. Counter/event-sequence wrap cannot resurrect a confirmation because neither is used as its token.
|
||||
|
||||
New observer connections/disconnections do not invalidate unrelated confirmations. The selected target's non-reused ID still prevents reassignment to its replacement; any writer disconnect changes the lease generation. The central grant/release/revoke emission invariant must be preserved by future lease mutations.
|
||||
|
||||
## HTTP and operation contract
|
||||
|
||||
All routes require current cookie/principal authentication and administrator role. Normal users cannot read management details/results or transfer. GET retains the established bodyless/queryless/header/Origin policy. POST additionally requires canonical same-origin Origin, CSRF and exact supported JSON Content-Type, with no query or transfer encoding. Responses use no-store/nosniff/no-referrer; scratch cleanup and unread-body close behavior follow Display.
|
||||
|
||||
| Route | Method | Bound / response |
|
||||
|---|---|---|
|
||||
| `/api/settings/broker` | GET | 2,048-byte response buffer; exactly `generation`, `writer`, `clients`. 503 when broker unavailable/busy. |
|
||||
| `/api/settings/broker-operation` | POST | Nonempty, at most 256 bytes/four receive calls. Exactly `action:"assign"`, `generation` (1–4294967294), `target` (1–4294967295). 202 means admitted, not executed. |
|
||||
| `/api/settings/broker-operation` | GET | 96-byte response buffer; exactly `id`, `action`, `state`, latest result for this originating login only. |
|
||||
|
||||
Each client row has exactly `id`, `type`, `name_hex`, `pending`, `high_water`, `dropped`. Type is the existing enum (0 Console, 1 USB, 2 Web, 3 SSH, 4 Internal). Names are at most 23 bytes encoded as at most 46 lowercase hex characters; browser decoding and DOM `textContent` avoid JSON/HTML injection. Invalid UTF-8 displays replacement characters, while IDs remain the authoritative identity. `dropped` is an unsigned decimal **string**, preserving all 64 bits in JavaScript. Writer/observer role is derived from the same snapshot's writer ID. No passwords, tickets, verifiers, private keys or UART bytes are included.
|
||||
|
||||
The narrow flat parser rejects duplicates, missing/unknown fields, escaped names/actions, strings in numeric fields, nested values, leading zeros, negatives, fractions, exponents, integer overflow, embedded NUL and trailing garbage. Exact 256-byte whitespace-padded requests are supported. Excessive fragmentation/receive errors fail rather than retry/drain indefinitely.
|
||||
|
||||
One static slot is shared across administrators, but results are isolated by non-reused **web-session ID**, not username. Another login sees `{id:0,action:"none",state:"idle"}`. Pending/full/not-ready dispatcher or exhausted operation IDs returns 503/Retry-After 1. IDs never wrap; completed results are replaceable, not durable history or idempotency records.
|
||||
|
||||
The dispatcher checks original session/principal/admin and a **30-second dequeue admission deadline** before calling the conditional broker API. No timer or hard cancellation is added: a blocked dispatcher can retain the bounded pending record beyond the deadline until dequeue. Work admitted before logout/expiry/stop may finish; no delivery guarantee is made. Auth stop/restart invalidates queued originating identities. Principal material is wiped at completion and local copies are wiped on exit.
|
||||
|
||||
States: `idle` (no retained result for this login, not proof of cancellation), `pending` (queued/executing), `ok` (assignment/no-op completed at execution time, not a future lease guarantee), `conflict` (stale/exhausted generation, absent target or uninitialized broker; no change by this operation), `failed` (other owner failure), `cancelled` (currentness/dequeue deadline denied before owner admission).
|
||||
|
||||
Optional registration orders snapshot → result GET → mutation POST. Failed snapshot/result registration prevents later routes; failed POST removes result GET, unless unregister itself fails, leaving reads only. Other settings, authentication, serial/admin transports and recovery survive optional failures. Budget: **30 → 33 URI handlers**, six sockets/no LRU unchanged.
|
||||
|
||||
## Browser behavior
|
||||
|
||||
- Admin-only full-page Settings navigation; no empty placeholder, contextual popover, icon redesign or new ordinary-user status detail.
|
||||
- No default selected target; no transfer on opening/selecting. Refresh always clears selection rather than silently renewing a previous confirmation.
|
||||
- Confirmation identifies target ID/type/name and snapshot writer ID, explains observer transfer and already-queued UART bytes. The exact selected target/generation is captured before asynchronous session revalidation and submission; concurrent changes are rejected at the broker lock.
|
||||
- One explicit POST, no automatic mutation retry. At most ten one-second result GETs and a 15-second overall automatic-check deadline, including delayed session work. Manual Check Result remains available after uncertainty.
|
||||
- Lost acknowledgement, another-tab result replacement, invalid response and navigation preserve explicit uncertain-outcome messaging. Known terminal results refresh the snapshot once; stale/conflict requires fresh selection/confirmation.
|
||||
- Navigation, pagehide, logout and expiry abort/fence stale browser work and clear selected identities/rows. This is not backend cancellation. Both terminals retain their existing hidden-output draining and selected-input policy.
|
||||
|
||||
## Validation actually run
|
||||
|
||||
All commands below passed in this implementation session with bounded terminal runtimes:
|
||||
|
||||
- `python3 tests/session_broker_diagnostics/run.py`: existing seven diagnostic groups plus two management/wrap groups, actual broker/console under deterministic RTOS/serial doubles. Covers atomic/non-consuming projection, zero-wait contention, USB/SSH/Web interleavings, exactly-one-writer transfer, stale/reused targets, release/reacquire ABA, counter clear, local force-release, writer disconnect, saturation and final client IDs.
|
||||
- `python3 tests/web_cookie_auth/run.py --broker`: six new groups plus shared auth/store/installed-IDF boundary tests; actual HTTP policy/parser/operation module with broker dependency double. Bounds, safe eight-row encoding, session-isolated results, queue failure/replay, stale-owner result mapping, currentness/deadline/expiry/revocation/stop-restart and lost acknowledgement.
|
||||
- `python3 tests/admin_console_boundary/run.py`: actual dispatcher, including new Broker ID routing, zero/full/not-ready admission, unchanged four-slot capacity and no command-runner invocation; existing certificate and SSH adapters also pass.
|
||||
- `python3 tests/web_admin_transport/server_lifecycle.py`: 25 groups, including six Broker descriptor/name allocation positions, failed result unregister, failed stop/retry/restart and unrelated route isolation. Orchestration uses HTTPD/TLS doubles, not actual TLS allocation faults.
|
||||
- `python3 tests/web_ui_session/run.py`: **119 browser groups** (eight new Broker groups) plus actual C renderer/HTML structure/shared styles/no-store/loader-CSP checks. Snapshot validation, safe names/64-bit counters, no view/selection mutation, confirmation/cancel, stale completion, polling bounds, lost/replaced results, navigation/401/pagehide/logout fences. No real browser geometry or target visual pass claimed.
|
||||
- Cookie variants `--display`, `--network`, `--accounts`, `--serial-settings`, `--settings`, `--admin`; canonical `tests/web_network_settings/run.py`; `tests/web_auth_parse/run.py` (294 cases); `tests/admin_console_boundary/accounts.py`, `lifecycle.py`; `tests/admin_ssh_policy/run.py`; `tests/web_serial_performance/run.py` (including 513 installed-SDK wire comparisons); `tests/web_httpd_idle/run.py`; `tests/web_admin_transport/run.py --tickets`; `tests/web_session_store/run.py --serial`; `tests/web_diagnostics/run.py`; `tests/web_login_ui/run.py`.
|
||||
- `git --no-pager diff --check` passed.
|
||||
|
||||
Tests initially caught and fixed a six-field Broker row incorrectly checked as seven fields. Harness integration corrections: static helper collision with extracted console code, HTTP test output bound, and lifecycle route-count expectations. No remaining known focused test failures. Self-review inspected broker transitions/ID generation, HTTP/session policy, optional registration and browser confirmation/result flow; subsequent independent review found no actionable findings (see status above).
|
||||
|
||||
### Build/resource evidence
|
||||
|
||||
Actual pre-edit `pio run`: PASS **7.14 s**, **100,196 B RAM / 1,765,233 B flash**. Post-integration build: PASS82.93s. Final confirmation after formatting/review: PASS21.20s, unchanged sizes. Final substantive-source build: PASS **20.54 s**, **100,300 B RAM / 1,782,613 B flash**: **+104 B RAM / +17,380 B flash** versus baseline, within the existing 4 MiB application partition. Authored response sizes: HTML28,929 bytes; app JS109,731 bytes after copy-text cleanup (not generated assets). Existing nonfatal SDK `FATFS_PRINT_FLOAT` bool-default warning occurred in the integration build; no SDK/config workaround applied.
|
||||
|
||||
CPU160 verified in `sdkconfig.defaults`, active `sdkconfig.esp32-s3-devkitc-1-n16r8` and generated `config/sdkconfig.h`; PlatformIO's generic board banner still says240MHz and is not the configured frequency. No CPU/scheduling/throughput tuning was performed.
|
||||
|
||||
Static image delta excludes runtime URI allocation overhead and peak nested stack usage. Snapshot handler uses bounded 2,048-byte response plus compact eight-row snapshot and auth locals on the existing HTTPD stack; operation admission uses256-byte request/96-byte result buffers and one slot, no payload heap allocation. HTTPD/dispatcher live margins, full-mix internal/DMA/PSRAM minima and scheduling overhead are **unmeasured**. A build pass does not establish safe runtime reserves.
|
||||
|
||||
## Pending target checklist — not executed
|
||||
|
||||
- [ ] Compare admin Broker rows with UART0 broker snapshots/counters for zero, one and full supported client mix; verify ID/type/name/role/pending/HWM/drop semantics without consuming serial data. Normal user direct GET/POST requests must be denied.
|
||||
- [ ] Open Settings/Broker, select/cancel/Refresh, switch terminals/views and confirm no lease/socket change; exercise keyboard/touch selection and native confirmation on narrow/wide screens.
|
||||
- [ ] Assign among USB, role-user SSH and both web serial clients while both admin routes remain open. Verify exactly one writer, observers still receive binary output, former writer input is rejected, no in-band controls and already-accepted TX semantics.
|
||||
- [ ] Hold confirmation while target disconnects/reconnects; confirm old target fails without changing the current writer. Repeat with competing USB/SSH/browser writer requests, admin-shell force, local expected-writer release and same-writer release/reacquire. Refresh/reselect/confirm explicitly after conflict.
|
||||
- [ ] Test two independent admin logins and two tabs on one login: pending capacity, isolated/latest replaced results, lost POST/result response, bounded polling/manual recovery and no duplicate transfer. Logout/expiry/role revoke/HTTPS stop-restart while queued must reject stale admission; already-admitted work may finish.
|
||||
- [ ] With full mix at signed-off160MHz/230400 baud, repeat settled non-consuming throughput captures and verify binary transparency/drop isolation. Measure boot/full-mix/operation internal/DMA/PSRAM free/minimum/largest blocks and HTTPD/dispatcher stack minima, including maximum rows and concurrent HTTPS load.
|
||||
- [ ] Validate optional route allocation failures and failed stop/restart where a safe target fault-injection mechanism is available; unavailable settings must not remove UART0 or native USB recovery. Host failure injection is not target proof.
|
||||
- [ ] Record exact revision/build/client mix, observations and limits; independent review is complete, but explicit user 8D.16 sign-off remains required. No erase or identity/configuration migration is required.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Phase 8D.17 — Serial / Wi-Fi quick popovers
|
||||
|
||||
## Status and scope (2026-09-13)
|
||||
|
||||
User-authorized implementation complete; host tests and firmware build verified. **Independent review complete; target/user sign-off pending.** No M3 or memory-reserve approval implied. Initial `git --no-optional-locks status --short` was empty.
|
||||
|
||||
Only authored `src/web_ui.c`, UI regressions and phase/agent documentation changed. No backend/API, handler budget, dispatcher, task, stack, queue, schema, dependency, icon, generated asset, SDK or configuration change. No 8D.18 client/writer dialogs or 8D.15 diagnostics. Broker8D.16/Display behavior and the signed-off combined binary WS send at CPU160 are preserved; no upload, erase, commit or branch creation.
|
||||
|
||||
## Independent-review P2 correction (2026-09-13)
|
||||
|
||||
Reviewer identified that `closeQuick(false)` restored terminal selection through an unconditional xterm focus, stealing keyboard focus during adjacent-trigger switching and timed departure to an outside control. `selectTerminal` now has an independent focus option (default unchanged for ordinary navigation); quick dismissal restores selection/input state with terminal focus suppressed. Automatic dismissal/switching preserves focus. Escape/Close deliberately focus only the originating trigger, never transiently xterm.
|
||||
|
||||
`Terminal.focus()` in the Node harness now updates activeElement and counts calls. New adjacent Wi-Fi→Serial→Wi-Fi keyboard regression reproduced the failure before the fix. Two added groups cover that switch, timed outside-focus and outside-pointer dismissal for both Serial/Admin backgrounds, and Escape/Close trigger-only focus. Final `python3 tests/web_ui_session/run.py` PASS **128 groups plus renderer/HTML/CSP**; `pio run` PASS21.90s, **100,300 B RAM / 1,788,661 B flash** (+0/+32 B versus initial8D.17 build; +0/+6,048 B versus phase baseline); `git --no-pager diff --check` PASS. Earlier126-group/build results below are pre-review history. Existing uncommitted phase work preserved. Independent reviewer confirmed the P2 resolved with no new actionable issue and independently reran128 UI groups+renderer/HTML/CSP and diff checks PASS. Parent final build confirmation PASS6.98s,100,300 B RAM/1,788,661 B flash; diff check PASS. Target sign-off remains pending; geometry not rerun, prior sandbox limitation stands.
|
||||
|
||||
## UI and ownership contract
|
||||
|
||||
- Existing Wi-Fi/Serial status values are native labeled buttons, initially disabled and enabled only after validated admin session adoption. Ordinary users retain status text but cannot open settings through them; server authorization is unchanged.
|
||||
- One existing `serial-settings` DOM host, never cloned or simultaneously mounted. Serial uses the completed framing/lifecycle/persistence form. Wi-Fi uses the completed AP/boot, four STA-profile, connection and mDNS controls, **excluding password action/input/status from the quick presentation**. No new settings fields or endpoint semantics.
|
||||
- The same snapshot validators, SSID UTF-8/hex conversion, edit validation, confirmations, single-flight request controllers, generation/currentness checks, operation IDs and bounded result polling serve full and quick views. Network fetch remains the strict secret-free typed snapshot, never a saved-credentials fetch. Password controls are hidden and disabled in quick mode; transient input is cleared on entry, navigation and promotion, with keep-existing semantics. No saved PSK can be returned/prefilled by these endpoints.
|
||||
- Mouse hover or keyboard focus opens without mutation. Click/native Enter/Space/tap enters the same content and focuses Close. Touch pointer-enter does not open preemptively. Repeated activation does not refetch/reset the current quick form. Opening performs session validation and one existing domain GET, not an operation POST or operation-result GET.
|
||||
- Nonmodal dialog semantics, accessible name, `aria-controls`, `aria-haspopup=dialog`, `aria-expanded`, visible focus outline, native controls and explicit Close/full-page link. No focus trap. Focused content resists unrelated hover; switching by explicit activation remains available.
|
||||
- A single 250ms pointer/focus departure timer bridges trigger-to-content movement. Departure closes only if neither region is hovered and focus is outside both. Escape closes and restores trigger focus; automatic reopening of that dismissed trigger is suppressed until departure, while explicit activation can reopen. Outside pointer-down closes without stealing focus back to the trigger.
|
||||
- One fixed top-right panel, 8px viewport inset, at most 600px wide and viewport-minus16px tall (dynamic viewport height with fallback), internally scrollable. No icons were changed. Geometry fixture checks are implemented but **did not run successfully in this sandbox** (see below).
|
||||
- Full-page link promotes the **same** host/controller and draft, without refetch, POST, result replay or poll cancellation. Password transient state is cleared. Close/switch instead clears drafts and aborts/fences reads and browser automatic checks; admitted backend work is not cancelled. Pending/unknown outcome gates remain retained for explicit Check Result on return; opening never resumes polling or replays a mutation.
|
||||
- When a full Settings page already owns a draft, hover/focus on a status trigger does nothing. Explicit click/Enter/tap navigates the existing full page to that domain and focuses its navigation control. This deliberate exception prevents hover from discarding a full-page draft or introducing a second DOM owner.
|
||||
- The previous Serial/Admin terminal remains visible behind the quick panel and continues receiving. Logical Settings selection disables both terminal input routes while quick content is open. Close restores the previous terminal selection; no socket, broker-client identity, lease or binary protocol change. Session revalidation also respects this visible-background selection. Expiry/logout/pagehide use existing clear/fence/close semantics; no secret or mutation replay on restore.
|
||||
- Apply changes RAM; Save persists device working values, not unsubmitted browser drafts. Existing Serial discard warnings, Network connection-loss confirmations/recovery guidance and uncertain-result handling remain visible. Network profile selection is only editing, never connection selection or mutation. Actual explicit Network operations can disrupt network clients; UART0 administration and native USB UART1 remain independent recovery paths.
|
||||
|
||||
## Validation executed
|
||||
|
||||
All commands from the repository root, bounded at 120 seconds per tool invocation (geometry at 60 seconds, browser subprocess at 30 seconds). All below passed, except the explicitly failed optional geometry attempt:
|
||||
|
||||
```sh
|
||||
python3 tests/web_ui_session/run.py
|
||||
python3 tests/web_cookie_auth/run.py
|
||||
python3 tests/web_cookie_auth/run.py --settings
|
||||
python3 tests/web_cookie_auth/run.py --serial-settings
|
||||
python3 tests/web_cookie_auth/run.py --accounts
|
||||
python3 tests/web_cookie_auth/run.py --admin
|
||||
python3 tests/web_cookie_auth/run.py --network
|
||||
python3 tests/web_cookie_auth/run.py --display
|
||||
python3 tests/web_cookie_auth/run.py --broker
|
||||
python3 tests/web_admin_transport/server_lifecycle.py
|
||||
python3 tests/admin_console_boundary/run.py
|
||||
python3 tests/admin_console_boundary/accounts.py
|
||||
python3 tests/admin_console_boundary/lifecycle.py
|
||||
python3 tests/admin_ssh_policy/run.py
|
||||
python3 tests/web_network_settings/run.py
|
||||
python3 tests/session_broker_diagnostics/run.py
|
||||
python3 tests/web_auth_parse/run.py
|
||||
python3 tests/web_session_store/run.py
|
||||
python3 tests/web_session_store/run.py --serial
|
||||
python3 tests/web_admin_transport/run.py --tickets
|
||||
python3 tests/web_httpd_idle/run.py
|
||||
python3 tests/web_diagnostics/run.py
|
||||
python3 tests/web_serial_performance/run.py
|
||||
python3 tests/web_security/run.py
|
||||
python3 tests/web_login_ui/run.py
|
||||
pio run
|
||||
git --no-pager diff --check
|
||||
```
|
||||
|
||||
Final UI: **126 behavior groups** (119 existing +7 quick-specific) executing production C-rendered JS, plus C response/header/failure, parsed HTML/labels/unique ownership and exact unchanged inline-loader CSP hash checks. New focus/containment doubles exercise hover/touch activation, stationary-pointer Escape suppression, input gating/binary drains, outside dismissal and delayed snapshot fencing, pagehide, full-page draft/promotion ownership, actual explicit Apply and Save bodies, pending-result non-replay, strict Network schema/secret exclusion and endpoint401. Full-page regression suites exercise the shared controllers' remaining validation/result/identity cases. These are deterministic DOM/fetch/clock/socket doubles, not real browser event dispatch, radio, NVS or target timing proof.
|
||||
|
||||
During test development, a new Serial acknowledgement fixture incorrectly included Network's fourth `error` field; strict production validation correctly rejected it, making the promotion-poll assertion fail. Fixed both new Serial fixtures to the existing exact three-field schema and reran the suite/build successfully. Self-review also added focused-editor hover protection and stationary-pointer Escape suppression; this is not independent review.
|
||||
|
||||
Optional real geometry attempted:
|
||||
|
||||
```sh
|
||||
WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py
|
||||
```
|
||||
|
||||
**Unavailable, not passed:** Chromium failed before measurement with read-only `~/.config/chromium/Crash Reports/settings.dat` and fatal process-singleton `socket() failed: Operation not permitted`; ptrace also denied. No sandbox escape or installed dependency change attempted. The optional fixture now covers 18 cases: existing four full settings views at320/600/1200px plus Serial/Wi-Fi quick views at those widths and360px height; checks panel bounds/overflow, scrolling and password/navigation exclusion. It strips the authenticated loader/assets and uses fixture content: even a future pass is not live-app interaction or target visual sign-off.
|
||||
|
||||
## Build/resources
|
||||
|
||||
| Measurement | RAM | Flash | Time |
|
||||
|---|---:|---:|---:|
|
||||
| Actual pre-edit baseline `pio run` | 100,300 B | 1,782,613 B | 6.88 s |
|
||||
| Final substantive source `pio run` | 100,300 B | 1,788,629 B | 21.74 s |
|
||||
| Delta | **0 B** | **+6,016 B** | incremental build times not comparable |
|
||||
|
||||
CPU160 independently confirmed in tracked defaults, existing active sdkconfig and generated sdkconfig.h. PlatformIO's board banner says240MHz but does not override those actual settings. No new firmware tasks, buffers or endpoint handlers:33 handlers/six sockets retained. Browser costs are bounded quick state, listeners and one250ms departure timer; settings snapshots/controllers are reused. No runtime heap, HTTPD/dispatcher stack, responsiveness, transient reserve or loaded-client measurement was performed. Existing full-mix throughput acceptance is not reopened or expanded.
|
||||
|
||||
## Pending target / parent checklist
|
||||
|
||||
- [x] Independent code/test review and P2 fix confirmation; no remaining actionable findings.
|
||||
- [ ] Desktop mouse: enter trigger, cross to scrollable content, move between fields, leave both; no flicker/refetch/draft loss. Escape while pointer remains stationary stays dismissed; Close and explicit reopen work.
|
||||
- [ ] Keyboard: Tab-focus disclosure, Enter/Space activation, all fields/actions/full-page link reachable, Shift-Tab departure, Escape focus return and no serial/admin key leakage. Screen reader names/expanded/nonmodal semantics verified.
|
||||
- [ ] Touch: tap both triggers, edit/select without unintended submission, scroll panel, outside-tap/Close, rotate/resize/zoom/software keyboard at narrow/short viewports; no inaccessible controls or horizontal overflow. Run optional geometry where Chromium can start.
|
||||
- [ ] Full-page draft (including transient Network password) untouched by mere trigger hover/focus; explicit navigation clears old context. Quick-to-full preserves only intended nonsecret draft/controller state.
|
||||
- [ ] Compare quick/full/CLI Serial settings and explicit Apply/Save; no Save of browser-only drafts. Reboot persistence and serial reconfiguration discard semantics verified.
|
||||
- [ ] AP/four STA profiles/SSID hex and UTF-8, omitted PSK preservation, explicit connection confirmations, accepted-versus-online distinction, mDNS and explicit persistence. No secret shown or cached; recover after actual network loss via STA/AP, UART0/native USB.
|
||||
- [ ] Delayed/lost acknowledgement, stale snapshot, concurrent CLI/tab edits, dismissed pending operation, Check Result/Refresh recovery; no opening/selection/restore replay. Session expiry, role change, logout, bfcache and late response isolation.
|
||||
- [ ] Both browser terminals plus physical USB/SSH/full mix at signed-off160MHz/230400 baud: sockets/lease survive UI actions and bounded output continues; capture drop/latency/heap/HTTPD-dispatcher margins without claiming new reserve approval.
|
||||
|
||||
No target checklist item was executed or signed off by this implementation.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Phase 8D.18 — Client/writer contextual dialogs
|
||||
|
||||
## Status and scope (2026-09-13)
|
||||
|
||||
User-authorized 8D.18 implementation complete; host tests and bounded firmware builds pass. **Independent review complete with no confirmed actionable defects; target/user sign-off remains pending.** Reviewer independently reran135 UI groups+renderer/HTML/CSP, canonical broker diagnostics/management/wrap, cookie Broker and diff checks PASS. Parent final build confirmation PASS6.89s,100,300 B RAM/1,793,301 B flash; diff check PASS. Native picker behavior during live option updates and real blank-to-target reselection remain target checks, not established by direct Node change events. No M3 or memory-reserve approval. Initial read-only Git status was empty; no preexisting work was removed.
|
||||
|
||||
Only `src/web_ui.c`, `tests/web_ui_session/{broker.cjs,layout.py}` and phase/agent documentation changed. No backend, writer policy, API, dispatcher, task, queue, stack-size, schema, dependency, icon, generated asset, SDK or configuration changes. No 8D.19/later or removed 8D.15 diagnostics. No upload, erase, commit or branch creation. The signed-off CPU160 combined binary WebSocket send remains untouched.
|
||||
|
||||
## Implementation plan and completed source set
|
||||
|
||||
1. Read code-map, current-state, relevant architecture/design decisions and exact 8D.18 row; inspect 8D.17's single quick host/focus correction and 8D.16's typed Broker controller and lease-fence contract.
|
||||
2. Establish clean-worktree/bounded-build baseline. Reuse the existing Broker snapshot, session validation and operation controller, not a second backend or UI owner.
|
||||
3. Extend the existing status-trigger/nonmodal quick host to Broker clients and Active writer. Preserve terminal receive/input isolation, dismissal focus semantics and full-page draft ownership.
|
||||
4. Introduce one explicit selection record and single-flight, deadline-bounded live snapshot refresh. Never renew selection tokens implicitly; latch invalidation and retain visible stale-result/uncertainty messages.
|
||||
5. Extend emitted-production-JS, parsed HTML and existing broker/auth/lifecycle regressions; build, verify CPU160 and record exact limits and pending target checks.
|
||||
|
||||
All five steps completed. Shared firmware APIs already supplied the required correctness boundary; no owner adaptation was necessary.
|
||||
|
||||
## UI and ownership contract
|
||||
|
||||
- Broker clients and Active writer status values are native labeled buttons, initially disabled, enabled only after validated admin session adoption. Ordinary users retain ordinary status values, cannot open management via hover/focus/click, and receive unchanged server-side denial on protected endpoints. No management read is issued for ordinary users.
|
||||
- Both contextual entrances disclose the **same existing Broker form** in the sole `serial-settings` host. They have distinct live-client/writer accessible titles, share the client list/selection/explicit assignment controls, and use the existing browser-native confirmation dialog for the actual transfer. No second draft, client list, operation slot or DOM clone.
|
||||
- Mouse hover and keyboard focus disclose without mutation; native click/Enter/Space/tap activation focuses Close. Touch pointer-enter alone does not disclose. `aria-controls`, `aria-expanded`, `aria-haspopup=dialog`, accessible names, visible focus, nonmodal dialog semantics, native select/actions, Close and full-page link remain available. No focus trap.
|
||||
- The 8D.17 250ms pointer/focus bridge, focused-editor protection, Escape/outside dismissal and stationary-pointer suppression apply to both new triggers. Dismissal/adjacent-trigger switching restores logical terminal selection without calling xterm focus; Close/Escape focus only the trigger. Both terminals continue receiving bounded binary-transparent output, with input disabled while the settings host is selected. No socket/client/lease mutation on UI navigation.
|
||||
- Full-page Settings retains its sole DOM/draft owner. **Neither hover, focus nor explicit activation of the new Broker/writer triggers changes a full-page domain or its draft**, including transient account/Network password input. Explicit activation focuses the existing domain navigation; trigger help explains using Broker navigation or returning to a terminal first. Existing Serial/Wi-Fi explicit full-page navigation is unchanged. Switching between quick views is deliberate context departure and retains existing draft cleanup rules.
|
||||
- Promotion through Open full settings page retains the same Broker selection/controller without refetch, abort, POST or replay; it stops contextual live scheduling. Full Broker remains manually refreshed. Closing/switching clears selection, aborts/fences browser reads/results and stops timers, but never claims cancellation of admitted firmware work.
|
||||
- Periodic button state updates use `aria-disabled` and preserve a currently focused control instead of natively disabling it and dropping keyboard focus. Nonfocused unavailable controls remain natively disabled. Independent request/action guards enforce availability, including clicks delivered to an aria-disabled focused button. Stale assignment attempts display a rejection without a POST.
|
||||
|
||||
## Live refresh and selection fences
|
||||
|
||||
- Immediate opening read, then **one next snapshot refresh 5 seconds after successful completion**, only while a Broker contextual view is open. Each cycle revalidates the current login, then uses the existing bodyless GET `/api/settings/broker` (strict 2048-byte/eight-client schema). This is not an additional aggregate status backend.
|
||||
- At most one active Broker snapshot/operation controller. Manual reads, repeated opening and result/assignment work cannot overlap that controller. A **5-second whole snapshot-cycle deadline** covers session validation, fetch and body handling; timeout aborts, clears controller ownership and invalidates selection. The deadline is cleared on completion/abort. Error/timeout stops automatic live refresh until explicit Refresh/reopen; no automatic error retry.
|
||||
- No timer while an operation/result is active or its outcome is pending/unknown. Known terminal operation results use the existing refresh path. Polling frequency is not a hard wall-clock freshness guarantee: background timer throttling, HTTPD and network delays remain possible.
|
||||
- Explicit select change captures target ID, type/name identity and the current opaque lease generation. Reorder/counter-only refresh retains that choice and token. A lease version or identity mismatch latches `stale`; a failed/invalid/timed-out snapshot also latches it. Later matching data cannot clear the latch. A missing target clears the displayed select and latches absence so its later appearance cannot silently reselect it.
|
||||
- Refresh never authorizes a new transfer. The admin must deliberately choose the blank option then the target again to reselect an otherwise still-displayed stale target. Assignment stays blocked for missing/stale/exhausted/current-writer targets. Submission consumes validity; operation completion refresh cannot re-arm it.
|
||||
- Confirmation captures exact target/version **before** asynchronous session revalidation and submits once through `/api/settings/broker-operation`. Concurrent real lease changes after the browser snapshot are rejected by the unchanged atomic 8D.16 broker fence. Existing conflict result text visibly states no lease change by that operation. No check-unlock-force, lease-policy change or automatic mutation retry.
|
||||
- Existing bounded result checks remain at most 10 one-second GETs/15 seconds; lost ACK, replacement, invalid response, expiry and late responses retain uncertainty and explicit Check Result recovery. Browser abort is not server cancellation, successful execution is not a guarantee the writer remains current, and latest login-bound results are not durable/idempotent records.
|
||||
- Session/work generation, request-controller identity and API abort/current checks fence late responses, including late 401 after navigation. Expiry/logout/pagehide invalidate views and close both routes through existing cleanup. New views cannot adopt stale selection/snapshot/result state.
|
||||
|
||||
## Executed validation
|
||||
|
||||
All commands below passed, bounded at 120 seconds per tool invocation; none timed out. Broad suites ran after the initial implementation; the final focused-control refinement was followed by another complete UI suite and firmware build.
|
||||
|
||||
```sh
|
||||
python3 tests/web_ui_session/run.py
|
||||
python3 tests/session_broker_diagnostics/run.py
|
||||
python3 tests/web_cookie_auth/run.py --broker
|
||||
python3 tests/admin_console_boundary/run.py
|
||||
python3 tests/web_admin_transport/server_lifecycle.py
|
||||
python3 tests/web_cookie_auth/run.py
|
||||
python3 tests/web_cookie_auth/run.py --settings
|
||||
python3 tests/web_cookie_auth/run.py --serial-settings
|
||||
python3 tests/web_cookie_auth/run.py --accounts
|
||||
python3 tests/web_cookie_auth/run.py --admin
|
||||
python3 tests/web_cookie_auth/run.py --network
|
||||
python3 tests/web_cookie_auth/run.py --display
|
||||
python3 tests/web_auth_parse/run.py
|
||||
python3 tests/web_session_store/run.py
|
||||
python3 tests/web_session_store/run.py --serial
|
||||
python3 tests/admin_console_boundary/accounts.py
|
||||
python3 tests/admin_console_boundary/lifecycle.py
|
||||
python3 tests/admin_ssh_policy/run.py
|
||||
python3 tests/web_network_settings/run.py
|
||||
python3 tests/web_admin_transport/run.py --tickets
|
||||
python3 tests/web_httpd_idle/run.py
|
||||
python3 tests/web_diagnostics/run.py
|
||||
python3 tests/web_serial_performance/run.py
|
||||
python3 tests/web_security/run.py
|
||||
python3 tests/web_login_ui/run.py
|
||||
pio run
|
||||
git --no-pager diff --check
|
||||
```
|
||||
|
||||
Final UI **135 behavior groups** (128 baseline +7 contextual groups), plus actual C rendering/header/error checks, parsed unique HTML ownership/native labels and exact unchanged inline-loader CSP. New groups cover:
|
||||
|
||||
- ordinary/admin pointer/focus/touch disclosure, explicit close/Escape/outside focus, Serial/Admin receive and input isolation;
|
||||
- deferred single-flight reads, reordered/counter-updated clients, preserved explicit choice, stale lease/identity latches and missing/returning target non-resurrection;
|
||||
- focused action/select accessibility during refresh and stale rejection without focus theft or POST;
|
||||
- malformed/503 snapshots, stalled session/snapshot deadlines, explicit recovery and ignored late snapshot data;
|
||||
- full-page Serial/account/Network drafts, adjacent Serial→writer keyboard focus and promotion without reads/reset;
|
||||
- captured confirmation through delayed session validation, visible conflict, lost acknowledgement, dismissal/reopen without replay;
|
||||
- late read/POST/result401 after adjacent quick navigation, current expiry/pagehide/principal replacement/401 cleanup.
|
||||
|
||||
Existing canonical broker management/wrap tests verify real lease ABA/target-generation rejection; cookie Broker tests verify admin/session/Origin/CSRF/bounds/result isolation; lifecycle25 and unchanged dispatcher capacity tests pass. Parser294, transports/tickets, session binding, Network/accounts, idle, diagnostics, combined-send performance, security and login regressions also pass.
|
||||
|
||||
Test-development failures were corrected rather than hidden: one old assertion expected refresh to clear the choice; new refresh fixtures initially fired ordinary status's 5-second interval instead of the dedicated one-shot, and a delayed session fixture used the wrong username. The final harness explicitly distinguishes interval versus one-shot timers and uses the actual same principal. Production strict identity rejection was correct.
|
||||
|
||||
**Limits:** Node uses deterministic DOM/fetch/clock/socket doubles, not real native browser keyboard event defaults, focus engine, screen reader, mobile picker or touch hardware. Parsed native button semantics plus exercised click/focus/keydown/pointer paths do not establish real-browser accessibility sign-off. No optional Chromium geometry run in this phase; 8D.17 recorded sandbox launch restrictions. No on-device concurrency, actual TLS timing, heap/reserve, HTTPD/dispatcher stack or full-mix target measurement. These results do not reopen or expand the signed-off CPU160 throughput evidence.
|
||||
|
||||
## Build and resources
|
||||
|
||||
| Build | RAM | Flash | Time |
|
||||
|---|---:|---:|---:|
|
||||
| Actual pre-edit baseline | 100,300 B | 1,788,661 B | 7.02 s |
|
||||
| Initial implementation | 100,300 B | 1,793,077 B | 21.99 s |
|
||||
| Final focused-control refinement | 100,300 B | 1,793,301 B | 21.52 s |
|
||||
| Final delta from baseline | **0 B** | **+4,640 B** | incremental times not comparable |
|
||||
|
||||
CPU160 verified by targeted inspection of `sdkconfig.defaults`, active `sdkconfig.esp32-s3-devkitc-1-n16r8` and generated `.pio/build/esp32-s3-devkitc-1-n16r8/config/sdkconfig.h`. PlatformIO's generic board banner still says240MHz; actual config says160. No edits to these files. 33 handlers/six sockets, existing tasks/stacks/queue capacity and broker/WebSocket buffers are unchanged. Browser additions are one bounded selection record, one live scheduling timer or active read deadline, and two trigger listener sets; existing rows/controllers are reused. Regular live reads add HTTPS load while shown; runtime headroom remains unmeasured.
|
||||
|
||||
## Pending parent and target checklist
|
||||
|
||||
- [x] Independent review of source, tests, ownership, stale selection and request/focus fencing; no confirmed actionable defects.
|
||||
- [ ] Desktop mouse hover bridge, scrollable client list, adjacent triggers, outside dismissal and no unwanted reopening after Escape.
|
||||
- [ ] Keyboard Tab/Shift-Tab, native Enter/Space, select navigation, blank/reselect, focused action refresh/stale invalidation, native confirmation Cancel/Accept and Escape; no terminal key leakage. Screen-reader titles/expanded/aria-disabled/status announcements.
|
||||
- [ ] Touch tap/scroll/select/confirm/outside tap, rotation/zoom/software keyboard and narrow/short viewport geometry; real native-picker behavior during refresh.
|
||||
- [ ] Every full settings draft survives Broker/writer activation; sole-host promotion and Serial/Wi-Fi quick switching behave as documented.
|
||||
- [ ] USB/SSH/two-browser clients join/leave/reorder; concurrent shell/tab writer changes and same-writer release/reacquire reject stale confirmation visibly; explicit reselection does not assign until separately confirmed.
|
||||
- [ ] Delayed/lost snapshot/ACK/results, actual network loss, expiry/logout/bfcache and identity replacement: no late state restoration, mutation replay or cancellation claims; Check Result recovery remains available.
|
||||
- [ ] Both browser terminals continue receiving with UI open; correct input focus after dismissal; physical UART0 administration/native USB recovery remain usable.
|
||||
- [ ] Full mix at signed-off160MHz/230400 baud with live refresh: capture drops/latency/heap and HTTPD/dispatcher margins, without inferring numeric reserve approval.
|
||||
|
||||
No target checklist item or independent-review item was signed off by this implementation. Stop before 8D.19 or any other separately authorized phase.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Phase 8D.19 — first service slice: SSH ordinary controls
|
||||
|
||||
Date: 2026-09-13. User-authorized implementation; **SSH slice implemented, host-tested and build-verified. Independent review complete with no confirmed actionable defects; target sign-off pending. Phase 8D.19 as a whole remains incomplete.**
|
||||
|
||||
Independent reviewer reran canonical SSH4, cookie SSH6+shared, dispatcher, lifecycle27, UI143+renderer/HTML/CSP and diff checks, all PASS. Parent final `pio run` confirmation PASS19.95s at100,404 B RAM/1,808,441 B flash; diff check PASS. Host doubles do not prove actual concurrent SSH cleanup, physical HTTPS/USB continuity or runtime margins.
|
||||
|
||||
## Scope decision made before editing
|
||||
|
||||
Read `docs/agent/{code-map,architecture,design-decisions,current-state}.md`, the exact 8D.19 row/work-unit rules, SSH public APIs/owner implementation, canonical `ssh_console` and parsed browser-shell restrictions. Initial worktree was clean. Baseline `pio run` passed in 6.87 s, 100,300 B linked RAM / 1,793,301 B flash.
|
||||
|
||||
The plan explicitly requires a service split when multiple owner adaptations are necessary. Selected one coherent, end-to-end service: **SSH status, SSH start, SSH stop, and disconnect exactly one SSH session**. SSH has one existing owner task and two fixed session slots; these operations do not terminate the invoking HTTPS session. Allowed source set: `ssh_transport.{c,h}`, narrow `web_ssh_settings.{c,h}`, `admin_ssh_console.{c,h}`, `web_server.c`, `web_ui.c`, and its CMake source entry. Related host tests and agent/phase contracts are part of this deliverable.
|
||||
|
||||
Explicit exclusions:
|
||||
|
||||
- No WebSocket/browser-admin/HTTPS-cookie disconnection, web-server stop/restart, or all-web/account revocation controls. An arbitrary broker ID is **not** a transport disconnect handle.
|
||||
- No USB start/stop/disconnect; preserve DTR/attach ownership and native network-independent UART1 access.
|
||||
- No new serial-service controls (already in 8D.9), Wi-Fi controls, reboot, certificate/SSH host-key rotation/reset, secret fetch, or 8D.20/8D.21 work.
|
||||
- No shell-policy broadening. Canonical browser shell still rejects `ssh stop` and `ssh disconnect`: its SSH-specific deferral is not a WEB-owner action. Typed SSH requests have a separate, narrow current-admin admission boundary and reuse canonical SSH lifecycle/owner close semantics, not command strings.
|
||||
- No new task, timer, generic runner, queue depth, stack size, socket/session capacity, dependencies, SDK/configuration or generated assets. No commit/upload/erase.
|
||||
|
||||
Remaining 8D.19: separately audit and select a web-transport slice for **other**, non-invoking sessions if useful. It must identify the precise originating login/transport, exclude invoking-session effects, and retain HTTPD-owner fd/epoch safety. USB actions are not promised: first establish whether its actual public ownership/API model permits any useful non-recovery-disrupting control. Do not expose generic broker disconnects or claim all-service parity. Self-affecting HTTPS/Wi-Fi actions remain 8D.20, identities 8D.21.
|
||||
|
||||
## HTTP contract and bounded dispatcher
|
||||
|
||||
Three optional exact handlers (36 total, previously 33):
|
||||
|
||||
| Route | Contract |
|
||||
|---|---|
|
||||
| `GET /api/settings/ssh` | Current cookie/admin, bodyless/queryless secret-free published status. |
|
||||
| `POST /api/settings/ssh-operation` | Current cookie/admin + canonical Origin/CSRF JSON policy; admits exactly one typed action. |
|
||||
| `GET /api/settings/ssh-operation` | Latest retained result for this login only; bodyless/queryless current-admin read. |
|
||||
|
||||
Snapshot is `{generation, running, transitioning, sessions}`. At most two rows `{id, state, route, name_hex, closing}`. `state`: 1 handshake, 2 active, 3 closing. `route`: 0 not yet routed, 1 broker serial, 2 admin console. Names are at most 16 bytes encoded as lowercase hex; empty until a principal is published. No socket fd, principal/account IDs, authentication material, keys, password, ticket or peer data is serialized. `transitioning` also gates incomplete cleanup; use canonical shell recovery rather than guessing that a failed cleanup is a clean stop.
|
||||
|
||||
Mutation body has **exactly** `{action, generation, target}`:
|
||||
|
||||
- `start`, `stop`: `target:0`, nonzero nonsaturated observed service generation.
|
||||
- `disconnect`: nonzero SSH session ID, observed service generation. Not a broker client ID, account ID, fd, console slot or browser session ID.
|
||||
- Strict decimal uint32 values; no unknown/duplicate fields, escapes, exponents/fractions, leading-zero integers, nesting or additional service/action selector. 256 bytes and four receives maximum.
|
||||
|
||||
HTTPD authenticates, parses and publishes one fixed login-bound slot, then submits **only a non-reused uint32 operation ID** with zero wait to the existing four-entry dispatcher queue. No HTTPD lifecycle work or owner wait. Pending slot/queue exhaustion rejects with 503; optional route allocation failure preserves other settings, authentication and both browser transports. Mutation is never registered without both SSH read endpoints; POST registration failure removes the result GET where possible, with failed unregister retaining reads only.
|
||||
|
||||
Dispatcher revalidates initiating session/principal/current admin and 30-second dequeue deadline before the conditional SSH API. It executes outside console/HTTPD/operation locks. A pending operation is not replaced by another login/tab. Terminal results are replaceable, not a durable history or idempotency key. Completed principal scratch is wiped; IDs saturate rather than wrap. There is no expiry timer: stale queued work occupies its single slot until the dispatcher dequeues it. Already admitted lifecycle/close work can finish after logout/revocation. Authentication stop/restart invalidates old initiating sessions; queued old IDs cannot act for a new login.
|
||||
|
||||
96-byte result `{id, action, state}` uses `idle`, `pending`, `ok`, `failed`, `cancelled`, `conflict`. Other logins see idle, never another login's result. Invalid current service/absent target gives conflict; owner errors/timeouts give failed. **Failed or lost acknowledgement is not proof of cancellation.** Successful disconnect means an owner-qualified close request was published, not that cleanup/peer closure or response delivery is complete. Start/stop use canonical lifecycle completion/error semantics.
|
||||
|
||||
## SSH owner/lifecycle safety
|
||||
|
||||
`ssh_transport_get_management_snapshot()` copies published rows and lifecycle state under the existing short portMUX; it does not call wolfSSH, scan task stacks, acquire the command mutex or inspect owner-task slots. It includes externally requested close intent even before owner consumption. It returns unavailable before successful initialization; optional SSH failure does not disable HTTPS.
|
||||
|
||||
`ssh_transport_manage_current()` acquires the existing command mutex **with zero wait**. It checks initialized/nontransitioning/clean state and service generation under the SSH lock. For disconnect it checks the active nonclosing published session and sets the existing per-slot `s_external_close_id` under that same lock. For lifecycle it retains the command mutex through comparison and canonical `request_running_locked()` admission/wait. CLI and host-key lifecycle wrappers already use this mutex. No snapshot-check-unlock-unconditional-stop race against another command-mutex caller.
|
||||
|
||||
- Separate service generation starts at 1, advances on every real canonical lifecycle admission, saturates at UINT32_MAX and survives counter clears. Stop/start ABA rejects old confirmation even if running state matches again. Transitions/timeouts stay fenced until the existing owner completes. Saturation disables typed controls but not canonical UART0 recovery.
|
||||
- SSH IDs retain their existing two-slot-bit/generation encoding. `find_free_slot()` now retires a slot at `SSH_TRANSPORT_GENERATION_MAX`; accept increments rather than wraps. Existing cleanup preserves its generation. Stale published IDs/queued owner requests cannot close a replacement connection after disconnect/reuse or listener restart. Capacity remains two until exhaustion (over a billion admissions per slot), then that slot requires reboot; reboot invalidates browser sessions too.
|
||||
- Existing `consume_external_close()` compares the exact owner slot session ID and preserves visible close intent while cleanup starts. Published snapshots can lag actual owner progress: disconnect may be accepted just as that connection disappears, but never intentionally closes a replacement. Normal close/revocation/task routing is unchanged.
|
||||
- Start/stop change runtime service state, not NVS settings or SSH/HTTPS identity. Stop is explicitly **all SSH sessions**, including admissions after the displayed snapshot and before execution; session rows are not a frozen stop target set. A queued/executing SSH console command has its existing cancellation/currentness semantics; already executing work is not rolled back.
|
||||
- Only SSH owner task touches wolfSSH/context/socket cleanup. No HTTPD callback is queued or awaited by this module. Snapshot has no blocking owner mutex; dispatcher does not hold console/HTTPD locks while waiting. This avoids a new HTTPD-task deadlock cycle. Existing bounded canonical owner wait remains (no hard task-scheduling guarantee); no peer-drain/receipt guarantee is introduced.
|
||||
|
||||
## UI contract
|
||||
|
||||
Admin-only **Settings → SSH**, using existing settings navigation, definition-list/form/action styles and native confirmation. No new contextual/quick entry or additional dependencies. Serial/Admin sockets continue draining; Settings never sends terminal input or changes the writer lease by navigation/selection. Explicit SSH disconnection/stop removes the affected SSH broker clients through normal owner cleanup; ordinary broker semantics handle an affected writer.
|
||||
|
||||
- Read/selection never mutates. Confirmation names exactly SSH/all SSH or one selected session and explains settings/identity preservation, independent HTTPS/Wi-Fi/USB/UART0 and already-executing-command limits.
|
||||
- Explicit session selection captures ID, route/name and the original service generation. Refresh never rebases it. Absence, identity/version mismatch, transitioning state, close intent or failed/timed-out read latches stale and clears the visible selection. A later matching response cannot resurrect it; explicit reselection required.
|
||||
- Captured request survives delayed session validation unchanged. Backend identity/version check remains authoritative for changes while the native confirmation is open.
|
||||
- One request at a time, 15-second whole-request deadline including session validation/body processing, controller/work-generation/navigation fencing. No automatic polling or retry in this bounded slice: after acknowledgement use **Check Operation Result**, then **Refresh**. This is deliberately manual completion inspection, not the Broker controller's live refresh/automatic completion loop.
|
||||
- Lost acknowledgement, replaced results, errors/timeouts and navigation preserve pending/unknown handling and prohibit automatic replay. Mutation remains gated until result inspection; refreshing alone never resolves pending uncertainty. Terminal result invalidates the snapshot, requiring Refresh before new controls.
|
||||
- 8D.18 Broker clients/Active writer contexts and the single quick-settings host are unchanged. Existing UI regression suite remains authoritative for those contracts.
|
||||
|
||||
## Resource accounting and build
|
||||
|
||||
| Resource | Before → after |
|
||||
|---|---|
|
||||
| Linked RAM | 100,300 → **100,404 B** (**+104 B**) |
|
||||
| Flash | 1,793,301 → **1,808,441 B** (**+15,140 B**) |
|
||||
| HTTPD handlers | 33 → 36, three optional descriptors/URI copies + three table pointers |
|
||||
| HTTPS sockets | 6 → 6; LRU still disabled |
|
||||
| SSH slots / shared remote console slots | 2 / 2, unchanged |
|
||||
| Dispatcher queue | four entries, unchanged item union size; added ID variant only |
|
||||
| Tasks / stacks / firmware timers | unchanged |
|
||||
| New operation storage | target object symbols:80 B slot +8 B lock +4 B next ID;4 B SSH service version (96 B before linker padding/placement; actual linked delta104 B) |
|
||||
| HTTP request/result/snapshot buffers | 256 / 96 / 768 B fixed arrays, plus bounded published SSH-row/auth/local operation copies on existing stacks |
|
||||
| New module dynamic allocation / PSRAM payload | none |
|
||||
| HTTPD registration heap | bounded descriptor/name/table growth only; allocator overhead and runtime floors unmeasured |
|
||||
|
||||
Baseline pio 6.87 s; first integration pio 82.78 s (nonfatal existing SDK FATFS bool-default warnings during configuration); final production pio **35.31 s**, PASS at the sizes above. CPU160 independently verified in tracked defaults, active sdkconfig and generated sdkconfig.h; board marketing line still says 240MHz but is not the effective setting. Signed-off combined WebSocket binary send path, baud rates, capacities and transport buffers unchanged.
|
||||
|
||||
No hardware/real-browser geometry/accessibility/heap/stack measurements this phase. Linked-size deltas are not runtime reserve approval. Optional URI allocations and existing HTTPD/dispatcher/SSH stack high-water/floors need target measurement, especially given the previously recorded low internal/DMA minima.
|
||||
|
||||
## Validation actually run
|
||||
|
||||
- `python3 tests/ssh_management/run.py`: **4 groups**, exact production management/snapshot/canonical lifecycle admission/close-consumption/free-slot functions; deterministic locks/RTOS/owner completion, plus accept increment guard. Tests stale/disconnected/reused IDs, duplicate/other-slot isolation, mutex busy, start/stop and CLI ABA, timeout/incomplete cleanup, saturation and ordinary recovery. Not a real socket/handshake/scheduler test.
|
||||
- `python3 tests/web_cookie_auth/run.py --ssh`: **6 SSH groups + shared policy/store tests**. Production handlers/parser/auth/store with SSH/dispatcher doubles: admin denial, Origin/CSRF/framing/receive limits, safe two-row projection, all actions, slot/result isolation, old IDs, queue error, stale principal/session/deadline, auth stop/restart, lost acknowledgement and ID exhaustion.
|
||||
- `python3 tests/admin_console_boundary/run.py`: existing suites + new SSH zero-wait/not-ready/full/four-entry/FIFO ID routing, no command-string execution.
|
||||
- `python3 tests/web_admin_transport/server_lifecycle.py`: **27 groups**, all six SSH staged descriptor/name failure positions, read-only failed-unregister residue, failed-stop ownership/restart, other-domain isolation; real lifecycle orchestration with dependency doubles.
|
||||
- `python3 tests/web_ui_session/run.py`: **143 behavior groups** (8 new SSH), production C renderer/JS, six-view structural HTML/shared styles, security headers and exact loader CSP. New UI checks cover scoped/cancelled confirmations, all typed bodies, stale/reordered/absent identity, manual result flow, malformed snapshots, timeout/lost acknowledgement/replacement, delayed auth, navigation/expiry fences and both-terminal isolation. Not real browser/device validation.
|
||||
- Broad sequential regression batch PASS: cookie `--broker`, `--display`, `--network`, `--accounts`, `--serial-settings`, `--settings`, `--admin`; `admin_ssh_policy/run.py`; `admin_console_boundary/{accounts.py,lifecycle.py}`; `session_broker_diagnostics/run.py`; `web_auth_parse/run.py`; `web_diagnostics/run.py`; `web_httpd_idle/run.py`; `web_login_ui/run.py`; `web_network_settings/run.py`; `web_security/run.py`; `web_serial_performance/run.py`; `web_session_store/run.py` and `--serial`; `web_admin_transport/run.py --tickets`.
|
||||
- Target-object `readelf -sW` measured the slot/lock/version symbols listed above; these are compiler object sizes, not runtime stack/heap margins. Final focused SSH owner/cookie/lifecycle/UI suites rerun after integration/documentation, PASS.
|
||||
- `git diff --check` PASS; production/registration/UI diff self-reviewed. Fresh worktree initially clean, no unrelated source edits. Test development corrected a lock-token lvalue/missing extracted constant in the new owner harness and one erroneous UI-fixture assertion; focused suites passed afterward. Formatting-only integration corrections preceded final build.
|
||||
|
||||
## Pending target/parent checks — no sign-off
|
||||
|
||||
1. Independently review this SSH-only contract and diff; do not mark all 8D.19 complete.
|
||||
2. Compare Settings SSH status/session IDs/routes with UART0 `ssh status`/`ssh sessions`, including handshake, user and admin connections, and missing/unavailable SSH. Verify ordinary-user server denial.
|
||||
3. Confirm/cancel start, all-session stop and one-session disconnect. Confirm browser login, both browser terminals, UART0 and USB remain available; other SSH connection survives targeted disconnect. Host identity/fingerprint and saved settings must remain unchanged across stop/start.
|
||||
4. Disconnect/reconnect/reuse selected SSH target while confirmation/read is delayed; exercise CLI stop/start ABA, simultaneous new admissions, close/revocation and queued typed work. Stale targets must not close replacements. Stop scope includes new SSH admissions before execution.
|
||||
5. Delay dispatcher with a normal bounded admin interaction; test pending/result visibility, deadline expiry, other-login isolation, logout/account-generation changes, request loss and repeated result checks. Inspect after uncertainty, never assume disconnect cancelled work or acknowledgement proves receipt.
|
||||
6. With SSH admin command in flight, verify stop/disconnect cleanup and subsequent console-slot reuse. Record real owner/dispatcher progress, errors/timeouts and lack of HTTPD watchdog/deadlock.
|
||||
7. Repeat full supported client mix at **160MHz / 230400 baud**, quiet UART0 broker/web counters before/after, byte/drop comparison on unaffected clients; include both 8D.18 contexts and native USB independent access. No throughput sign-off is inferred from host tests.
|
||||
8. Record settled boot/full-mix/repeated SSH close/start/stop/login-cycle internal/DMA/PSRAM free/minimum/largest blocks and available HTTPD/dispatcher/SSH stack margins. No reserve floor approval or long-soak result exists here.
|
||||
9. Real pointer/keyboard/touch/narrow-window Settings SSH selection and native confirmation, focus, screen-reader labels, refresh/reselect and uncertain-result recovery. No automatic polling is intended.
|
||||
@@ -0,0 +1,163 @@
|
||||
# Phase 8D.20 — confirmed HTTPS lifecycle and reboot
|
||||
|
||||
Date: 2026-09-13. **The authorized HTTPS stop/restart/reboot API, ACK handoff, dispatcher integration and Settings UI are implemented and host-tested/build-verified. Independent review complete with no confirmed actionable defects; target validation remains pending.** Existing Network controls are reused, not duplicated. No unrelated remaining 8D.19 domain or 8D.21 identity work is included.
|
||||
|
||||
Independent reviewer verified installed-IDF callback/request ordering, nonreused IDs and lost-work reservation, original-login/deadline checks, canonical lifecycle ABA and failure handling, dispatcher/HTTPD lock separation, reboot shutdown path and UI duplicate gates. Reviewer independently reran lifecycle41, cookie lifecycle8+shared, dispatcher, UI153+renderer/HTML/CSP and diff checks PASS. Parent final `pio run` confirmation PASS6.82s,100,508 B RAM/1,821,505 B flash; diff check PASS. These are host/source/build checks, not real TLS/UDP/reset or runtime-reserve validation.
|
||||
|
||||
## End-to-end integration contract (supersedes prerequisite-only status below)
|
||||
|
||||
The continuation session found uncommitted integration source/tests already present beyond the prerequisite described by project memory. It preserved that work, inspected the actual implementation, ran all focused/broad checks, extended send-return/request-lifetime/relogin tests, clarified post-restart session expiry in the UI and completed these records. The previous prerequisite evidence is retained below as history; it is not the final feature cost.
|
||||
|
||||
### API and authorization
|
||||
|
||||
`web_lifecycle_settings.{c,h}` owns three optional exact routes: current-admin bodyless GET `/api/settings/lifecycle`, GET `/api/settings/lifecycle-operation`, and JSON POST `/api/settings/lifecycle-operation`. Existing cookie policy enforces the original live login/current principal, admin role, bounded framing, no query/body on reads, and Origin/CSRF on mutations. No CLI string, password, ticket, private key, identity mutation or new authentication bypass is exposed.
|
||||
|
||||
- Snapshot: `{generation,running,transitioning,controllable}`, 128-byte output buffer, zero-wait canonical owner copy. Busy/unavailable returns 503.
|
||||
- POST: exactly `{action,generation}` with action `stop`, `restart` or `reboot`, unsigned nonzero/nonexhausted generation; no duplicate/extra fields, escapes, coercion or numeric overflow. At most 256 body bytes and four receives. Body/request scratch is wiped. Parser errors are 400; occupied/exhausted admission is 503.
|
||||
- Accepted response: 202 with `{id,action,state:"pending"}`. This acknowledges **pending admission**, not execution, completion or peer receipt. Result buffer is 96 bytes; reads expose only the invoking session's slot, otherwise `{id:0,action:"none",state:"idle"}`. States are `idle`, `pending`, `ok`, `failed`, `cancelled`; executing is projected as pending. Result IDs are not secrets or authorization tokens.
|
||||
- One static operation/result slot and one firmware-lifetime non-reused uint32 ID sequence. Exhaustion rejects until reboot; successful HTTPS restart does not reset IDs. Pending/executing work cannot be overwritten. Completed results are replaceable, not durable history or an idempotency/replay facility.
|
||||
|
||||
### ACK ownership, sequencing and failure bounds
|
||||
|
||||
1. HTTPD authenticates/parses and reserves the slot with the copied session/principal, expected HTTPS generation, two-second ACK deadline and 30-second total dequeue-admission deadline.
|
||||
2. The synchronous response send must return successfully before one `httpd_queue_work()` submission. The callback argument contains only the ID cast through `uintptr_t`: no request, response, fd, socket DB or reusable operation pointer. The saved server handle is comparison-only: its matching callback clears it, or successful HTTPD destruction retires the lost reservation.
|
||||
3. HTTPD invokes `ack_handoff` after the synchronous handler has returned. It checks/consumes the exact ACK ID, rejects expired work and submits only the operation ID with zero wait on the existing four-entry administration queue. It does no lifecycle work, socket IO, authentication or waiting. The dispatcher may be scheduled before this short callback returns; the callback holds no lock across queue submission and never waits for that dispatcher.
|
||||
4. The existing dispatcher reserves the executing slot, copies/wipes shared principal material, validates the **original** session/principal/current admin, checks the 30-second deadline again after validation, then invokes the generation-conditional owner API outside all slot/console locks. A newer login for the same account never substitutes. Ordinary HTTP socket loss does not itself revoke the login or cancel admitted work.
|
||||
5. Stop/restart uses canonical owner orchestration and its continuously held transition reservation. Once admitted, restart deliberately invalidates the old login and still completes its reserved start without revalidating that invalidated login. Owner failure is `failed`, not `cancelled`: even `ESP_ERR_INVALID_STATE` can follow auth invalidation/detach side effects. `cancelled` is reserved for rejection before owner admission.
|
||||
|
||||
Send failure never queues a callback. HTTPD queue failure or dispatcher queue failure cancels before admission without retry. An accepted-but-lost HTTPD callback keeps **one** ACK reservation, even after its operation deadline expires: subsequent POSTs fail busy rather than accumulating delayed callbacks. A late callback clears only its own reservation and cannot execute expired work. Successful canonical HTTPD destruction retires the reservation before a replacement server starts; failed destruction retains it. Same-handle/request-storage reuse and duplicate/old IDs are inert. Canonical UART0/reachable SSH `web stop` then `web start` is the recovery for a lost reservation; this closes every web client. A delayed callback that eventually arrives can also release its reservation.
|
||||
|
||||
Deadlines are checked lazily on callback, dequeue and API activity; there is no new firmware timer and no hard wall-clock cancellation/erasure promise. The two-second ACK phase includes synchronous response sending; a send returning at/after the deadline cannot admit work. Socket timeout, scheduling delay, successful send, aborted fetch or missing result proves neither peer receipt nor cancellation of an already-admitted action. HTTPD shutdown/start and reset are not bounded by these admission deadlines.
|
||||
|
||||
### SDK and reboot audit
|
||||
|
||||
Installed ESP-IDF 5.5.0 `httpd_main.c` was inspected: `httpd_queue_work` sends a control message, `httpd_process_ctrl_msg` calls it on HTTPD, and request/session processing is synchronous on that same owner. No inline callback from queue submission. Existing `CONFIG_HTTPD_QUEUE_WORK_BLOCKING` is disabled in defaults/active/generated configuration; the new module fails compilation if enabled. No private HTTPD access was added outside the established adapter. Real UDP loss, TLS timing and task scheduling remain target tests, not established by dependency doubles.
|
||||
|
||||
`web_server_reboot_current()` checks zero-wait canonical mutex/state/generation, reserves transition and advances generation, releases the mutex, then calls **the same `esp_restart()` API** used by `system_console` UART0, SSH owner, browser-admin owner and local reboot. No duplicate reboot runner or CLI dispatch. Source audit found no application shutdown-handler registration and no typed operation console-session pending/self-cleanup reservation. The dispatcher typed branch executes outside the console lock before ordinary console-session execution handling. ESP-IDF reset invokes its registered shutdown handlers then the no-OS reset; the typed path does not synchronously stop HTTPD first. Unexpected reset return reports failure and deliberately retains the transition reservation to prevent duplicate admission. Target reset/SDK shutdown behavior is unmeasured.
|
||||
|
||||
### UI and recovery contract
|
||||
|
||||
`web_ui.c` adds admin-only Settings → **HTTPS / Reboot**, using existing Settings navigation, same-session validation, bounded API reader and native explicit confirmations. Opening/refreshing/selecting does not mutate or change either terminal/broker lease. Service-wide confirmations include clients admitted after the snapshot and before execution, all web logins/both browser terminal routes, and unchanged identity/device working configuration for HTTPS-only actions. Reboot warns that all clients, SSH, USB and UART operation are interrupted and unsaved RAM-only working configuration can be lost. No saved configuration/identity reset is performed.
|
||||
|
||||
Requests have a single-flight 15-second whole-request deadline including session validation, headers and body, with controller/generation/navigation fences. Pending/unknown outcomes block duplicate submission across navigation/pagehide. Results are checked explicitly, not automatically polled. A lost ACK or replaced/mismatched ID/action remains uncertain; Refresh cannot clear that uncertainty or replay the operation. Known matched terminal results still require a fresh snapshot before another explicit confirmation. There is no automatic mutation retry, persistence, restored mutation or fresh-login result adoption. HTTPS restart expires the login; reload and explicitly sign in after recovery. Existing session-expiry handling closes both sockets and fences stale responses/401s.
|
||||
|
||||
The view links directly to the **existing Network** controller. Its backend and mutation/check logic are unchanged; existing bounded confirms, generation/current-login checks, uncertainty handling and no-replay tests pass. Network help/confirmation wording now accurately distinguishes restoring STA/AP reachability/UART0 administration from native USB's network-independent **UART1 serial**, not Wi-Fi or web administration. SSH recovery requires that SSH still runs and is reachable; Wi-Fi loss can remove it. USB is not promised uninterrupted access through whole-device reboot.
|
||||
|
||||
### Validation performed in the continuation
|
||||
|
||||
All commands passed with finite tool timeouts; no timeout or failed build occurred:
|
||||
|
||||
- `python3 tests/web_admin_transport/server_lifecycle.py`: **41 groups**, 34 prior + seven integration groups. Extracted real owner and ACK/execute functions are joined for canonical stop/reserved restart/reboot, ABA during validation, every stop failure, lost reservation through failed/successful destruction, same-server reuse, expiry/queue/revocation, reset admission/defensive return and all six optional route allocation positions plus unregister failure/restart.
|
||||
- `python3 tests/web_cookie_auth/run.py --lifecycle`: **eight lifecycle groups plus shared auth**. Real handlers/parser/store, dependency doubles for HTTPD queue/owner/dispatcher; strict policy/parser/bounds, send/queue failures, ACK and dequeue/post-validation deadlines, original-login revocation races, duplicates, lost/late callback/ID exhaustion, admitted revocation and uncertain owner failure. Added send-return-at-deadline rejection and overwritten request/connection/scratch storage before callback/dispatch, demonstrating no retained request pointer.
|
||||
- `python3 tests/admin_console_boundary/run.py`: real typed dispatcher ID routing, not-ready/full queue, unchanged four-entry queue and no command runner; existing owner/self-detach/deferred regressions.
|
||||
- `python3 tests/web_ui_session/run.py`: **153 browser groups** (143 prior + ten lifecycle), seven-view renderer/HTML/CSP. Includes bounded late headers/body/session/ACK/401, navigation/pagehide, strict schemas, no mutation on view/Network link, confirmation/recovery wording, duplicate/unknown gating, both terminal drains, explicit accepted restart followed by expired login and fresh-document no-replay.
|
||||
- `python3 tests/admin_console_boundary/lifecycle.py`, `accounts.py`; `tests/admin_ssh_policy/run.py`.
|
||||
- `python3 tests/web_cookie_auth/run.py` and each existing variant `--ssh`, `--network`, `--accounts`, `--serial-settings`, `--broker`, `--display`, `--admin`, `--settings`.
|
||||
- `python3 tests/web_admin_transport/run.py --tickets`, `tests/web_admin_tickets/run.py`, `tests/web_httpd_idle/run.py`, `tests/web_serial_performance/run.py`.
|
||||
- `python3 tests/web_session_store/run.py` and `--serial`; `tests/ssh_management/run.py`, `tests/web_network_settings/run.py`, `tests/session_broker_diagnostics/run.py`, `tests/web_auth_parse/run.py` (294 cases), `tests/web_diagnostics/run.py`, `tests/web_security/run.py`, `tests/web_login_ui/run.py`.
|
||||
- `git --no-pager diff --check`, source self-review and bounded `pio run`. Final resource confirmation follows below.
|
||||
|
||||
These are host/renderer tests with controlled boundary failures, not real HTTPD scheduling/TLS/on-wire delivery, physical reboot, browser geometry, hardware/full-mix throughput or independent review. No upload/erase/commit/device action, identity/SDK/dependency/generated-asset change or full M3 sign-off.
|
||||
|
||||
### Integrated resource evidence
|
||||
|
||||
Reference before 8D.20: **100,404 B RAM / 1,808,441 B flash** (previous agent's measured baseline). Prerequisite build: **100,404 / 1,808,589 B**. Continuation first `pio run` passed in **21.86 s**, **100,508 / 1,821,441 B** before the final explicit post-restart-login wording. This is **+104/+13,000 B** versus pre-phase baseline, **+104/+12,852 B** versus prerequisite. Final wording/build measurement is recorded separately below; the original baseline was not recreated by reverting uncommitted work.
|
||||
|
||||
Target `nm -S` for the new module: operation slot **88 B**, lock **8 B**, ID sequence/ACK ID/server comparison handle **4 B each** = **108 B mutable object storage** (linked RAM delta includes alignment/placement, not simply the object sum). Read-only action/state pointer arrays **12/24 B**. No module heap/PSRAM allocation or internal fallback. Handler locals include one operation copy, authenticated session view,256-byte body,40-byte content type and96-byte response; snapshot response128 B; dispatcher has one operation copy. Compiler stack frames/runtime high-water remain unmeasured.
|
||||
|
||||
Handlers increase **36→39** (three optional routes and three table pointers plus HTTPD-owned descriptor/URI allocations). Six HTTPS sockets, two serial slots, one browser-admin slot, four cookie sessions, existing ticket capacities, four-entry dispatcher/item capacity, task count/priorities/stacks and firmware timers remain unchanged. At most one lifecycle HTTPD control work item and one operation ID on the existing dispatcher; lost HTTPD work stays reserved rather than growing queued work. Optional route allocation failure cannot take down other domains. CPU160 defaults/active/generated and nonblocking HTTPD queue configuration verified; no CPU/config/throughput tuning. Static size is not approval of runtime heap/largest-block/HTTPD or dispatcher stack reserves.
|
||||
|
||||
### Final confirmation
|
||||
|
||||
After the explicit post-restart session-expiry UI wording, `pio run` passed in **22.85 s** (120,000 ms timeout): **100,508 B linked RAM / 1,821,505 B flash**. Final total delta versus pre-phase100,404/1,808,441 is **+104 B RAM / +13,064 B flash**; versus prerequisite100,404/1,808,589 it is **+104/+12,916 B**. The final UI153, cookie lifecycle8+shared, owner/pipeline41 and dispatcher suites were rerun and passed after the wording change. Broad regressions above passed before that wording-only change. No build timed out. Independent parent review is the next handoff, not represented by these self-checks.
|
||||
|
||||
## Historical prerequisite record
|
||||
|
||||
The following sections preserve the previous internal-slice evidence. Statements that integration was unimplemented describe that earlier checkpoint only.
|
||||
|
||||
## Scope and split decision
|
||||
|
||||
Read agent code-map, relevant architecture/design decisions, current-state, exact phase8d_plan 8D.20 row and work-unit/resource rules. Inspected `main.c`, canonical system reboot, administration dispatcher, browser-admin owner lifecycle, existing SSH typed backend/UI and existing Network confirmations. Initial worktree was clean.
|
||||
|
||||
The explicit user request permits proceeding with 8D.20 despite 8D.19 being only the completed/reviewed SSH slice. No unrelated other-service 8D.19 prerequisite is invented. Existing Network already owns Wi-Fi controls and must not be duplicated.
|
||||
|
||||
The concrete missing HTTPS prerequisite was generation-checked public lifecycle admission and a restart reservation spanning stop through start. Existing `web_server_stop()` followed by `web_server_start()` has a lifecycle-admission gap and no expected-generation argument. A snapshot comparison followed by unconditional stop would allow stale work to affect a replacement server.
|
||||
|
||||
Before editing, split under the plan's 600–800-authored-line scope threshold: owner adaptation, bounded HTTPD ACK/dispatcher handoff, authenticated typed routes, UI session/uncertainty handling and their owner/HTTP/UI fault suites were estimated to exceed one bounded chunk. Selected an internal prerequisite, which the plan permits, rather than exposing partially protected routes or omitting acceptance tests. This is **not delivery of the requested full API/UI feature**. The remaining complete integration is listed below.
|
||||
|
||||
Changed production files: `src/web_server.{c,h}` only. Extended existing `tests/web_admin_transport/server_lifecycle.py`; related phase/agent documents updated. No new task, runner, queue, timer, route, dispatcher item, stack size, socket/session/ticket capacity, NVS schema, configuration, dependency, SDK or asset change. No reboot/identity/certificate/key/factory-reset operation added. Existing 8D.18/UI and SSH19, signed-off CPU160 combined binary WebSocket send, Network, UART0 recovery and USB ownership are preserved.
|
||||
|
||||
## Public owner contract
|
||||
|
||||
- `web_server_get_management_snapshot()` is a zero-wait, secret-free projection `{generation, running, transitioning, controllable}` using the existing server mutex. Failure clears the destination; null is invalid argument, uninitialized owner is invalid state, mutex contention is timeout. It performs no HTTPD work, socket query, task scan, authentication or persistence.
|
||||
- `web_server_stop_current(expected_generation)` and `web_server_restart_current(expected_generation)` acquire the existing mutex with zero wait, then atomically compare generation and lifecycle state before canonical stop admission. Zero generation is invalid argument. Stale/saturated generation, absent server, transition or retained lifecycle failure is invalid state without lifecycle side effects.
|
||||
- These APIs are **off-HTTPD only**, just like ordinary start/stop. A future caller must authorize the original login/current admin, complete its bounded ACK handoff and invoke from the existing dispatcher. Neither lifecycle API authenticates, sends an ACK, waits a bounded time for HTTPD, or cancels admitted work after revocation.
|
||||
- Restart uses the same canonical stop implementation. On successful stop it keeps `s_transitioning` reserved while clearing the old handle, then invokes the private reserved start path. Competing canonical/conditional lifecycle callers cannot acquire the stopped-to-start gap. The server mutex is released around authentication, transport detach, TLS start/stop, registration and external callbacks; it is not held while waiting for HTTPD.
|
||||
- Any idle/admin/serial hard-detach or SSL-stop failure skips start, releases the transition reservation, retains existing ownership/error semantics and makes conditional controls unavailable. Canonical `web_server_stop()` remains available for recovery. Existing serial-detach timeout semantics are unchanged: this timeout permits SSL destruction, then restart after successful destruction.
|
||||
- Start-side failure releases the reservation. If cleanup fails, the partial server remains owned and canonical stop must clean it before another start. No identity/configuration rollback or automatic retry occurs.
|
||||
- Repeated `web_server_init()` no longer clears a retained `ESP_ERR_INVALID_STATE` lifecycle error. Only first initialization resets its initial sentinel, preventing an unsuccessful recovery start/init from making failed cleanup appear controllable.
|
||||
|
||||
### Generation and completion limits
|
||||
|
||||
One firmware-lifetime uint32 generation begins at 1. Every admitted canonical or conditional start/stop advances it, including an admission that later fails. Rejected calls do not advance it; counter clear and repeated init do not reset it. A successful restart normally advances twice. Same HTTPD-handle reuse and canonical stop/start ABA cannot restore an old confirmation.
|
||||
|
||||
Generation saturates at UINT32_MAX. Conditional admission then fails closed, while ordinary start/stop recovery remains available. A restart already admitted at UINT32_MAX−1 can complete its reserved start at saturation; no new conditional action is admitted with the saturated token. Reboot will reset firmware state and invalidate all old browser sessions; no typed reboot admission is part of this slice.
|
||||
|
||||
`controllable` means a running, nontransitioning server with a successful last lifecycle result and a nonexhausted generation. It does not establish network reachability, response receipt, browser-session validity, or a hard shutdown deadline. A lifecycle failure may already have invalidated all web logins. Once stop is admitted, later logout/revocation/timeout must not be reported as cancellation of that stop or its reserved restart.
|
||||
|
||||
## Validation actually performed
|
||||
|
||||
All commands below passed, each terminal group with a finite timeout. No build timed out.
|
||||
|
||||
- `python3 tests/web_admin_transport/server_lifecycle.py`: **34 groups**, 27 existing plus seven new owner groups. Compiles extracted production lifecycle functions, public header and real URI initializers against bounded dependency doubles.
|
||||
- `python3 tests/admin_console_boundary/run.py`, `lifecycle.py`, `accounts.py`; `python3 tests/admin_ssh_policy/run.py`.
|
||||
- `python3 tests/web_admin_transport/run.py --tickets`; `python3 tests/web_admin_tickets/run.py`.
|
||||
- `python3 tests/web_httpd_idle/run.py`; `python3 tests/web_serial_performance/run.py` (existing combined-send/SDK-boundary regressions).
|
||||
- `python3 tests/web_session_store/run.py`, and `--serial`.
|
||||
- `python3 tests/web_ui_session/run.py`: existing 143 browser groups plus renderer/HTML/CSP checks. No UI edits or new 8D.20 UI test claims.
|
||||
- `python3 tests/web_cookie_auth/run.py` and each existing variant: `--ssh`, `--network`, `--accounts`, `--serial-settings`, `--broker`, `--display`, `--admin`, `--settings`.
|
||||
- `python3 tests/ssh_management/run.py`, `tests/web_network_settings/run.py`, `tests/session_broker_diagnostics/run.py`, `tests/web_auth_parse/run.py`, `tests/web_diagnostics/run.py`, `tests/web_security/run.py`, `tests/web_login_ui/run.py`.
|
||||
- `git --no-pager diff --check`; production diff self-review. Final lifecycle34/diff rerun and bounded build confirmation also passed. This is not independent parent review.
|
||||
|
||||
New owner cases cover preinit/null/zero arguments; zero-wait mutex contention and cleared snapshots; same-handle canonical ABA; counter clear; successful stop; reserved restart with deterministic competing canonical and conditional calls at the released-mutex gap; each stop failure stage; repeated init preserving cleanup failure; six restart start-side failures including failed partial-server cleanup; saturation without wrap; transition rejection; and inherited serial timeout behavior. All external lifecycle doubles assert that the server mutex is not held where required. Failure recovery uses actual canonical orchestration, not replacement management functions.
|
||||
|
||||
Limits: no real TLS/HTTPD scheduler, device sockets, physical reboot, browser geometry, queue/ACK pipeline, original-login typed admission, UI stale-result or typed reboot tests were added or claimed. Existing idle/admin/store tests exercise their own callback/lifecycle fences, **not an unimplemented 8D.20 ACK handoff**. No secrets are added to production projections/logs or new test output. No independent reviewer tool is available in this session; parent review is pending, not simulated by self-review.
|
||||
|
||||
## Build and resource evidence
|
||||
|
||||
`pio run` timeout: 120,000 ms.
|
||||
|
||||
| Measurement | Time | Linked RAM | Flash |
|
||||
|---|---:|---:|---:|
|
||||
| Actual pre-edit baseline | 21.10 s | 100,404 B | 1,808,441 B |
|
||||
| Post-production-change build | 35.05 s | 100,404 B | 1,808,589 B |
|
||||
| Final confirmation | 20.02 s | 100,404 B | 1,808,589 B |
|
||||
| Delta | — | +0 B | +148 B |
|
||||
|
||||
Target object `nm -S` reports `s_generation` 4 B, management snapshot function 165 B, conditional wrappers 26 B each. The public management entry points have no production callers yet and are linker-discarded; the linked delta is **not** the eventual end-to-end feature cost. The additional generation exists in the firmware, with placement yielding no net linked-RAM increase. No heap/PSRAM allocation is introduced. The new management projection has four scalar fields; no request/response buffer exists yet. Existing 36 handlers, six HTTPS sockets, two web serial slots, one browser-admin socket, four cookie sessions and four-entry dispatcher remain unchanged.
|
||||
|
||||
CPU160 was checked in `sdkconfig.defaults`, active sdkconfig and generated sdkconfig.h. PlatformIO's generic board banner says 240 MHz; it does not override the verified generated configuration. No CPU tuning occurred. Runtime HTTPD/dispatcher stack floors, heap minima/largest blocks and reserve approval remain unmeasured; static size is not runtime reserve evidence.
|
||||
|
||||
## Historical remaining-integration checklist — now implemented above
|
||||
|
||||
1. Add one narrow bounded HTTPS/reboot operation domain on the existing dispatcher, with optional current-admin cookie/Origin/CSRF routes and a secret-free bounded snapshot/result. Do not introduce a generic runner or increase queue/task budgets.
|
||||
2. Specify and test ACK handoff before stop/restart/reboot: HTTPD must never wait for its own shutdown. Owner completion must be bounded/generation-qualified without request/raw-fd lifetime capture. Handle response send failure, queue failure, accepted-but-lost/delayed callback, expired/revoked original login, shutdown and callback ABA. A late callback must not revive a retired action or authorize a replacement login/server. A bounded ACK phase is not a bounded lifecycle completion or peer-receipt guarantee.
|
||||
3. Revalidate original login, principal/current admin and dequeue deadline immediately before action admission. Use the new conditional owner APIs for HTTPS stop/restart, and establish narrow canonical reboot admission rather than executing a CLI string. Do not revalidate a deliberately invalidated login between the already-admitted stop and its reserved start. No certificate/key/identity/reset action.
|
||||
4. Add coherent Settings API/UI with explicit service-wide confirmations, warning that all affected clients—including clients admitted after the snapshot—disconnect. HTTPS stop needs UART0 or still-running reachable admin SSH `web start`; native USB remains UART1 serial, not an administrative web-start route. Reboot affects the entire device, including SSH, USB and UART operation during restart, loses unsaved RAM-only working configuration, and does not imply uninterrupted recovery links.
|
||||
5. Reuse existing Network controls instead of duplicating Wi-Fi. Audit their confirmation/recovery wording and uncertainty behavior within this phase: Wi-Fi disruption can also remove SSH reachability. USB preserves network-independent UART1 access for network-only operations, not administration or uninterrupted reboot.
|
||||
6. Bound pending/result and browser request lifetimes. Disable duplicate submission during pending/unknown outcomes; never automatically retry or restore a mutation. Original-login/result-ID/session fences must prevent old responses from being adopted after reconnect. A missing ACK, timeout, revocation, connection loss or result replacement is not evidence that admitted work was cancelled. Recovery reads and explicit reconnect/relogin must not replay an operation.
|
||||
7. Add real production-path owner/dispatcher/HTTP/UI tests for the above, rerun regressions/build, measure incremental linked/object resources, and request independent parent review. No 8D.21 or unrelated remaining 8D.19 work.
|
||||
|
||||
## Pending target and review checklist
|
||||
|
||||
- [ ] Independent parent review of this prerequisite and later integrated ACK/API/UI work.
|
||||
- [ ] Canonical UART0 and SSH web stop/start/recovery after these internal changes.
|
||||
- [ ] Target typed HTTPS stop/restart/reboot flows (integration now implemented and host-tested).
|
||||
- [ ] Slow/lost ACK, delayed callback, busy dispatcher, expired/revoked login and failed stop target fault cases where practical; no stale execution/replay.
|
||||
- [ ] HTTPS-stop recovery through UART0/reachable SSH; explicit fresh web login after restart, no old operation restore.
|
||||
- [ ] Reboot/all-client/USB interruption and unsaved-working-state warnings verified against target behavior.
|
||||
- [ ] Existing Network Wi-Fi loss/recovery without duplicated controls or false USB-admin claims.
|
||||
- [ ] Existing UI18/SSH19/Network/auth/logout and full supported client mix at signed-off CPU160/combined WS send; broker byte/drop accounting.
|
||||
- [ ] Settled boot/full-mix/recovery memory minima, largest blocks and HTTPD/dispatcher stack margins; no invented reserves.
|
||||
|
||||
No device operation, upload, erase, commit, target sign-off, full 8D.20 completion or M3 claim.
|
||||
@@ -0,0 +1,294 @@
|
||||
# 8D.21 — HTTPS and SSH host identity settings
|
||||
|
||||
## Current aggregate status (2026-09-13)
|
||||
|
||||
**The explicitly authorized HTTPS and remaining SSH identity slices are implemented end-to-end and host/build verified.** This completes the allowed implementation scope of 8D.21, not target acceptance, runtime reserve approval, M3, or 8D.22. The HTTPS slice's independent-review history below is preserved. The SSH slice has now been independently reviewed with no confirmed actionable defects. Reviewer checked installed wolfSSH key copying/wiping, retained-context cleanup, reservation/lock ordering, failure effects, public metadata and auth/UI scope; independently reran SSH security5, retention/management, cookie SSH7+shared, UI158+renderer/HTML/CSP, console boundary/lifecycle/policy and HTTPS lifecycle/security17 PASS. Parent final `pio run` confirmation PASS7.15s,100,556 B RAM/1,828,573 B flash; diff check PASS. Target trust renewal, real scheduling, NVS power-loss and runtime margins remain unverified.
|
||||
|
||||
The SSH continuation began with a clean working tree and preserved the completed HTTPS shared reservations, 8D.19 SSH controller/routes, earlier UI work and 160 MHz throughput configuration. No upload, erase, commit, branch, assets regeneration, SDK/dependency/configuration changes, or new tasks/timers/queues/routes were performed.
|
||||
|
||||
### SSH delivered workflow and exclusions
|
||||
|
||||
Admin Settings → SSH now adds stored host-key OpenSSH SHA-256 fingerprint, identity generation, service generation and the sole canonical supported algorithm, `ecdsa-sha2-nistp256` (ECDSA P-256 / `nistp256`). This is a public metadata projection, not private/public key download or proof of the identity a peer actually served. No algorithm selector or invented host-key algorithm was added; user authorized-key algorithms and authentication are unrelated and unchanged.
|
||||
|
||||
**Rotate SSH host identity…** confirms the exact old fingerprint and both generations before any asynchronous session validation. It explicitly warns that all SSH sessions, including subsequently admitted sessions, close; already executing administration may finish; stopped ordinary rotation stays stopped; persistence and restart may fail after partial effects. Verify the NEW fingerprint using trusted UART0 **`ssh host-key info` before accepting changed `known_hosts` trust**. Do not blindly remove warnings. HTTPS remains accessible and its login/browser terminals are not deliberately stopped; Wi-Fi, users, UART0 administration and native USB UART1 access are unchanged. USB is not an administrative recovery console.
|
||||
|
||||
Reset audit: healthy `ssh reset --force` duplicates key replacement, additionally starts stopped SSH and permits unavailable/corrupt-material recovery. Preserve that canonical CLI/deferred-SSH behavior; do not add a duplicate browser Reset or new recovery endpoint. Full21 excludes user authorized-key work, key/certificate export, passwords/recovery secrets, unavailable-database recovery, factory erase/configuration wipe, OTA, NVS encryption and secure boot. Existing browser-shell SSH policy remains unchanged; typed SSH controls do not enable deprecated/restricted shell commands.
|
||||
|
||||
### SSH owner and material lifetime contract
|
||||
|
||||
- `ssh_transport_replace_identity(service_generation, identity_generation, reset, &committed)` is the shared off-HTTPD owner. Both nonzero generations select conditional rotation; both zero retain canonical semantics; reset plus conditional generations is invalid. The canonical `ssh_transport_replace_host_key()` wrapper and existing UART0/deferred admin-SSH paths use it. Conditional stopped rotation is supported without starting SSH.
|
||||
- Take the existing command mutex with zero wait, compare current initialized/service generation/transition/cleanup state, then reserve identity **before stop or crypto/NVS**. Canonical rotate/reset also now fail busy rather than waiting on another identity owner. Ordinary start/stop retain their existing command-mutex waits. The combined command mutex spans stop, replacement and conditional restart, without releasing an interleaving gap.
|
||||
- `ssh_security_reserve_identity()` uses the security mutex only for short admission. Direct canonical `ssh_security_rotate()` / `ssh_security_reset()` share this reservation. Nonreused uint32 tokens are task-owner-bound; only the reserving task can replace once and release; stale tokens cannot release or reuse a later reservation. Failed expected identity comparison has no service side effect. Init cannot publish unavailable material over a live reservation.
|
||||
- After both reservations, advance saturated service generation; retain established **stop → generate/commit/publish → conditional restart** ordering. Crypto and NVS run outside the security mutex and all spinlocks; the service command mutex remains held. Read-only public metadata/DER copies can take their short security mutex without a crypto-held lock. Existing startup initialization retains its mutex semantics.
|
||||
- Failed stop/timeout skips identity mutation and **never attempts another start**. Pending owner work is not cancelled by timeout. Failed cleanup retains canonical recovery. Generation/RNG/NVS failure after successful stop leaves live identity unchanged, but SSH clients have already disconnected; if previously running, make the established best-effort restart using unchanged material. That restart can itself fail. No blanket “failure means no effect” claim.
|
||||
- Commit precedes publication and old-private-material wipe. `committed=true` remains true if the subsequent restart fails; no rollback. Identity generations change only after successful commit, saturating without wrap. Service generation advances on admitted replacement and admitted lifecycle transitions, including failures, independently of counter clear. Reservation exhaustion blocks future identity mutation until reboot; reboot invalidates old browser logins.
|
||||
- Only the existing SSH owner task invokes runtime wolfSSH/context/socket operations. Installed `wolfSSH_CTX_UsePrivateKey_buffer()` delegates to `wolfSSH_ProcessBuffer()`; its ASN.1 path allocates and copies input DER (`internal.c` 2122–2129) before `SetHostPrivateKey`. The production caller wipes its bounded stack DER on every return path, so a running context does not borrow `s_material` or stack bytes. No SDK/library edit was needed.
|
||||
- `start_runtime()` now rejects retained context/listener/non-free slots rather than overwriting orphan handles. `stop_runtime()` retains the context until all slots retire; `process_slots()` frees a retained context on the owner, outside the spinlock, before clearing cleanup admission. The prior `cleanup_slot()` already frees its wolfSSH object before a possible broker-disconnect failure: this review did **not** demonstrate a preexisting UAF. The change enforces the explicitly requested stronger retained-context invariant. Library destructor private-key wiping was source-inspected, not exercised by a real wolfSSH target test.
|
||||
- Existing SSH self-affecting command drain/deferred-control behavior is preserved. No new dispatcher/task or command-string replay. The dispatcher does not execute its own SSH-shell host rotation synchronously; the existing control owner performs it after the handler/drain boundary. Existing authentication roles, shell-request policy, exact session generations and broker routing are unchanged.
|
||||
|
||||
### SSH HTTP/UI bounds and result semantics
|
||||
|
||||
Reuse GET `/api/settings/ssh` and GET/POST `/api/settings/ssh-operation`, current-admin cookie/Origin/CSRF/no-store protections, existing ID-only dispatcher and single original-login slot. No HTTPD crypto, NVS, lifecycle wait or wolfSSH call. Existing 256-byte/four-receive input, 768-byte snapshot and 96-byte result bounds remain unchanged; 39 total handlers/six sockets and unchanged queue depth/item capacity.
|
||||
|
||||
Service requests retain exactly `action`, `generation`, `target`. Rotation requires exactly four fields, e.g. `{"action":"rotate","generation":7,"target":0,"identity_generation":3}`; unknown/duplicate/escaped fields, unsupported action, nonzero rotation target, missing/zero/saturated identity generation, coercion, malformed or oversized bodies reject. Snapshot adds `identity_generation`, `algorithm`, `fingerprint`, `rotatable` to the existing four fields. Fingerprint is unpadded OpenSSH `SHA256:` base64. Security metadata is atomically copied under a zero-wait lock, with no private material. Service and identity observations are separate, not a cross-owner atomic authorization; execution compares/reserves both. Unavailable identity yields generation0/empty fingerprint/rotatable false without removing ordinary service controls.
|
||||
|
||||
Queue admission and response follow the existing SSH post-before-execute pattern, **not** the HTTPS self-cutting ACK gate: SSH rotation does not stop the invoking HTTPD/login. A lost response does not cancel queued work. Original-login/current-admin/30-second dequeue deadline checks precede combined owner admission; revocation after admission is not cancellation. Completed duplicate IDs are inert, IDs do not wrap, results are replaceable and login-isolated, not durable/idempotent history. Rotation errors conservatively report `failed`, even for rejected owner admission, because later failures can have partial effects; ordinary service conflict reporting is unchanged.
|
||||
|
||||
The existing controller keeps 15-second whole-request bounds, captured confirmation values, single-flight/pending gates, manual Check Operation Result then Refresh, navigation/late-response fencing and no mutation retry or restore. Terminal errors/results explicitly warn that SSH may have disconnected despite persistence failure, or a key may be persisted despite restart failure. HTTPS does not require fresh login due to this SSH operation. No new polling timer or separate controller.
|
||||
|
||||
### SSH validation and resources
|
||||
|
||||
Commands actually run, all PASS after the described harness fixes:
|
||||
|
||||
- `python3 tests/ssh_management/security.py`: five groups, full production security + real host mbedTLS and NVS fault doubles, plus extracted exact production combined owner functions. Covers P-256 generation/validation/copy/reload, RNG/NVS open/set/commit faults and unchanged stored/live bytes, commit-before-publication, stale admission before effects, competing canonical/direct owners during crypto, task/token ownership/reuse/one-shot/exhaustion, postcommit restart failure/no rollback, zero-wait metadata, malformed storage and canonical reset recovery. NVS handle closure and candidate wipes checked. No real power-loss/RTOS scheduling claims.
|
||||
- `python3 tests/ssh_management/runtime.py`: exact production runtime start/stop/process-slot functions; deterministic retained-resource doubles prove failed-stop retention, no orphan overwrite/start, owner-only final retirement and listener-failure cleanup. Not a real wolfSSH allocator/socket test.
|
||||
- `python3 tests/ssh_management/run.py`: five groups, existing published snapshot/session-close/ABA/retired-ID/timeout/exhaustion tests plus combined identity-owner comparison, busy reservation, failed-stop no start/mutation, persistence recovery and stopped/reset semantics.
|
||||
- `python3 tests/web_cookie_auth/run.py --ssh`: seven SSH groups plus shared auth; strict rotation fields/generations, dispatcher-only single execution, revoked queued rotation, login isolation, metadata bounds, existing Origin/CSRF/session/receive/expiry/deadline/lost-response tests. Uses owner doubles, not crypto.
|
||||
- `python3 tests/web_ui_session/run.py`: **158 groups** plus production C rendering/HTML/CSP checks. Adds rotation confirmation/fingerprint/both generations, exact request, all-SSH/trust/UART0/partial-effect warnings, duplicate suppression, HTTPS terminal isolation and malformed/unavailable identity metadata; all prior domains remain green.
|
||||
- Broad PASS: `tests/admin_console_boundary/run.py`, `tests/admin_console_boundary/accounts.py`, `tests/admin_console_boundary/lifecycle.py`, `tests/admin_ssh_policy/run.py`, `tests/web_admin_transport/server_lifecycle.py` (44+2 integrated real HTTPS security groups), `tests/web_security/run.py` (17), all cookie variants (`--admin`, `--settings`, `--serial-settings`, `--accounts`, `--network`, `--display`, `--broker`, `--lifecycle`, `--ssh`), `tests/web_admin_transport/run.py --tickets`, `tests/web_session_store/run.py --serial`, `tests/web_serial_performance/run.py`, `tests/web_httpd_idle/run.py`, `tests/web_auth_parse/run.py`, `tests/web_network_settings/run.py`, `tests/session_broker_diagnostics/run.py`, `tests/web_diagnostics/run.py`, `tests/web_login_ui/run.py` (all invoked with `python3`).
|
||||
- Initial compile caught an enum-type comparison in the new API action; fixed with the module's unsigned action value. Cookie harness needed real `-lmbedcrypto` for base64; integrated owner harness needed its extracted generation constant. These were corrected and affected suites rerun successfully.
|
||||
- Baseline `pio run`: PASS7.20s, **100,532 B RAM / 1,825,073 B flash**. Final production build after retained-context changes: PASS21.98s, **100,556 B RAM / 1,828,573 B flash**, SSH slice delta **+24 B / +3,500 B**; aggregate21 delta versus pre-HTTPS100,508/1,821,505 is **+48 B / +7,068 B**. These are linked static/flash counts, not heap or stack reserves.
|
||||
- Final confirmation `pio run` PASS7.07s at identical100,556/1,828,573 B; final SSH security/runtime/management/cookie/UI158 and console accounts/lifecycle reruns PASS. No independent-review attribution is implied by these same-agent reruns.
|
||||
- Defaults, active sdkconfig and generated sdkconfig.h explicitly checked: CPU **160 MHz**. Board-banner240MHz is not the configured CPU clock. Combined binary WebSocket send path/config/assets untouched. `git diff --check` PASS.
|
||||
|
||||
### Pending SSH parent and target gates
|
||||
|
||||
Independent parent review is required after this implementation; no independent review was performed or fabricated. Review combined-owner lock ordering, command/control self-deferral, direct-security exclusion, retained-context recovery and actual DER-copy/destructor semantics, plus API/UI partial-effect contracts. No target acceptance is claimed.
|
||||
|
||||
On device: compare stored fingerprint with trusted UART0 `ssh host-key info` and actual peer host key; confirm changed-known_hosts verification; rotate with active admin/user SSH and concurrent HTTPS/USB/full mix; verify all SSH disconnect while HTTPS stays accessible; test stopped rotation/reset recovery, service restart/CLI interleavings, broker cleanup failures, reboot persistence and realistic NVS/power-loss faults. Measure dispatcher/control/SSH/HTTPD stack high-water, heap/internal/DMA minima and throughput at160MHz. Confirm unchanged account roles/authorized keys and ordinary/restricted shell behavior. Hardware trust, timing, power-loss, real-wolfSSH allocation/failure behavior and runtime margins remain unmeasured. No new phase is authorized by this record.
|
||||
|
||||
---
|
||||
|
||||
## Historical HTTPS slice status (2026-09-13)
|
||||
|
||||
The following record preserves the completed HTTPS slice and its independent-review evidence. Its statements that SSH/full21 remain unimplemented are historical and superseded by the aggregate status above; its target-pending limits still apply.
|
||||
|
||||
**Chosen HTTPS slice implemented end-to-end, host-tested and build-verified.**
|
||||
Independent review complete with no confirmed actionable findings; target validation/sign-off remains pending. Full
|
||||
8D.21 remains incomplete: SSH identity work requires its separately requested
|
||||
owner/scope audit and implementation. No SSH identity changes were made here.
|
||||
|
||||
The previous agent delivered only an audit and baseline, modifying this document,
|
||||
`docs/agent/current-state.md` and `docs/phase8d_plan.md`. Those were the only initial
|
||||
worktree changes in this continuation. This implementation supersedes their
|
||||
NOT-implemented status, not prior user sign-offs or resource follow-ups.
|
||||
|
||||
Independent reviewer checked shared service/identity reservations, all canonical mutation callers, installed-IDF TLS material copies, commit/failure semantics, auth/ACK and public metadata. Reviewer reran security17, lifecycle44+2 integration groups, cookie lifecycle8+shared, UI/renderer/HTML/CSP, dispatcher/console lifecycle and diff checks PASS. Parent final `pio run` confirmation PASS6.98s,100,532 B RAM/1,825,073 B flash; diff check PASS. No target trust/reset/NVS power-loss or runtime-margin validation inferred.
|
||||
|
||||
## Delivered scope and user workflow
|
||||
|
||||
- Admin Settings → **HTTPS / Reboot** now also shows the **stored** HTTPS
|
||||
certificate's SHA-256 fingerprint, identity generation and service generation.
|
||||
This is public metadata behind current-admin authorization, not an unauthenticated
|
||||
endpoint, certificate download, private-key export or proof of the served identity.
|
||||
- **Rotate HTTPS identity…** confirms the old fingerprint and both generations,
|
||||
persistence, changed browser trust and loss of all web logins/both browser terminal
|
||||
routes, including clients admitted before execution. Save browser drafts first.
|
||||
- Verify the NEW fingerprint using trusted UART0 `web certificate info` before
|
||||
accepting changed browser trust. A browser certificate warning alone is not
|
||||
trusted verification. Reload and sign in freshly; never automatically retry.
|
||||
- A new identity may already be persisted even if stop/start fails. A retained
|
||||
HTTPD can still serve the old certificate; compare stored and served identity
|
||||
explicitly and recover using canonical `web stop` / `web start` when necessary.
|
||||
- SSH identity, accounts, Wi-Fi, serial configuration and other saved settings
|
||||
are unchanged. UART0 administration and native USB UART1 access are independent
|
||||
of this rotation. USB is not an administrative console. The preexisting reboot
|
||||
action still interrupts the entire device; rotation does not reboot it.
|
||||
- No duplicate browser Reset for healthy TLS material. Existing CLI
|
||||
`web reset --force` retains TLS-only replacement/recovery and starts a stopped
|
||||
HTTPS service; ordinary CLI rotation leaves a stopped service stopped. Neither
|
||||
touches users. Unavailable-user-database recovery stays UART0-only. No browser
|
||||
unavailable-material recovery, bootstrap, retained recovery secret, new password
|
||||
feature, configuration wipe, factory reset, OTA, encryption or secure boot.
|
||||
|
||||
## Ownership and admission contract
|
||||
|
||||
### Combined service/security owner
|
||||
|
||||
`web_server_replace_identity(service_generation, identity_generation, reset,
|
||||
&committed)` is the shared off-HTTPD owner entry point. Both nonzero generations
|
||||
select conditional healthy-running rotation; both zero select existing canonical
|
||||
CLI semantics. Reset cannot be combined with conditional generations.
|
||||
|
||||
1. Conditional calls avoid the blocking legacy initializer and take the existing
|
||||
server mutex with zero wait. Reject stale/saturated service generation, absent
|
||||
server, transition or failed cleanup **before any identity mutation**.
|
||||
2. Set `s_transitioning` under the server mutex, then release it. This reservation
|
||||
excludes canonical start/stop/restart/reboot and competing CLI identity work.
|
||||
3. Reserve the expected identity using `web_security_reserve_identity()`. Its
|
||||
zero-wait security mutex check excludes stale/unavailable/saturated identity,
|
||||
existing reservation and exhausted reservation IDs. Canonical direct
|
||||
`web_security_rotate_certificate()` / `web_security_reset_all()` acquire the
|
||||
same reservation, so they cannot bypass an admitted combined operation.
|
||||
4. After both reservations, advance the saturated service generation. Generate a
|
||||
local candidate and commit its bounded existing NVS blob without holding either
|
||||
mutex or a spinlock. Publish only after successful commit, wiping the old key.
|
||||
The security mutex protects short state admission/copies/publication, not crypto
|
||||
or flash. Initialization/migration retains its existing startup locking.
|
||||
5. Keep the identity reservation through canonical reserved stop/start. Start can
|
||||
copy the committed TLS pair under the ordinary security mutex without deadlock.
|
||||
Running rotation/reset restarts HTTPS; stopped rotation remains stopped and
|
||||
stopped reset starts HTTPS. No unreserved stop-to-start gap is introduced.
|
||||
6. Release the nonreused identity token on return. Only its reserving owner may
|
||||
replace once and release it; old tokens cannot replace/release a new reservation.
|
||||
|
||||
Identity generation advances only on committed replacement, retaining the existing
|
||||
persisted UINT32_MAX fail-closed rule. Service generation advances on admitted
|
||||
replacement and on each admitted stop/start, including failed attempts, saturating
|
||||
at UINT32_MAX without wrapping. Failed identity admission does not advance it;
|
||||
failed crypto/storage after combined admission can advance service generation
|
||||
without changing identity or stopping HTTPD. Reservation IDs never reuse; exhaustion
|
||||
denies identity mutations until reboot, while existing canonical service recovery
|
||||
remains available. Reboot invalidates all old browser sessions.
|
||||
|
||||
Public snapshot copies use separate zero-wait service/security observations, not
|
||||
one cross-owner atomic view. They authorize nothing; any intervening service or
|
||||
identity change is rejected by the combined execution-time reservations. Unavailable
|
||||
identity metadata yields generation0/empty fingerprint/rotatable false without
|
||||
removing ordinary lifecycle controls.
|
||||
|
||||
### Failures, revocation and console safety
|
||||
|
||||
- Generation, RNG, NVS open/set/commit errors before publication leave the live
|
||||
identity unchanged and do not stop HTTPD or revoke its logins. Host NVS faults
|
||||
also verify unchanged stored bytes; doubles do not prove power-loss behavior.
|
||||
- `committed=true` is irreversible publication even when stop/start returns an
|
||||
error. Stop failure skips start and retains canonical cleanup ownership. Failed
|
||||
start follows the existing retained-handle cleanup rules. No rollback to the old
|
||||
identity occurs. CLI reports the precommit/postcommit distinction; typed results
|
||||
deliberately retain conservative failure/unknown-outcome language.
|
||||
- Stop invokes canonical `web_cookie_auth_stop()` and transport detach/stopped
|
||||
paths, invalidating all web sessions/tickets and closing browser routes. No
|
||||
unrelated account or SSH revocation is added. Later logout/revocation is not
|
||||
cancellation of admitted crypto/commit/restart.
|
||||
- `web_console.c` rotate/reset wrappers and `web_admin_transport.c` deferred
|
||||
browser-shell rotation call the same combined owner, not separate commit/stop/start.
|
||||
Browser shell retains its existing parsed force policy and drain/dispatcher mask;
|
||||
its acknowledgement is still the bounded application-drain heuristic, not HTTP ACK.
|
||||
- Inspected `web_admin_transport_detach()`: it waits only for queue submitters, not
|
||||
console execution. `admin_ssh_console_close()` marks an executing slot inactive
|
||||
without waiting/wiping it; dispatcher return retires that slot. No self-console
|
||||
cleanup wait, command-string replay, HTTPD self-stop or lock-held HTTPD wait.
|
||||
|
||||
## Bounded HTTP and UI contracts
|
||||
|
||||
Reuse the three existing optional routes and single lifecycle operation slot:
|
||||
|
||||
- `GET /api/settings/lifecycle`: seven fields, **320-byte response buffer**:
|
||||
`generation`, `running`, `transitioning`, `controllable`, `identity_generation`,
|
||||
`fingerprint` (64 lowercase hex digits, SHA-256), `rotatable`.
|
||||
- `POST /api/settings/lifecycle-operation`: `stop|restart|reboot` still require
|
||||
exactly action + service generation. `rotate` requires exactly
|
||||
`{"action":"rotate","generation":7,"identity_generation":11}`.
|
||||
No reset, extra fields, duplicate fields, escaped/coerced values, zero/saturated
|
||||
generations or trailing bytes. Existing **256-byte/four-receive** bound retained.
|
||||
- `GET /api/settings/lifecycle-operation`: existing **96-byte** login-isolated
|
||||
`id/action/state` result. `failed` can mean rejected admission OR partial effects;
|
||||
no falsely precise success, cancellation, rollback or durable-history guarantee.
|
||||
|
||||
Current cookie/principal/admin checks protect all routes; mutations additionally
|
||||
require existing JSON/Origin/CSRF policy. The operation keeps the original login,
|
||||
not merely the account. Reuse8D.20's send-return → nonreused-ID HTTPD callback →
|
||||
existing dispatcher handoff: no crypto, persistence or lifecycle on HTTPD and no
|
||||
request/fd/operation pointer captured. Two-second ACK and post-validation30-second
|
||||
dequeue deadlines remain admission bounds, not peer receipt or execution deadlines.
|
||||
One accepted-but-lost callback reservation survives until callback arrival or
|
||||
successful HTTPD destruction; failed destruction cannot accumulate new callbacks.
|
||||
|
||||
`web_ui.c` extends the existing lifecycle view/controller, not a parallel Security
|
||||
controller. It validates the complete bounded public schema, captures both versions
|
||||
before confirmation/session validation, and shares single-flight/pending gates with
|
||||
all lifecycle actions. Identity unavailable/exhausted disables rotation. Explicit
|
||||
manual Check Result/Refresh,15-second whole-request deadline, late-result/context
|
||||
fencing and original-login rules remain. Unknown/lost ACK cannot clear the duplicate
|
||||
gate by adopting another result; navigation/pagehide does not replay or restore a
|
||||
mutation. No generated secret, secret input or retained retry material is introduced.
|
||||
No generated assets or loader/CSP changes; existing HTML/CSP tests pass.
|
||||
|
||||
## Validation performed
|
||||
|
||||
All commands below passed in this continuation; no hardware/runtime pass inferred.
|
||||
|
||||
- `python3 tests/web_security/run.py`: **17 production-security groups** with real
|
||||
host mbedTLS, including exact NVS open/set/commit transactional failures, real
|
||||
fingerprint/key changes, generation exhaustion, public-only projection, zero-wait
|
||||
contention, one-use/nonreused tokens and competing canonical writers during real
|
||||
crypto outside locks. Existing migration/invalid storage/recovery tests retained.
|
||||
- `python3 tests/web_admin_transport/server_lifecycle.py`: **44 orchestration/ACK
|
||||
groups plus2 integrated owner/security groups**. The second executable links the
|
||||
extracted production server owner to the complete production security implementation
|
||||
and real mbedTLS, with NVS/HTTPD/scheduler doubles. Tests real generation-time RNG
|
||||
and NVS open/set/commit failures, exact unchanged identity/storage before commit,
|
||||
committed identity surviving stop/start failures, stale service/identity rejection,
|
||||
and canonical/direct-security exclusion during crypto AND stop/start. The44 groups
|
||||
additionally cover CLI stopped rotation/reset, saturated/stale/busy/currentness
|
||||
admission, every detach failure, original ACK sequencing and lifecycle recovery.
|
||||
- `python3 tests/web_cookie_auth/run.py --lifecycle`: **8 extended lifecycle groups
|
||||
plus shared auth tests**, actual handlers/parser/auth/session store; rotate's exact
|
||||
third generation, malformed/truncated inputs, bounded fingerprint-only projection,
|
||||
all four actions off-handler/off-callback, login invalidation after admission,
|
||||
queue/send/lost/late callback/request storage reuse and deadline/wrap regressions.
|
||||
- `python3 tests/web_ui_session/run.py`: **156 browser behavior groups plus C-renderer,
|
||||
seven-view HTML and exact CSP checks**. Three new groups cover fingerprint/version
|
||||
confirmation, missing/invalid generation/fingerprint/secret-field rejection,
|
||||
unavailable identity, shared pending gate, partial-effect warning, role loss,
|
||||
lost ACK, expired login and no replay/restore. Existing lifecycle timing and
|
||||
navigation tests remain. Not real-browser geometry or TLS target validation.
|
||||
- `python3 tests/admin_console_boundary/run.py` and `lifecycle.py`: PASS, including
|
||||
deferred browser certificate dispatcher/executing-slot/self-detach safety and
|
||||
typed dispatcher queue isolation. `python3 tests/web_admin_transport/run.py`:
|
||||
**25 groups**; its owner dependency double now represents the combined API.
|
||||
- `python3 tests/web_cookie_auth/run.py` with no option and each of `--settings`,
|
||||
`--serial-settings`, `--accounts`, `--network`, `--display`, `--broker`, `--ssh`,
|
||||
`--lifecycle`, `--admin`: PASS.
|
||||
- `python3 tests/<suite>/run.py` for `admin_ssh_policy`, `web_auth_parse`,
|
||||
`web_session_store`, `web_httpd_idle`, `web_serial_performance`, `web_diagnostics`,
|
||||
`web_login_ui`, `ssh_management`, `web_network_settings`,
|
||||
`session_broker_diagnostics`, `web_security`, `web_admin_tickets`: PASS.
|
||||
`python3 tests/web_session_store/run.py --serial`: PASS.
|
||||
- Final combined owner/integration suite rerun after adding generation-time RNG and
|
||||
stop/start reservation interleavings: PASS. The initial integration-fixture compile
|
||||
found an unused copied test counter; fixture extraction was corrected, not compiler
|
||||
warnings disabled. No production test failure was hidden.
|
||||
- `git --no-pager diff --check`: PASS. Final worktree inspection shows only the
|
||||
intended source/tests/docs; no assets, configuration or device-operation changes.
|
||||
|
||||
## Build, resources and preserved work
|
||||
|
||||
- Prior audit baseline: `pio run` PASS7.17s, **100,508 B RAM / 1,821,505 B flash**.
|
||||
It was not redundantly rerun before implementation.
|
||||
- Intermediate implementation build: PASS21.92s,100,532 /1,824,889 B.
|
||||
- Final production `pio run`: **PASS22.78s,100,532 B RAM /1,825,073 B flash**.
|
||||
Final delta against audited baseline: **+24 B RAM /+3,568 B flash**.
|
||||
- Still **39 handlers/six sockets**, same existing lifecycle slot/dispatcher item
|
||||
and queue depth, no new task/timer/stack size/session/ticket/heap allocation/schema.
|
||||
Lifecycle snapshot buffer grows128→320 bytes; identity-operation slot adds one
|
||||
uint32 field; security adds bounded token/one-use state. The existing local TLS
|
||||
candidate is retained, not another persistent private-material allocation.
|
||||
- Defaults, active sdkconfig and generated sdkconfig.h independently checked:
|
||||
**CPU160MHz**. No configuration changes. Combined binary WebSocket send,
|
||||
signed-off throughput, serial broker isolation and native USB path unchanged.
|
||||
- No assets regeneration, SDK/dependency changes, device actions, upload, erase,
|
||||
commit or new branch. User/prior-agent documentation work preserved and updated.
|
||||
Runtime HTTPD/dispatcher stack margins and heap minima are not inferred from
|
||||
linked RAM or host sizeof; the larger snapshot handler frame needs target checks.
|
||||
|
||||
## Pending parent/target gates and remaining phase
|
||||
|
||||
- [ ] Independent parent review of combined ownership, direct canonical exclusions,
|
||||
snapshots/ACK/auth/parser/UI and failure/uncertainty contracts.
|
||||
- [ ] On target, verify old/new stored and served fingerprints, trusted UART0
|
||||
verification, browser trust renewal, fresh login and persisted identity after
|
||||
a normal reboot (no automatic reboot in this feature).
|
||||
- [ ] Verify canonical CLI reset with unavailable TLS material and stopped-service
|
||||
rotation/reset semantics, including low-memory/cleanup failure recovery.
|
||||
- [ ] Real-client lost response, timeout, two-admin concurrency, stale confirmations,
|
||||
changed role/login and cleanup behavior; no mutation replay.
|
||||
- [ ] Full mix at160MHz (two web serial, browser admin, SSH serial/admin and USB),
|
||||
unchanged binary transparency/writer isolation, UART0 recovery/native USB
|
||||
availability through HTTPS rotation, HTTPD/dispatcher stack and heap minima.
|
||||
- [ ] User target sign-off. No reserve approval, full M3 or full8D.21 claim.
|
||||
|
||||
**HTTPS slice has no remaining implementation item identified here.** SSH identity
|
||||
metadata/rotation/reset remains outside this slice and requires a separate request;
|
||||
no speculative SSH owner design or implementation is included.
|
||||
@@ -187,16 +187,17 @@ Typed operations must preserve subsystem owner/lock/persistence contracts and co
|
||||
| **8D.12 — Network settings without secret mutation** | Secret-free STA/AP/profile and mDNS settings, non-secret edits, and explicit persistence through `wifi_manager`, `wifi_config`, `mdns_service`/`mdns_config`. | Responses never serialize saved PSKs; validate working/persisted semantics, live hostname changes, and behavior after connection loss. No new manager/task or Wi-Fi blob migration. Split mDNS into a follow-up if needed. |
|
||||
| **8D.13 — Wi-Fi secrets and connection controls** | Explicit password replacement/clear semantics, bounded transient input, profile selection/reconnect and AP policy actions using manager-owned operations. | Preserve existing secrets when fields are omitted; never prefill saved secrets; document apply/save and likely connection loss; reconnect via STA/AP and verify UART0/USB recovery. No background secret fetch or general credential export. |
|
||||
| **8D.14 — Display settings** | **Implemented, host/build verified; target pending.** Typed local display configuration and explicit persistence via `local_ui_config`/generation-checked public UI APIs; [contract/evidence](phase8d14_implementation.md). | Host limits/storage/CLI-generation/activity/lifecycle regressions pass; actual save/reboot, absent-display and concurrent buttons/CLI target checklist remains pending. No I2C ownership changes or electrical diagnostics UI. |
|
||||
|
||||
| **8D.16 — Broker client visibility and writer transfer** | Admin-only detailed client snapshot plus explicit confirmed writer assignment using existing broker APIs; smallest broker change only if authoritative generation-safe validation is missing. | Stale/disconnected/reused target fails without changing the current lease; exactly one writer; normal users cannot obtain management details or transfer. Test concurrent USB/SSH/browser requests. No transfer on page open or selection alone. |
|
||||
| **8D.17 — Serial/Wi-Fi quick popovers** | UI-only reuse of completed typed endpoints, with full-page links and shared validation; start in `web_ui`. | Hover, focus, click/tap parity, Escape/outside-click dismissal, no mutation on opening, explicit apply/save, no secret exposure. No duplicate backend or new settings scope. |
|
||||
| **8D.18 — Client/writer contextual dialogs** | Reuse 8D.16 for live client popover and confirmed Active writer dialog. | Accessible pointer/keyboard/touch paths; refresh preserves explicit selection safely; stale confirmation is rejected visibly; normal users retain only ordinary status. No new writer policy. |
|
||||
| **8D.19 — Ordinary service/session controls** | Typed service status and targeted disconnect/start/stop controls, excluding actions that cut off the invoking HTTPS session; start in existing service APIs and generation-safe snapshots. | Explicit scope/confirmation, stale target rejection, owner-safe execution and failure isolation. Stop/start and disconnect do not clear settings/identities. Split by service if more than one owner adaptation is necessary. |
|
||||
| **8D.20 — Self-affecting service actions and reboot** | Confirmed typed operations for connection-losing HTTPS/Wi-Fi/service actions and reboot, reusing lifecycle behavior established in 8D.7/8D.13. | Acknowledgement/pending-action handling is bounded, no duplicate action on UI retry, connection loss is explained, recovery and reconnect work. No new unbounded queue or certificate/key rotation yet. |
|
||||
| **8D.21 — Security/danger-zone settings** | Carefully separated HTTPS/SSH identity rotation/reset and any explicitly retained recovery-secret operation through existing security APIs. Enumerate allowed operations first; split HTTPS and SSH work into separate requests if both need owner changes. | Confirmation, secret-safe one-time responses, no routine private-key export, expected trust/fingerprint changes, bounded self-disconnect, no accidental configuration wipe. Bootstrap/unavailable-database recovery remain UART0-only. NVS encryption, secure boot, OTA, and new factory-reset semantics stay out of scope. |
|
||||
| **8D.16 — Broker client visibility and writer transfer** | **Implemented, host/build verified; independent parent review and target sign-off pending.** Admin-only detailed snapshot plus explicit confirmed assignment through existing dispatcher/broker; smallest owner changes add atomic lease-version/target validation and nonwrapping IDs. [Contract/evidence](phase8d16_implementation.md). | Host stale/disconnected/reused target and lease ABA rejection, one writer, admin denial, bounded/session-isolated results, lifecycle and UI non-mutation regressions pass. Concurrent physical USB/SSH/browser/full-mix and stack-margin checklist remain pending. No transfer on page open/selection; no8D.17/18 popovers/icons. |
|
||||
| **8D.17 — Serial/Wi-Fi quick popovers** | **Implemented, host/build verified; independent parent review and target sign-off pending.** UI-only reuse of completed typed endpoints through one settings DOM/controller, full-page link and shared validation; `web_ui`. [Contract/evidence](phase8d17_implementation.md). | Hover/focus/click/tap, Escape/outside dismissal, no opening/selection mutation, explicit Apply/Save, Network password exclusion; draft/session/pending-operation regressions pass. Full-page hover deliberately preserves its existing draft; explicit activation navigates that page. Optional geometry blocked by sandbox; real pointer/keyboard/touch/full-mix checklist pending. No duplicate backend/new settings scope/icons/8D.18. |
|
||||
| **8D.18 — Client/writer contextual dialogs** | **Implemented, host/build verified; independent parent review and target sign-off pending.** UI-only reuse of8D.16 and8D.17's single host for live client popover and confirmed Active writer dialog. [Contract/tests/checklist](phase8d18_implementation.md). | Native pointer/keyboard/touch entrances; single-flight5-second live refresh/deadline, explicit selection preserved without lease-token renewal, sticky stale/absent rejection, full-page draft protection and focus-safe updates. Ordinary users retain only ordinary status.135 UI groups plus broad broker/auth/lifecycle regressions pass; real browser/device checks pending. No new writer policy/backend/icons/8D.19/later. |
|
||||
| **8D.19 — Ordinary service/session controls** | **First service slice SSH implemented, host/build verified; independent parent review and target sign-off pending. Phase incomplete.** Typed SSH status and confirmed exact-session disconnect/start/stop via existing dispatcher/SSH owner, excluding invoking HTTPS-session-cutting actions. [SSH contract/tests/resources](phase8d19_implementation.md). | Explicit SSH/all-SSH/one-session confirmation; owner lock/service generation/retired session IDs reject stale/reused targets and stop/start ABA. No settings/identity clear. SSH4, cookie SSH6+shared, dispatcher, lifecycle27 and UI143 PASS. Split-by-service rule applied: all web-session/HTTPS/USB controls excluded; next other-session web slice requires explicit login/owner-safety audit, USB actions are not promised. No generic broker disconnect or8D.20/21. Target full-mix/heap/stack/recovery checks pending. |
|
||||
| **8D.20 — Self-affecting service actions and reboot** | **User-authorized HTTPS stop/restart/reboot integration implemented, host/build verified; independent parent review and target sign-off pending.** Current-admin typed routes, bounded send-return/HTTPD ID callback/existing dispatcher handoff, canonical generation/reserved lifecycle and shared reset API; explicit Settings HTTPS/Reboot and link to existing Network. [Contracts, tests, costs and checklist](phase8d20_implementation.md). | PASS lifecycle41, cookie lifecycle8+shared, UI153+HTML/CSP, dispatcher and broad regressions. Tests cover queue/send/lost/late callback/request-lifetime/ABA/login revocation/deadlines/owner failures, no replay/late result adoption, all-client/unsaved-state and accurate UART0/SSH/USB recovery. Final pio100,508 RAM/1,821,505 flash (+104/+13,064 vs pre-phase).39 handlers/six sockets, no new tasks/timers/queue growth/assets/config/identity or unrelated19/21. Real TLS/scheduling/reboot/full-mix/runtime reserves and independent review remain pending. |
|
||||
| **8D.21 — Security/danger-zone settings** | **Allowed HTTPS+SSH identity implementation complete, host/build verified; SSH independent parent review and all target sign-off pending.** Remaining SSH slice reuses19 routes/controller/dispatcher for fixed P-256 fingerprint/algorithm and confirmed rotation, with service+identity reservation shared by canonical CLI/deferred SSH/direct security, retained-context failed-stop safety and manual15-second/no replay. SSH security5+runtime+management5, cookie SSH7/UI158 and broad regressions PASS; final100,556 RAM/1,828,573 flash, CPU160. [Current aggregate contract/evidence](phase8d21_implementation.md). **Preserved HTTPS slice history:** Public stored certificate fingerprint/identity+service generations and confirmed rotation reuse8D.20 routes/ACK slot/dispatcher/UI controller. Shared service-before-identity reservation covers canonical CLI/browser-shell/direct security mutation exclusion through crypto/commit/stop/start; no rollback after commit. Canonical TLS-only reset/recovery retained without duplicate browser Reset. [Exact HTTPS contract/tests/resources and pending gates](phase8d21_implementation.md). **SSH identity continuation now implemented as described above**; no recovery-secret operation added. | PASS security17, lifecycle44+two real-mbedTLS/NVS integration groups, cookie lifecycle8+shared/all variants, UI156+HTML/CSP and broad regressions. Final pio100,532 RAM/1,825,073 flash (+24/+3,568 vs audited baseline),39 handlers/six sockets/no new task/timer/queue/assets/config. Confirm fingerprint/both generations, warn changed trust, trusted UART0 verification/fresh login, partial effects and no replay. No private-key/certificate export, browser invalid-material recovery or configuration wipe. Bootstrap/unavailable-database recovery remain UART0-only; NVS encryption/secure boot/OTA/new factory reset excluded. Parent/target trust/persistence/full-mix/runtime reserve gates pending. |
|
||||
|
||||
**Scope decision (2026-09-09):** Phase 8D.15 has been removed at the user's request. Network diagnostics remain exclusive to the admin shell; no dedicated typed diagnostic endpoints or settings UI are planned. Existing shell transport permissions and implemented Network settings/status remain unchanged. Later phase numbers are retained for stable references; the next planned chunk after 8D.14 is 8D.16, requiring a separate implementation request.
|
||||
|
||||
**8D.20 authorization clarification (2026-09-13):** The user explicitly authorized20 with knowledge that19 completed/reviewed only its SSH slice. Proceed using needed established prerequisites; do not implement unrelated other-service19 as a dependency. The internal owner prerequisite was not full20 delivery; the explicitly authorized continuation now completes API/ACK/dispatcher/UI integration and records pending independent review/target checks in the linked contract.
|
||||
|
||||
Dependencies: 8D.8 establishes only the minimal typed-request/UI pattern. 8D.9 follows 8D.8; 8D.11 follows 8D.10; 8D.13 follows 8D.12; 8D.17 follows 8D.9/8D.13; 8D.18 follows 8D.16; 8D.20 follows 8D.7/8D.13/8D.19; 8D.21 follows 8D.20. Independent domains can be reordered by the user, but should not be implemented concurrently against shared `web_server`/`web_ui` files.
|
||||
|
||||
## 8D.22 — Final integration acceptance and documentation
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
This document retains phase-specific regression procedures. The Phase 8A and 8B sections describe the behavior of those historical implementation baselines; they are not the current end-to-end acceptance behavior. In current Phase 8C firmware, HTTPS and SSH authenticate through the user database, role-`user` SSH sessions receive the broker-backed serial stream, and role-`admin` SSH sessions receive the administration shell. Use the Phase 8C section for current routing and shared-console validation. Never include generated or entered passwords in test logs.
|
||||
|
||||
## Current Broker management regression procedure — 8D.16
|
||||
|
||||
Implemented and host/build verified; **independent parent review, target validation and user sign-off pending**. The [8D.16 contract and target checklist](phase8d16_implementation.md) defines exact API, generation/wrap safety, resource bounds and evidence limits. This procedure is not an execution claim.
|
||||
|
||||
- Verify admin-only detailed client rows against non-consuming UART0 snapshots/counters; direct normal-user management GET/POST denied. Observe zero/one/full client mix without reading UART payloads as a probe.
|
||||
- Opening, selecting, cancelling, refreshing and navigating must leave writer and both terminals unchanged. Refresh clears selection. Native confirmation must identify exact target and snapshot writer; no8D.17/18 popover behavior is included.
|
||||
- Assign among USB/SSH/two browser serial clients with both admins connected. Disconnect/reuse target while confirmation is open; race writer release/reacquire, competing requests, shell force and local release. Stale confirmation must fail without changing the intervening lease; Refresh/reselect/confirm explicitly.
|
||||
- Test separate-login result isolation, same-login tab replacement, pending capacity, lost ACK/result, bounded polling/manual recovery, logout/expiry/revocation and HTTPS stop/restart. Never infer cancellation from connection loss or replay automatically.
|
||||
- At signed-off160MHz/230400 baud, check full-mix binary traffic/drop isolation, UART0/USB recovery and optional-route failure isolation. Capture exact revision/mix and internal/DMA/PSRAM plus HTTPD/dispatcher margins; host tests/build do not approve reserves or target throughput for this phase.
|
||||
|
||||
## Current Network settings regression procedure — 8D.12/8D.13
|
||||
|
||||
Both phases were authorized and implemented together. The [implementation record](phase8d12_13_implementation.md) defines the complete API, limits, result states, exclusions and pending target checklist. Backend/cookie Network PASS, UI agent97+renderer/CSP/review PASS and lifecycle agent21 PASS are reported evidence; final parent build/tests and target sign-off remain pending. Do not treat this procedure as executed or infer M3 completion/8D.14 authorization.
|
||||
|
||||
@@ -35,6 +35,9 @@ idf_component_register(
|
||||
"web_account_settings.c"
|
||||
"web_network_settings.c"
|
||||
"web_display_settings.c"
|
||||
"web_broker_settings.c"
|
||||
"web_ssh_settings.c"
|
||||
"web_lifecycle_settings.c"
|
||||
"web_admin_tickets.c"
|
||||
"web_admin_transport.c"
|
||||
"web_assets_data.c"
|
||||
|
||||
+46
-2
@@ -20,6 +20,9 @@
|
||||
#include "web_account_settings.h"
|
||||
#include "web_network_settings.h"
|
||||
#include "web_display_settings.h"
|
||||
#include "web_broker_settings.h"
|
||||
#include "web_ssh_settings.h"
|
||||
#include "web_lifecycle_settings.h"
|
||||
|
||||
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
|
||||
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
|
||||
@@ -89,6 +92,9 @@ typedef enum {
|
||||
ADMIN_REQUEST_ACCOUNT_SETTINGS,
|
||||
ADMIN_REQUEST_NETWORK_SETTINGS,
|
||||
ADMIN_REQUEST_DISPLAY_SETTINGS,
|
||||
ADMIN_REQUEST_BROKER_SETTINGS,
|
||||
ADMIN_REQUEST_SSH_SETTINGS,
|
||||
ADMIN_REQUEST_LIFECYCLE_SETTINGS,
|
||||
} admin_request_origin_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -103,6 +109,9 @@ typedef struct {
|
||||
uint32_t account_settings_id;
|
||||
uint32_t network_settings_id;
|
||||
uint32_t display_settings_id;
|
||||
uint32_t broker_settings_id;
|
||||
uint32_t ssh_settings_id;
|
||||
uint32_t lifecycle_settings_id;
|
||||
};
|
||||
} admin_request_t;
|
||||
|
||||
@@ -698,6 +707,36 @@ esp_err_t admin_ssh_console_submit_display_settings(uint32_t id)
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready || !id) return ESP_ERR_INVALID_STATE;
|
||||
admin_request_t request = {.origin = ADMIN_REQUEST_BROKER_SETTINGS, .broker_settings_id = id};
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready || !id) return ESP_ERR_INVALID_STATE;
|
||||
admin_request_t request = {.origin = ADMIN_REQUEST_SSH_SETTINGS, .ssh_settings_id = id};
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready || !id) return ESP_ERR_INVALID_STATE;
|
||||
admin_request_t request = {.origin = ADMIN_REQUEST_LIFECYCLE_SETTINGS, .lifecycle_settings_id = id};
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
static void worker_task(void *context)
|
||||
{
|
||||
(void)context;
|
||||
@@ -707,11 +746,16 @@ static void worker_task(void *context)
|
||||
continue;
|
||||
}
|
||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS ||
|
||||
request.origin == ADMIN_REQUEST_NETWORK_SETTINGS || request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) {
|
||||
request.origin == ADMIN_REQUEST_NETWORK_SETTINGS || request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS ||
|
||||
request.origin == ADMIN_REQUEST_BROKER_SETTINGS || request.origin == ADMIN_REQUEST_SSH_SETTINGS ||
|
||||
request.origin == ADMIN_REQUEST_LIFECYCLE_SETTINGS) {
|
||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) web_network_settings_execute(request.network_settings_id);
|
||||
else web_display_settings_execute(request.display_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) web_display_settings_execute(request.display_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_BROKER_SETTINGS) web_broker_settings_execute(request.broker_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_SSH_SETTINGS) web_ssh_settings_execute(request.ssh_settings_id);
|
||||
else web_lifecycle_settings_execute(request.lifecycle_settings_id);
|
||||
secure_wipe(&request, sizeof(request));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,9 @@ esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id);
|
||||
|
||||
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
|
||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||
|
||||
+51
-5
@@ -47,6 +47,8 @@ static session_broker_slot_t s_slots[SESSION_BROKER_MAX_CLIENTS];
|
||||
static session_broker_client_id_t s_writer_id;
|
||||
static uint32_t s_connected_clients;
|
||||
static uint64_t s_event_sequence;
|
||||
/* Saturation disables management confirmations, never ordinary recovery. */
|
||||
static uint32_t s_writer_generation = 1U;
|
||||
static session_broker_global_counters_t s_counters;
|
||||
static bool s_initialized;
|
||||
|
||||
@@ -102,6 +104,10 @@ static void broadcast_event_locked(session_broker_event_type_t type,
|
||||
session_broker_client_id_t client_id,
|
||||
session_broker_client_id_t writer_id)
|
||||
{
|
||||
if ((type == SESSION_BROKER_EVENT_WRITER_GRANTED ||
|
||||
type == SESSION_BROKER_EVENT_WRITER_RELEASED ||
|
||||
type == SESSION_BROKER_EVENT_WRITER_REVOKED) && s_writer_generation != UINT32_MAX)
|
||||
++s_writer_generation;
|
||||
session_broker_event_t event = {
|
||||
.sequence = ++s_event_sequence,
|
||||
.type = type,
|
||||
@@ -299,7 +305,8 @@ esp_err_t session_broker_connect(session_broker_client_type_t type,
|
||||
session_broker_slot_t *slot = NULL;
|
||||
size_t slot_index = 0U;
|
||||
for (; slot_index < SESSION_BROKER_MAX_CLIENTS; ++slot_index) {
|
||||
if (!s_slots[slot_index].connected) {
|
||||
if (!s_slots[slot_index].connected &&
|
||||
s_slots[slot_index].generation < SESSION_BROKER_MAX_GENERATION) {
|
||||
slot = &s_slots[slot_index];
|
||||
break;
|
||||
}
|
||||
@@ -319,9 +326,7 @@ esp_err_t session_broker_connect(session_broker_client_type_t type,
|
||||
}
|
||||
|
||||
uint32_t generation = slot->generation + 1U;
|
||||
if (generation == 0U || generation > SESSION_BROKER_MAX_GENERATION) {
|
||||
generation = 1U;
|
||||
}
|
||||
/* Exhausted slots are retired until reboot: no 29-bit ID reuse. */
|
||||
|
||||
xStreamBufferReset(slot->output);
|
||||
xQueueReset(slot->events);
|
||||
@@ -470,13 +475,19 @@ esp_err_t session_broker_release_writer(session_broker_client_id_t client_id)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
static esp_err_t broker_force_writer(session_broker_client_id_t client_id,
|
||||
uint32_t expected_generation)
|
||||
{
|
||||
if (!s_initialized) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
if (expected_generation && (expected_generation == UINT32_MAX ||
|
||||
expected_generation != s_writer_generation)) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
session_broker_slot_t *new_writer = NULL;
|
||||
if (client_id != SESSION_BROKER_NO_CLIENT) {
|
||||
new_writer = find_slot_locked(client_id);
|
||||
@@ -522,6 +533,41 @@ esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
{
|
||||
return broker_force_writer(client_id, 0);
|
||||
}
|
||||
|
||||
esp_err_t session_broker_assign_writer_current(session_broker_client_id_t client_id,
|
||||
uint32_t generation)
|
||||
{
|
||||
if (!client_id || !generation) return ESP_ERR_INVALID_ARG;
|
||||
return broker_force_writer(client_id, generation);
|
||||
}
|
||||
|
||||
esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot) return ESP_ERR_INVALID_ARG;
|
||||
if (!s_initialized) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
snapshot->generation = s_writer_generation;
|
||||
snapshot->writer_id = s_writer_id;
|
||||
for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) {
|
||||
const session_broker_slot_t *slot = &s_slots[i];
|
||||
if (!slot->connected) continue;
|
||||
session_broker_management_client_t *client = &snapshot->clients[snapshot->count++];
|
||||
client->id = slot->id;
|
||||
client->type = slot->type;
|
||||
memcpy(client->name, slot->name, sizeof(client->name));
|
||||
client->pending = xStreamBufferBytesAvailable(slot->output);
|
||||
client->high_water = slot->counters.output_high_water_bytes;
|
||||
client->dropped = slot->counters.output_dropped_bytes;
|
||||
}
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_force_release_writer(
|
||||
session_broker_client_id_t expected_writer_id)
|
||||
{
|
||||
|
||||
@@ -120,6 +120,31 @@ typedef struct {
|
||||
session_broker_global_counters_t counters;
|
||||
} session_broker_global_snapshot_t;
|
||||
|
||||
/* Compact, atomic, non-consuming management projection. No transport pointers. */
|
||||
typedef struct {
|
||||
session_broker_client_id_t id;
|
||||
session_broker_client_type_t type;
|
||||
char name[SESSION_BROKER_CLIENT_NAME_MAX + 1U];
|
||||
size_t pending, high_water;
|
||||
uint64_t dropped;
|
||||
} session_broker_management_client_t;
|
||||
typedef struct {
|
||||
uint32_t generation;
|
||||
session_broker_client_id_t writer_id;
|
||||
size_t count;
|
||||
session_broker_management_client_t clients[SESSION_BROKER_MAX_CLIENTS];
|
||||
} session_broker_management_snapshot_t;
|
||||
|
||||
/* Zero-wait atomic snapshot. Generation survives counter clears; UINT32_MAX
|
||||
* means confirmations exhausted until reboot. Every lease transition advances
|
||||
* it, including release/reacquire ABA. Client IDs never wrap within a boot. */
|
||||
esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *snapshot);
|
||||
/* Nonzero target and generation required; compare + target validation + transfer
|
||||
* share the broker lock. Stale/exhausted generation or absent target has no effects.
|
||||
* Existing unconditional force remains available to recovery/console callers. */
|
||||
esp_err_t session_broker_assign_writer_current(session_broker_client_id_t client_id,
|
||||
uint32_t generation);
|
||||
|
||||
/*
|
||||
* Allocates all eight output streams and event queues, then starts the
|
||||
* permanent broker task. The serial service must already be initialized
|
||||
|
||||
+83
-45
@@ -48,6 +48,9 @@ static bool s_mutex_creating;
|
||||
static ssh_security_blob_t s_material;
|
||||
static bool s_material_ready;
|
||||
static ssh_security_load_result_t s_load_result;
|
||||
static uint32_t s_identity_token, s_next_identity_token;
|
||||
static TaskHandle_t s_identity_owner;
|
||||
static bool s_identity_used;
|
||||
|
||||
static bool bytes_are_zero(const uint8_t *data, size_t size)
|
||||
{
|
||||
@@ -338,6 +341,10 @@ esp_err_t ssh_security_init(ssh_security_load_result_t *load_result)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
if (s_identity_token) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
ssh_security_blob_t candidate;
|
||||
bool missing = false;
|
||||
error = load_blob(&candidate, &missing);
|
||||
@@ -411,60 +418,91 @@ esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata)
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_rotate(void)
|
||||
esp_err_t ssh_security_get_identity_snapshot(ssh_security_identity_snapshot_t *snapshot)
|
||||
{
|
||||
if (s_security_mutex == NULL) {
|
||||
if (!snapshot) return ESP_ERR_INVALID_ARG;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
esp_err_t error = s_material_ready ? ESP_OK : ESP_ERR_INVALID_STATE;
|
||||
if (error == ESP_OK) {
|
||||
snapshot->metadata.generation = s_material.generation;
|
||||
memcpy(snapshot->metadata.sha256_fingerprint, s_material.sha256_fingerprint,
|
||||
sizeof(snapshot->metadata.sha256_fingerprint));
|
||||
snapshot->busy = s_identity_token != 0 || s_next_identity_token == UINT32_MAX;
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token)
|
||||
{
|
||||
if (!token || (reset && generation)) return ESP_ERR_INVALID_ARG;
|
||||
*token = 0;
|
||||
if (reset) {
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error == ESP_OK) error = ensure_mutex();
|
||||
if (error != ESP_OK) return error;
|
||||
}
|
||||
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
if (s_identity_token || s_next_identity_token == UINT32_MAX ||
|
||||
(!s_material_ready && !reset) ||
|
||||
(s_material_ready && s_material.generation == UINT32_MAX) ||
|
||||
(generation && generation != s_material.generation)) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
ssh_security_blob_t candidate;
|
||||
memset(&candidate, 0, sizeof(candidate));
|
||||
if (s_material_ready && s_material.generation != UINT32_MAX) {
|
||||
error = generate_blob(&candidate, s_material.generation + 1U);
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_blob(&candidate);
|
||||
}
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
*token = s_identity_token = ++s_next_identity_token;
|
||||
s_identity_owner = xTaskGetCurrentTaskHandle();
|
||||
s_identity_used = false;
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_reset(void)
|
||||
esp_err_t ssh_security_replace_reserved(uint32_t token)
|
||||
{
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = ensure_mutex();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (!s_security_mutex || !token) return ESP_ERR_INVALID_STATE;
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
uint32_t generation = 1U;
|
||||
if (s_material_ready) {
|
||||
if (s_material.generation == UINT32_MAX) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
generation = s_material.generation + 1U;
|
||||
if (s_identity_token != token || s_identity_used ||
|
||||
s_identity_owner != xTaskGetCurrentTaskHandle()) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
ssh_security_blob_t candidate;
|
||||
error = generate_blob(&candidate, generation);
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_blob(&candidate);
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
s_identity_used = true;
|
||||
uint32_t generation = s_material_ready ? s_material.generation + 1U : 1U;
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
|
||||
/* Reservation excludes writers while crypto and flash run outside locks. */
|
||||
ssh_security_blob_t candidate = {0};
|
||||
esp_err_t error = generate_blob(&candidate, generation);
|
||||
if (error == ESP_OK) error = save_blob(&candidate);
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
if (error == ESP_OK) install_blob(&candidate);
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
return error;
|
||||
}
|
||||
|
||||
void ssh_security_release_identity(uint32_t token)
|
||||
{
|
||||
if (!s_security_mutex || !token) return;
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
if (s_identity_token == token && s_identity_owner == xTaskGetCurrentTaskHandle()) {
|
||||
s_identity_token = 0;
|
||||
s_identity_owner = NULL;
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
}
|
||||
|
||||
static esp_err_t replace_identity(bool reset)
|
||||
{
|
||||
uint32_t token = 0;
|
||||
esp_err_t error = ssh_security_reserve_identity(0, reset, &token);
|
||||
if (error == ESP_OK) error = ssh_security_replace_reserved(token);
|
||||
ssh_security_release_identity(token);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_rotate(void) { return replace_identity(false); }
|
||||
esp_err_t ssh_security_reset(void) { return replace_identity(true); }
|
||||
|
||||
+17
-1
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
@@ -38,7 +39,22 @@ esp_err_t ssh_security_copy_private_key(uint8_t *output, size_t capacity,
|
||||
size_t *output_length);
|
||||
esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata);
|
||||
|
||||
/* Caller must stop SSH first. Rotation requires valid live material; reset replaces any stored state. */
|
||||
typedef struct {
|
||||
ssh_security_metadata_t metadata;
|
||||
bool busy;
|
||||
} ssh_security_identity_snapshot_t;
|
||||
|
||||
/* Zero-wait atomic public projection; no private material. */
|
||||
esp_err_t ssh_security_get_identity_snapshot(ssh_security_identity_snapshot_t *snapshot);
|
||||
/* Owner transaction: nonreused token, reserve before side effects and retain through
|
||||
* restart. Only the reserving task may replace once and release. Zero generation
|
||||
* selects canonical semantics; reset additionally permits unavailable material. */
|
||||
esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token);
|
||||
esp_err_t ssh_security_replace_reserved(uint32_t token);
|
||||
void ssh_security_release_identity(uint32_t token);
|
||||
|
||||
/* Rotation requires valid live material; reset replaces any stored state.
|
||||
* Direct callers share the reservation but do not restart the transport. */
|
||||
esp_err_t ssh_security_rotate(void);
|
||||
esp_err_t ssh_security_reset(void);
|
||||
|
||||
|
||||
+121
-35
@@ -100,6 +100,8 @@ static bool s_running;
|
||||
static bool s_transitioning;
|
||||
static bool s_desired_running;
|
||||
static bool s_cleanup_pending;
|
||||
/* Saturates independently of the internal completion sequence; never reset by counters. */
|
||||
static uint32_t s_management_generation = 1U;
|
||||
static uint32_t s_requested_sequence;
|
||||
static uint32_t s_completed_sequence;
|
||||
static esp_err_t s_command_result = ESP_ERR_INVALID_STATE;
|
||||
@@ -705,6 +707,11 @@ static esp_err_t create_listener(void)
|
||||
|
||||
static esp_err_t start_runtime(void)
|
||||
{
|
||||
/* Never overwrite an orphaned context/listener or sessions after failed stop. */
|
||||
if (s_context != NULL || s_listen_fd >= 0) return ESP_ERR_INVALID_STATE;
|
||||
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
if (s_slots[index].state != SSH_TRANSPORT_SESSION_FREE) return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
esp_err_t error = create_context();
|
||||
if (error == ESP_OK) {
|
||||
error = create_listener();
|
||||
@@ -750,7 +757,7 @@ static esp_err_t stop_runtime(void)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (s_context != NULL) {
|
||||
if (all_free && s_context != NULL) {
|
||||
wolfSSH_CTX_free(s_context);
|
||||
s_context = NULL;
|
||||
}
|
||||
@@ -837,7 +844,8 @@ static ssh_slot_t *find_free_slot(size_t *slot_index)
|
||||
}
|
||||
|
||||
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
if (s_slots[index].state == SSH_TRANSPORT_SESSION_FREE) {
|
||||
if (s_slots[index].state == SSH_TRANSPORT_SESSION_FREE &&
|
||||
s_slots[index].generation < SSH_TRANSPORT_GENERATION_MAX) {
|
||||
*slot_index = index;
|
||||
return &s_slots[index];
|
||||
}
|
||||
@@ -907,7 +915,8 @@ static void accept_connections(void)
|
||||
(void)setsockopt(socket_fd, IPPROTO_TCP, TCP_NODELAY,
|
||||
&enabled, sizeof(enabled));
|
||||
|
||||
uint32_t generation = next_generation(slot->generation);
|
||||
/* Exhausted slots are retired by find_free_slot(), never reused after wrap. */
|
||||
uint32_t generation = slot->generation + 1U;
|
||||
memset(slot, 0, sizeof(*slot));
|
||||
slot->state = SSH_TRANSPORT_SESSION_HANDSHAKE;
|
||||
slot->generation = generation;
|
||||
@@ -1389,10 +1398,18 @@ static void process_slots(void)
|
||||
}
|
||||
if (all_free) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!s_running) {
|
||||
s_cleanup_pending = false;
|
||||
}
|
||||
bool stopped = !s_running;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
/* The owner alone retires the retained context, before reopening admission. */
|
||||
if (stopped) {
|
||||
if (s_context != NULL) {
|
||||
wolfSSH_CTX_free(s_context);
|
||||
s_context = NULL;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_cleanup_pending = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1512,6 +1529,7 @@ static esp_err_t request_running_locked(bool desired)
|
||||
return ESP_OK;
|
||||
}
|
||||
s_desired_running = desired;
|
||||
if (s_management_generation != UINT32_MAX) ++s_management_generation;
|
||||
s_transitioning = true;
|
||||
s_requested_sequence = next_generation(s_requested_sequence);
|
||||
sequence = s_requested_sequence;
|
||||
@@ -1558,46 +1576,54 @@ esp_err_t ssh_transport_stop(void)
|
||||
return request_running(false);
|
||||
}
|
||||
|
||||
esp_err_t ssh_transport_replace_host_key(bool reset)
|
||||
esp_err_t ssh_transport_replace_identity(uint32_t service_generation,
|
||||
uint32_t identity_generation,
|
||||
bool reset, bool *committed)
|
||||
{
|
||||
if (s_command_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
xSemaphoreTake(s_command_mutex, portMAX_DELAY);
|
||||
if (!committed || (!!service_generation != !!identity_generation) ||
|
||||
(reset && service_generation) || service_generation == UINT32_MAX ||
|
||||
identity_generation == UINT32_MAX) return ESP_ERR_INVALID_ARG;
|
||||
*committed = false;
|
||||
if (!s_command_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_command_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
|
||||
bool was_running;
|
||||
bool cleanup_pending;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!s_initialized || s_transitioning) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
xSemaphoreGive(s_command_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
was_running = s_running;
|
||||
cleanup_pending = s_cleanup_pending;
|
||||
bool valid = s_initialized && !s_transitioning &&
|
||||
(!service_generation || (!s_cleanup_pending && service_generation == s_management_generation));
|
||||
bool was_running = s_running, cleanup_pending = s_cleanup_pending;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
esp_err_t error = ESP_OK;
|
||||
if (was_running || cleanup_pending) {
|
||||
error = request_running_locked(false);
|
||||
}
|
||||
uint32_t token = 0;
|
||||
esp_err_t error = valid ? ssh_security_reserve_identity(identity_generation, reset, &token)
|
||||
: ESP_ERR_INVALID_STATE;
|
||||
if (error == ESP_OK) {
|
||||
error = reset ? ssh_security_reset() : ssh_security_rotate();
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
if (was_running) {
|
||||
(void)request_running_locked(true);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_management_generation != UINT32_MAX) ++s_management_generation;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
/* Keep the service mutex and identity reservation through stop/replace/start.
|
||||
* Failed stop must never mutate identity or attempt another start. */
|
||||
if (was_running || cleanup_pending) error = request_running_locked(false);
|
||||
if (error == ESP_OK) {
|
||||
error = ssh_security_replace_reserved(token);
|
||||
*committed = error == ESP_OK;
|
||||
if (error != ESP_OK && was_running) {
|
||||
/* Stop succeeded: restore service using unchanged committed material. */
|
||||
(void)request_running_locked(true);
|
||||
} else if (error == ESP_OK && (was_running || reset)) {
|
||||
error = request_running_locked(true);
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(s_command_mutex);
|
||||
return error;
|
||||
}
|
||||
if (was_running || reset) {
|
||||
error = request_running_locked(true);
|
||||
}
|
||||
ssh_security_release_identity(token);
|
||||
xSemaphoreGive(s_command_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_transport_replace_host_key(bool reset)
|
||||
{
|
||||
bool committed;
|
||||
return ssh_transport_replace_identity(0, 0, reset, &committed);
|
||||
}
|
||||
|
||||
esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) {
|
||||
@@ -1633,6 +1659,66 @@ esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) return ESP_ERR_INVALID_ARG;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!s_initialized) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
snapshot->generation = s_management_generation;
|
||||
snapshot->running = s_running;
|
||||
snapshot->transitioning = s_transitioning || s_cleanup_pending;
|
||||
for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
|
||||
snapshot->sessions[i] = s_session_snapshots[i];
|
||||
snapshot->sessions[i].close_requested |=
|
||||
snapshot->sessions[i].active && s_external_close_id[i] == snapshot->sessions[i].session_id;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action,
|
||||
uint32_t target, uint32_t generation)
|
||||
{
|
||||
if (!generation || generation == UINT32_MAX ||
|
||||
action < SSH_TRANSPORT_MANAGE_START || action > SSH_TRANSPORT_MANAGE_DISCONNECT ||
|
||||
((action == SSH_TRANSPORT_MANAGE_DISCONNECT) != (target != 0U))) return ESP_ERR_INVALID_ARG;
|
||||
if (s_command_mutex == NULL) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_command_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_initialized && !s_transitioning && !s_cleanup_pending &&
|
||||
generation == s_management_generation) {
|
||||
if (action == SSH_TRANSPORT_MANAGE_DISCONNECT) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
|
||||
const ssh_transport_session_snapshot_t *session = &s_session_snapshots[i];
|
||||
if (session->active && session->session_id == target &&
|
||||
!session->close_requested && session->state != SSH_TRANSPORT_SESSION_CLOSING &&
|
||||
s_external_close_id[i] != target) {
|
||||
s_external_close_id[i] = target;
|
||||
error = ESP_OK;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (s_running != (action == SSH_TRANSPORT_MANAGE_START)) {
|
||||
error = ESP_OK;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
/* The command mutex spans comparison and canonical lifecycle admission.
|
||||
* No HTTPD work/lock is involved; only the SSH owner touches sockets/wolfSSH. */
|
||||
if (error == ESP_OK) {
|
||||
if (action == SSH_TRANSPORT_MANAGE_DISCONNECT) notify_task();
|
||||
else error = request_running_locked(action == SSH_TRANSPORT_MANAGE_START);
|
||||
}
|
||||
xSemaphoreGive(s_command_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_transport_clear_counters(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
|
||||
@@ -99,11 +99,39 @@ typedef struct {
|
||||
ssh_transport_counters_t counters;
|
||||
} ssh_transport_snapshot_t;
|
||||
|
||||
typedef enum {
|
||||
SSH_TRANSPORT_MANAGE_START = 0,
|
||||
SSH_TRANSPORT_MANAGE_STOP,
|
||||
SSH_TRANSPORT_MANAGE_DISCONNECT,
|
||||
} ssh_transport_management_action_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t generation;
|
||||
bool running;
|
||||
bool transitioning;
|
||||
ssh_transport_session_snapshot_t sessions[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
} ssh_transport_management_snapshot_t;
|
||||
|
||||
/* Compact published state only; no wolfSSH calls or task-stack scan. */
|
||||
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *snapshot);
|
||||
/* Dispatcher-only conditional admission; success on disconnect means owner notified,
|
||||
* not peer receipt/cleanup. Lifecycle timeout does not cancel admitted work. */
|
||||
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action,
|
||||
uint32_t target, uint32_t generation);
|
||||
|
||||
/* Installs wolfCrypt RNG/PSRAM hooks and starts the sole wolfSSH owner task. */
|
||||
esp_err_t ssh_transport_init(void);
|
||||
esp_err_t ssh_transport_start(void);
|
||||
esp_err_t ssh_transport_stop(void);
|
||||
|
||||
/* Conditional off-HTTPD rotation: both generations checked/reserved before stop.
|
||||
* Zero generations retain canonical rotate/reset semantics. A failed stop skips
|
||||
* mutation/start; persistence failure may already have disconnected all SSH.
|
||||
* committed reports irreversible publication even if restart subsequently fails. */
|
||||
esp_err_t ssh_transport_replace_identity(uint32_t service_generation,
|
||||
uint32_t identity_generation,
|
||||
bool reset, bool *committed);
|
||||
|
||||
/* Serialize stop, persistent host-key replacement, and conditional restart. */
|
||||
esp_err_t ssh_transport_replace_host_key(bool reset);
|
||||
|
||||
|
||||
@@ -109,11 +109,8 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
|
||||
/* The owner mask routes this crypto/NVS path to the 12KiB dispatcher.
|
||||
* Commit before restart; a failed stop retains HTTPD ownership and must
|
||||
* not be followed by start. No socket IO or console handler calls here. */
|
||||
esp_err_t error = web_security_rotate_certificate();
|
||||
if (error != ESP_OK) return error;
|
||||
error = web_server_stop();
|
||||
if (error != ESP_OK) return error;
|
||||
return web_server_start();
|
||||
bool committed = false;
|
||||
return web_server_replace_identity(0, 0, false, &committed);
|
||||
}
|
||||
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
|
||||
if (action == ADMIN_SSH_DEFER_REBOOT) {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_broker_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "secure_random.h"
|
||||
#include "session_broker.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
|
||||
enum { IDLE, PENDING, OK, FAILED, CANCELLED, CONFLICT };
|
||||
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "conflict"};
|
||||
typedef struct {
|
||||
uint32_t id;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
int64_t deadline;
|
||||
uint32_t generation, target;
|
||||
unsigned state;
|
||||
} broker_operation_t;
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static broker_operation_t s_operation;
|
||||
static uint32_t s_next_id;
|
||||
|
||||
/* Narrow flat JSON: exact action plus two unsigned decimal integers, no
|
||||
* escapes, duplicates, unknown fields, nesting, fractions or exponents. */
|
||||
static bool parse(const char *body, size_t length, broker_operation_t *operation)
|
||||
{
|
||||
const char *keys[] = {"action", "generation", "target"};
|
||||
unsigned seen = 0;
|
||||
size_t pos = 0;
|
||||
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
|
||||
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
|
||||
TAKE('{');
|
||||
for (unsigned field = 0; field < 3; ++field) {
|
||||
if (field) { TAKE(','); }
|
||||
TAKE('"');
|
||||
size_t start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
unsigned key = 0;
|
||||
for (; key < 3; ++key)
|
||||
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
||||
if (key == 3 || (seen & (1U << key))) return false;
|
||||
++pos; TAKE(':'); SPACE();
|
||||
if (key == 0) {
|
||||
const char action[] = "\"assign\"";
|
||||
if (length - pos < sizeof(action) - 1 || memcmp(body + pos, action, sizeof(action) - 1)) return false;
|
||||
pos += sizeof(action) - 1;
|
||||
} else {
|
||||
uint32_t number = 0;
|
||||
start = pos;
|
||||
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
|
||||
unsigned digit = (unsigned)(body[pos++] - '0');
|
||||
if (number > (UINT32_MAX - digit) / 10U) return false;
|
||||
number = number * 10U + digit;
|
||||
}
|
||||
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
||||
if (key == 1) operation->generation = number;
|
||||
else operation->target = number;
|
||||
}
|
||||
seen |= 1U << key;
|
||||
}
|
||||
TAKE('}'); SPACE();
|
||||
#undef TAKE
|
||||
#undef SPACE
|
||||
return pos == length && seen == 7 && operation->target &&
|
||||
operation->generation && operation->generation != UINT32_MAX;
|
||||
}
|
||||
|
||||
void web_broker_settings_execute(uint32_t id)
|
||||
{
|
||||
broker_operation_t operation;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
operation = s_operation;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!id || operation.id != id || operation.state != PENDING) {
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
return;
|
||||
}
|
||||
bool current = false;
|
||||
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t);
|
||||
unsigned state = CANCELLED;
|
||||
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
||||
esp_timer_get_time() < operation.deadline) {
|
||||
error = session_broker_assign_writer_current(operation.target, operation.generation);
|
||||
state = error == ESP_OK ? OK :
|
||||
(error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.id == id && s_operation.state == PENDING) {
|
||||
s_operation.state = state;
|
||||
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
}
|
||||
|
||||
static esp_err_t respond(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; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
esp_err_t web_broker_operation_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
bool mutation = request->method == HTTP_POST;
|
||||
esp_err_t error = mutation
|
||||
? web_cookie_auth_require_json(request, 256, &view, &allowed)
|
||||
: web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
broker_operation_t operation = {0};
|
||||
if (mutation) {
|
||||
char type[40] = {0}, body[256];
|
||||
size_t received = 0;
|
||||
bool valid = request->content_len && request->content_len <= sizeof(body) &&
|
||||
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
|
||||
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
|
||||
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
|
||||
int count = httpd_req_recv(request, body + received, request->content_len - received);
|
||||
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
|
||||
else received += (size_t)count;
|
||||
}
|
||||
valid = valid && received == request->content_len && parse(body, received, &operation);
|
||||
secure_wipe(body, sizeof(body));
|
||||
if (!valid) {
|
||||
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_broker_request\"}");
|
||||
goto done;
|
||||
}
|
||||
operation.session = view.id;
|
||||
operation.principal = view.principal;
|
||||
operation.deadline = esp_timer_get_time() + 30000000LL;
|
||||
operation.state = PENDING;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
|
||||
if (!busy) {
|
||||
operation.id = ++s_next_id;
|
||||
s_operation = operation;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (busy || admin_ssh_console_submit_broker_settings(operation.id) != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
error = httpd_resp_set_hdr(request, "Retry-After", "1");
|
||||
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
goto done;
|
||||
}
|
||||
} else {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.session == view.id) {
|
||||
operation.id = s_operation.id;
|
||||
operation.state = s_operation.state;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
char response[96];
|
||||
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
|
||||
operation.id, operation.id ? "assign" : "none", s_states[operation.state]);
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
||||
respond(request, mutation ? "202 Accepted" : "200 OK", response);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
done:
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_broker_settings_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
session_broker_management_snapshot_t snapshot;
|
||||
error = session_broker_get_management_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"broker_unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
/* Eight rows; names are exact bounded bytes as hex, never unescaped JSON.
|
||||
* Decimal-string drop counters retain all 64 bits in the browser. */
|
||||
char response[2048];
|
||||
int written = snprintf(response, sizeof(response),
|
||||
"{\"generation\":%" PRIu32 ",\"writer\":%" PRIu32 ",\"clients\":[",
|
||||
snapshot.generation, snapshot.writer_id);
|
||||
size_t used = 0;
|
||||
if (written < 0 || (size_t)written >= sizeof(response)) { error = ESP_FAIL; goto done; }
|
||||
used = (size_t)written;
|
||||
for (size_t i = 0; i < snapshot.count; ++i) {
|
||||
const session_broker_management_client_t *client = &snapshot.clients[i];
|
||||
char name[SESSION_BROKER_CLIENT_NAME_MAX * 2 + 1];
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
size_t n = 0;
|
||||
for (; n < SESSION_BROKER_CLIENT_NAME_MAX && client->name[n]; ++n) {
|
||||
unsigned byte = (unsigned char)client->name[n];
|
||||
name[n * 2] = hex[byte >> 4]; name[n * 2 + 1] = hex[byte & 15];
|
||||
}
|
||||
name[n * 2] = 0;
|
||||
written = snprintf(response + used, sizeof(response) - used,
|
||||
"%s{\"id\":%" PRIu32 ",\"type\":%u,\"name_hex\":\"%s\",\"pending\":%u,\"high_water\":%u,\"dropped\":\"%" PRIu64 "\"}",
|
||||
i ? "," : "", client->id, (unsigned)client->type, name,
|
||||
(unsigned)client->pending, (unsigned)client->high_water, client->dropped);
|
||||
if (written < 0 || (size_t)written >= sizeof(response) - used) { error = ESP_FAIL; goto done; }
|
||||
used += (size_t)written;
|
||||
}
|
||||
written = snprintf(response + used, sizeof(response) - used, "]}");
|
||||
error = written < 0 || (size_t)written >= sizeof(response) - used ? ESP_FAIL :
|
||||
respond(request, "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/* Optional admin-only snapshot and login-isolated typed assignment/results. */
|
||||
esp_err_t web_broker_settings_handler(httpd_req_t *request);
|
||||
esp_err_t web_broker_operation_handler(httpd_req_t *request);
|
||||
void web_broker_settings_execute(uint32_t id);
|
||||
+12
-55
@@ -234,68 +234,25 @@ static bool force_is_present(int argc, char **argv, int expected_argc)
|
||||
return argc == expected_argc && strcmp(argv[expected_argc - 1], "--force") == 0;
|
||||
}
|
||||
|
||||
static int restart_if_running(bool was_running)
|
||||
static int replace_material(bool reset)
|
||||
{
|
||||
if (!was_running) {
|
||||
return 0;
|
||||
}
|
||||
esp_err_t error = web_server_stop();
|
||||
bool committed = false;
|
||||
esp_err_t error = web_server_replace_identity(0, 0, reset, &committed);
|
||||
if (error != ESP_OK) {
|
||||
printf("Material changed, but the old TLS server could not stop: %s\n",
|
||||
esp_err_to_name(error));
|
||||
printf("%s: %s\n", committed
|
||||
? "New HTTPS identity persisted, but stop/start failed; no rollback. Inspect via UART0 before retrying"
|
||||
: "HTTPS identity replacement rejected or failed before publication",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
error = web_server_start();
|
||||
if (error != ESP_OK) {
|
||||
printf("Material changed, but HTTPS could not restart: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int rotate_certificate(void)
|
||||
{
|
||||
web_server_snapshot_t snapshot;
|
||||
esp_err_t error = web_server_get_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not inspect HTTPS runtime: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
error = web_security_rotate_certificate();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not rotate web certificate: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("Web certificate and private key rotated and persisted.\n");
|
||||
return restart_if_running(snapshot.running);
|
||||
}
|
||||
|
||||
static int reset_material(void)
|
||||
{
|
||||
web_server_snapshot_t snapshot;
|
||||
bool was_running = web_server_get_snapshot(&snapshot) == ESP_OK && snapshot.running;
|
||||
esp_err_t error = web_security_reset_all();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not reset web security material: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("HTTPS certificate and private key replaced and persisted; user accounts unchanged.\n");
|
||||
if (was_running) {
|
||||
return restart_if_running(true);
|
||||
}
|
||||
|
||||
error = web_server_start();
|
||||
if (error != ESP_OK) {
|
||||
printf("Security material recovered, but HTTPS could not start: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS started with the recovered security material.\n");
|
||||
printf("Verify the new fingerprint via trusted UART0, renew browser trust, and sign in again.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int rotate_certificate(void) { return replace_material(false); }
|
||||
static int reset_material(void) { return replace_material(true); }
|
||||
|
||||
static void print_performance_time(const char *name, const web_serial_performance_timing_t *t)
|
||||
{
|
||||
printf(" %s: count=%" PRIu64 " sum_us=%" PRIu64 " avg_us_est=%" PRIu64 " max_us=%" PRIu64 "\n",
|
||||
@@ -417,7 +374,7 @@ static int command_web(int argc, char **argv)
|
||||
printf("Could not schedule HTTPS certificate rotation: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS certificate rotation scheduled after console output drains; both browser connections will close. Reconnect and verify the new certificate. If restart fails, use UART0 or SSH recovery.\n");
|
||||
printf("HTTPS identity rotation scheduled after console output drains; all web logins and browser terminals will close. A new identity may persist even if stop/start fails; no rollback. Verify the new fingerprint via trusted UART0 web certificate info before renewing browser trust, then reload and sign in. SSH and USB UART1 access remain independent.\n");
|
||||
return 0;
|
||||
}
|
||||
return rotate_certificate();
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_lifecycle_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "secure_random.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "web_server.h"
|
||||
#include "web_security.h"
|
||||
|
||||
#if CONFIG_HTTPD_QUEUE_WORK_BLOCKING
|
||||
#error "Lifecycle ACK handoff requires nonblocking HTTPD work submission"
|
||||
#endif
|
||||
|
||||
enum { IDLE, PENDING, EXECUTING, OK, FAILED, CANCELLED };
|
||||
static const char *const s_states[] = {"idle", "pending", "pending", "ok", "failed", "cancelled"};
|
||||
static const char *const s_actions[] = {"stop", "restart", "reboot", "rotate"};
|
||||
typedef struct {
|
||||
uint32_t id, generation, identity_generation;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
int64_t ack_deadline, deadline;
|
||||
unsigned action, state;
|
||||
bool queued;
|
||||
} lifecycle_operation_t;
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static lifecycle_operation_t s_operation;
|
||||
static uint32_t s_next_id, s_ack_id;
|
||||
/* Comparison only; never dereferenced outside the invoking HTTPD handler. */
|
||||
static httpd_handle_t s_ack_server;
|
||||
|
||||
static void cancel_locked(void)
|
||||
{
|
||||
s_operation.state = CANCELLED;
|
||||
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
|
||||
}
|
||||
|
||||
static void expire_locked(int64_t now)
|
||||
{
|
||||
if (s_operation.state == PENDING &&
|
||||
now >= (s_operation.queued ? s_operation.deadline : s_operation.ack_deadline))
|
||||
cancel_locked();
|
||||
}
|
||||
|
||||
/* Exactly action + service generation, plus identity generation only for rotate.
|
||||
* No escapes, duplicates, coercions or extra fields. */
|
||||
static bool parse(const char *body, size_t length, lifecycle_operation_t *operation)
|
||||
{
|
||||
const char *keys[] = {"action", "generation", "identity_generation"};
|
||||
unsigned seen = 0;
|
||||
size_t pos = 0;
|
||||
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
|
||||
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
|
||||
TAKE('{');
|
||||
for (unsigned field = 0; field < 3; ++field) {
|
||||
if (field) { TAKE(','); }
|
||||
TAKE('"'); size_t start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
unsigned key = 0;
|
||||
for (; key < 3; ++key)
|
||||
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
||||
if (key == 3 || (seen & (1U << key))) return false;
|
||||
++pos; TAKE(':'); SPACE();
|
||||
if (key == 0) {
|
||||
TAKE('"'); start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
unsigned action = 0;
|
||||
for (; action < 4; ++action)
|
||||
if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break;
|
||||
if (action == 4) return false;
|
||||
operation->action = action; ++pos;
|
||||
} else {
|
||||
uint32_t number = 0; start = pos;
|
||||
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
|
||||
unsigned digit = (unsigned)(body[pos++] - '0');
|
||||
if (number > (UINT32_MAX - digit) / 10U) return false;
|
||||
number = number * 10U + digit;
|
||||
}
|
||||
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
||||
if (key == 1) operation->generation = number;
|
||||
else operation->identity_generation = number;
|
||||
}
|
||||
seen |= 1U << key;
|
||||
SPACE();
|
||||
if (pos < length && body[pos] == '}') break;
|
||||
}
|
||||
TAKE('}'); SPACE();
|
||||
#undef TAKE
|
||||
#undef SPACE
|
||||
return pos == length && seen == (operation->action == 3 ? 7U : 3U) &&
|
||||
operation->generation && operation->generation != UINT32_MAX &&
|
||||
(operation->action != 3 || (operation->identity_generation && operation->identity_generation != UINT32_MAX));
|
||||
}
|
||||
|
||||
/* Runs on HTTPD after its synchronous response handler returns. No socket IO,
|
||||
* wait, authorization or lifecycle call here. A duplicate/late ID is inert. */
|
||||
static void ack_handoff(void *argument)
|
||||
{
|
||||
uint32_t id = (uint32_t)(uintptr_t)argument;
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool submit = id && s_ack_id == id;
|
||||
if (submit) {
|
||||
s_ack_id = 0; s_ack_server = NULL;
|
||||
expire_locked(now);
|
||||
submit = s_operation.id == id && s_operation.state == PENDING;
|
||||
if (submit) s_operation.queued = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (submit && admin_ssh_console_submit_lifecycle_settings(id) != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.id == id && s_operation.state == PENDING) cancel_locked();
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
}
|
||||
|
||||
void web_lifecycle_settings_stopped(httpd_handle_t server)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (server && s_ack_server == server) { s_ack_id = 0; s_ack_server = NULL; }
|
||||
/* Successful shutdown invalidates all old logins. Executing work owns its
|
||||
* slot until return, including its deliberately session-invalidating stop. */
|
||||
if (s_operation.state == PENDING) cancel_locked();
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_lifecycle_settings_execute(uint32_t id)
|
||||
{
|
||||
lifecycle_operation_t operation = {0};
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
bool execute = id && s_operation.id == id && s_operation.state == PENDING && s_operation.queued;
|
||||
if (execute) {
|
||||
s_operation.state = EXECUTING;
|
||||
operation = s_operation;
|
||||
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!execute) return;
|
||||
bool current = false;
|
||||
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t);
|
||||
unsigned state = CANCELLED;
|
||||
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
||||
esp_timer_get_time() < operation.deadline) {
|
||||
bool committed = false;
|
||||
error = operation.action == 0 ? web_server_stop_current(operation.generation) :
|
||||
operation.action == 1 ? web_server_restart_current(operation.generation) :
|
||||
operation.action == 2 ? web_server_reboot_current(operation.generation) :
|
||||
web_server_replace_identity(operation.generation, operation.identity_generation, false, &committed);
|
||||
/* Even INVALID_STATE can be a detach failure after stop admission. */
|
||||
state = error == ESP_OK ? OK : FAILED;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.id == id && s_operation.state == EXECUTING) s_operation.state = state;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
}
|
||||
|
||||
static esp_err_t respond(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; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
esp_err_t web_lifecycle_operation_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
lifecycle_operation_t operation = {0};
|
||||
bool allowed = false, mutation = request->method == HTTP_POST;
|
||||
esp_err_t error = mutation
|
||||
? web_cookie_auth_require_json(request, 256, &view, &allowed)
|
||||
: web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done;
|
||||
}
|
||||
if (mutation) {
|
||||
char type[40] = {0}, body[256];
|
||||
size_t received = 0;
|
||||
bool valid = request->content_len && request->content_len <= sizeof(body) &&
|
||||
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
|
||||
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
|
||||
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
|
||||
int count = httpd_req_recv(request, body + received, request->content_len - received);
|
||||
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
|
||||
else received += (size_t)count;
|
||||
}
|
||||
valid = valid && received == request->content_len && parse(body, received, &operation);
|
||||
secure_wipe(body, sizeof(body));
|
||||
if (!valid) { error = respond(request, "400 Bad Request", "{\"error\":\"invalid_lifecycle_request\"}"); goto done; }
|
||||
operation.session = view.id; operation.principal = view.principal;
|
||||
int64_t now = esp_timer_get_time();
|
||||
operation.ack_deadline = now + 2000000LL;
|
||||
operation.deadline = now + 30000000LL;
|
||||
operation.state = PENDING;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
bool busy = s_ack_id || s_operation.state == PENDING || s_operation.state == EXECUTING || s_next_id == UINT32_MAX;
|
||||
if (!busy) {
|
||||
operation.id = ++s_next_id; s_operation = operation;
|
||||
s_ack_id = operation.id; s_ack_server = request->handle;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (busy) { error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}"); goto done; }
|
||||
} else {
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
if (s_operation.session == view.id) {
|
||||
operation.id = s_operation.id; operation.state = s_operation.state; operation.action = s_operation.action;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
char response[96];
|
||||
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
|
||||
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
||||
respond(request, mutation ? "202 Accepted" : "200 OK", response);
|
||||
if (mutation) {
|
||||
/* No lifecycle can run before this send returns and HTTPD hands off.
|
||||
* Successful send is not peer receipt. Never retry queue submission. */
|
||||
if (error != ESP_OK || httpd_queue_work(request->handle, ack_handoff, (void *)(uintptr_t)operation.id) != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_ack_id == operation.id) { s_ack_id = 0; s_ack_server = NULL; }
|
||||
if (s_operation.id == operation.id && s_operation.state == PENDING) cancel_locked();
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
}
|
||||
done:
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_lifecycle_settings_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done;
|
||||
}
|
||||
web_server_management_snapshot_t snapshot;
|
||||
if (web_server_get_management_snapshot(&snapshot) != ESP_OK) {
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"lifecycle_unavailable\"}"); goto done;
|
||||
}
|
||||
web_security_identity_snapshot_t identity = {0};
|
||||
bool available = web_security_get_identity_snapshot(&identity) == ESP_OK;
|
||||
char fingerprint[65] = {0};
|
||||
if (available) {
|
||||
for (size_t i = 0; i < sizeof(identity.fingerprint); ++i)
|
||||
snprintf(fingerprint + i * 2, 3, "%02x", identity.fingerprint[i]);
|
||||
}
|
||||
char response[320];
|
||||
int written = snprintf(response, sizeof(response),
|
||||
"{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,\"controllable\":%s,"
|
||||
"\"identity_generation\":%" PRIu32 ",\"fingerprint\":\"%s\",\"rotatable\":%s}",
|
||||
snapshot.generation, snapshot.running ? "true" : "false",
|
||||
snapshot.transitioning ? "true" : "false", snapshot.controllable ? "true" : "false",
|
||||
available ? identity.generation : 0, fingerprint,
|
||||
available && snapshot.controllable && !identity.busy && identity.generation &&
|
||||
identity.generation != UINT32_MAX ? "true" : "false");
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_http_server.h"
|
||||
|
||||
esp_err_t web_lifecycle_settings_handler(httpd_req_t *request);
|
||||
esp_err_t web_lifecycle_operation_handler(httpd_req_t *request);
|
||||
/* Existing dispatcher only; callbacks submit IDs, never execute lifecycle work. */
|
||||
void web_lifecycle_settings_execute(uint32_t id);
|
||||
/* Only after successful HTTPD destruction, before another server can start. */
|
||||
void web_lifecycle_settings_stopped(httpd_handle_t server);
|
||||
+80
-56
@@ -51,6 +51,8 @@ _Static_assert(sizeof(web_security_blob_t) == WEB_SECURITY_BLOB_SIZE,
|
||||
static SemaphoreHandle_t s_security_mutex;
|
||||
static web_security_blob_t s_material;
|
||||
static bool s_material_ready;
|
||||
static uint32_t s_identity_token, s_next_identity_token;
|
||||
static bool s_identity_used;
|
||||
static web_security_load_result_t s_load_result;
|
||||
|
||||
static bool bytes_are_zero(const uint8_t *data, size_t size)
|
||||
@@ -649,6 +651,10 @@ esp_err_t web_security_init(web_security_load_result_t *load_result)
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
if (s_identity_token) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (s_material_ready) {
|
||||
if (load_result != NULL) {
|
||||
*load_result = s_load_result;
|
||||
@@ -763,14 +769,6 @@ esp_err_t web_security_get_certificate_metadata(
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t increment_generation(web_security_blob_t *blob)
|
||||
{
|
||||
if (blob->generation == UINT32_MAX) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
++blob->generation;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void install_committed_blob(const web_security_blob_t *candidate)
|
||||
{
|
||||
@@ -781,64 +779,90 @@ static void install_committed_blob(const web_security_blob_t *candidate)
|
||||
s_load_result = WEB_SECURITY_LOAD_STORED;
|
||||
}
|
||||
|
||||
esp_err_t web_security_rotate_certificate(void)
|
||||
esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *snapshot)
|
||||
{
|
||||
if (s_security_mutex == NULL) {
|
||||
if (!snapshot) return ESP_ERR_INVALID_ARG;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
esp_err_t error = s_material_ready ? ESP_OK : ESP_ERR_INVALID_STATE;
|
||||
if (error == ESP_OK) {
|
||||
snapshot->generation = s_material.generation;
|
||||
memcpy(snapshot->fingerprint, s_material.certificate_fingerprint, sizeof(snapshot->fingerprint));
|
||||
snapshot->busy = s_identity_token != 0 || s_next_identity_token == UINT32_MAX;
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_security_reserve_identity(uint32_t expected_generation, bool reset, uint32_t *token)
|
||||
{
|
||||
if (!token || (reset && expected_generation)) return ESP_ERR_INVALID_ARG;
|
||||
*token = 0;
|
||||
if (!reset) {
|
||||
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
bool ready = s_material_ready;
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
if (!ready) return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error == ESP_OK) error = ensure_security_mutex();
|
||||
if (error != ESP_OK) return error;
|
||||
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
if (s_identity_token || s_next_identity_token == UINT32_MAX ||
|
||||
(!s_material_ready && !reset) ||
|
||||
(s_material_ready && s_material.generation == UINT32_MAX) ||
|
||||
(expected_generation && expected_generation != s_material.generation)) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
*token = s_identity_token = ++s_next_identity_token;
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
web_security_blob_t candidate;
|
||||
memset(&candidate, 0, sizeof(candidate));
|
||||
if (s_material_ready) {
|
||||
candidate = s_material;
|
||||
error = increment_generation(&candidate);
|
||||
if (error == ESP_OK) {
|
||||
error = generate_certificate(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_committed_blob(&candidate);
|
||||
}
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
s_identity_used = false;
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_security_reset_all(void)
|
||||
esp_err_t web_security_replace_reserved(uint32_t token)
|
||||
{
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = ensure_security_mutex();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (!s_security_mutex || !token) return ESP_ERR_INVALID_STATE;
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
web_security_blob_t candidate;
|
||||
uint32_t generation = 1U;
|
||||
if (s_material_ready) {
|
||||
if (s_material.generation == UINT32_MAX) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
generation = s_material.generation + 1U;
|
||||
if (s_identity_token != token || s_identity_used) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
error = generate_all(&candidate, generation);
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_committed_blob(&candidate);
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
s_identity_used = true;
|
||||
uint32_t generation = s_material_ready ? s_material.generation + 1U : 1U;
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
|
||||
/* The reservation, not a held mutex/spinlock, excludes all identity writers. */
|
||||
web_security_blob_t candidate = {0};
|
||||
esp_err_t error = generate_all(&candidate, generation);
|
||||
if (error == ESP_OK) error = save_blob(&candidate);
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
if (error == ESP_OK) install_committed_blob(&candidate);
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_security_release_identity(uint32_t token)
|
||||
{
|
||||
if (!s_security_mutex || !token) return;
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
if (s_identity_token == token) s_identity_token = 0;
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
}
|
||||
|
||||
static esp_err_t replace_identity(bool reset)
|
||||
{
|
||||
uint32_t token = 0;
|
||||
esp_err_t error = web_security_reserve_identity(0, reset, &token);
|
||||
if (error == ESP_OK) error = web_security_replace_reserved(token);
|
||||
web_security_release_identity(token);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_security_rotate_certificate(void) { return replace_identity(false); }
|
||||
esp_err_t web_security_reset_all(void) { return replace_identity(true); }
|
||||
|
||||
@@ -70,6 +70,23 @@ esp_err_t web_security_copy_tls_material(
|
||||
esp_err_t web_security_get_certificate_metadata(
|
||||
web_security_certificate_metadata_t *metadata);
|
||||
|
||||
typedef struct {
|
||||
uint32_t generation;
|
||||
uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH];
|
||||
bool busy;
|
||||
} web_security_identity_snapshot_t;
|
||||
|
||||
/* Zero-wait public metadata only; never returns DER or private material. */
|
||||
esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *snapshot);
|
||||
|
||||
/* Internal owner transaction shared with canonical mutations. Tokens never reuse.
|
||||
* Reserve before crypto; retain through service restart. No lock stays held.
|
||||
* Zero expected_generation selects canonical CLI semantics; reset permits recovery.
|
||||
* Only the reserving owner may replace once and release its token. */
|
||||
esp_err_t web_security_reserve_identity(uint32_t expected_generation, bool reset, uint32_t *token);
|
||||
esp_err_t web_security_replace_reserved(uint32_t token);
|
||||
void web_security_release_identity(uint32_t token);
|
||||
|
||||
/* Mutations become visible only after a complete blob has committed to NVS. */
|
||||
|
||||
esp_err_t web_security_rotate_certificate(void);
|
||||
|
||||
+169
-15
@@ -13,6 +13,7 @@
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif_ip_addr.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_system.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "secure_random.h"
|
||||
@@ -27,6 +28,9 @@
|
||||
#include "web_account_settings.h"
|
||||
#include "web_network_settings.h"
|
||||
#include "web_display_settings.h"
|
||||
#include "web_broker_settings.h"
|
||||
#include "web_ssh_settings.h"
|
||||
#include "web_lifecycle_settings.h"
|
||||
#include "web_admin_transport.h"
|
||||
#include "web_session_store.h"
|
||||
#include "web_cookie_auth.h"
|
||||
@@ -43,6 +47,8 @@ static SemaphoreHandle_t s_server_mutex;
|
||||
static httpd_handle_t s_server;
|
||||
static bool s_initialized;
|
||||
static bool s_transitioning;
|
||||
/* Firmware-lifetime lifecycle fence, independent of counters and handle reuse. */
|
||||
static uint32_t s_generation = 1U;
|
||||
static bool s_serial_transport_init_attempted;
|
||||
static bool s_serial_transport_initialized;
|
||||
static bool s_serial_transport_attached;
|
||||
@@ -414,6 +420,33 @@ static const httpd_uri_t s_account_generate_password_uri = {
|
||||
static const httpd_uri_t s_network_uri = {
|
||||
.uri = "/api/settings/network", .method = HTTP_GET, .handler = web_network_snapshot_handler,
|
||||
};
|
||||
static const httpd_uri_t s_lifecycle_settings_uri = {
|
||||
.uri = "/api/settings/lifecycle", .method = HTTP_GET, .handler = web_lifecycle_settings_handler,
|
||||
};
|
||||
static const httpd_uri_t s_lifecycle_operation_get_uri = {
|
||||
.uri = "/api/settings/lifecycle-operation", .method = HTTP_GET, .handler = web_lifecycle_operation_handler,
|
||||
};
|
||||
static const httpd_uri_t s_lifecycle_operation_post_uri = {
|
||||
.uri = "/api/settings/lifecycle-operation", .method = HTTP_POST, .handler = web_lifecycle_operation_handler,
|
||||
};
|
||||
static const httpd_uri_t s_ssh_settings_uri = {
|
||||
.uri = "/api/settings/ssh", .method = HTTP_GET, .handler = web_ssh_settings_handler,
|
||||
};
|
||||
static const httpd_uri_t s_ssh_operation_get_uri = {
|
||||
.uri = "/api/settings/ssh-operation", .method = HTTP_GET, .handler = web_ssh_operation_handler,
|
||||
};
|
||||
static const httpd_uri_t s_ssh_operation_post_uri = {
|
||||
.uri = "/api/settings/ssh-operation", .method = HTTP_POST, .handler = web_ssh_operation_handler,
|
||||
};
|
||||
static const httpd_uri_t s_broker_uri = {
|
||||
.uri = "/api/settings/broker", .method = HTTP_GET, .handler = web_broker_settings_handler,
|
||||
};
|
||||
static const httpd_uri_t s_broker_operation_get_uri = {
|
||||
.uri = "/api/settings/broker-operation", .method = HTTP_GET, .handler = web_broker_operation_handler,
|
||||
};
|
||||
static const httpd_uri_t s_broker_operation_post_uri = {
|
||||
.uri = "/api/settings/broker-operation", .method = HTTP_POST, .handler = web_broker_operation_handler,
|
||||
};
|
||||
static const httpd_uri_t s_display_uri = {
|
||||
.uri = "/api/settings/display", .method = HTTP_GET, .handler = web_display_settings_handler,
|
||||
};
|
||||
@@ -599,30 +632,27 @@ esp_err_t web_server_init(void)
|
||||
s_serial_transport_error = serial_transport_error;
|
||||
s_serial_transport_initialized = serial_transport_error == ESP_OK;
|
||||
}
|
||||
s_initialized = true;
|
||||
if (s_last_error == ESP_ERR_INVALID_STATE) {
|
||||
if (!s_initialized && s_last_error == ESP_ERR_INVALID_STATE) {
|
||||
s_last_error = ESP_OK;
|
||||
}
|
||||
s_initialized = true;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
|
||||
/* The Phase 5A HTTPS recovery surface remains available if WebSocket setup fails. */
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_server_start(void)
|
||||
static esp_err_t start_server(bool reserved)
|
||||
{
|
||||
esp_err_t error = web_server_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t error;
|
||||
bool serial_transport_ready;
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
if (s_server != NULL || s_transitioning) {
|
||||
if (s_server != NULL || s_transitioning != reserved) {
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_transitioning = true;
|
||||
if (s_generation != UINT32_MAX) ++s_generation;
|
||||
serial_transport_ready = s_serial_transport_initialized;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
|
||||
@@ -645,7 +675,7 @@ esp_err_t web_server_start(void)
|
||||
config.httpd.max_open_sockets = 6;
|
||||
config.httpd.max_uri_handlers =
|
||||
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
|
||||
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 16U;
|
||||
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 25U;
|
||||
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
|
||||
config.httpd.lru_purge_enable = false;
|
||||
config.httpd.recv_wait_timeout = 1;
|
||||
@@ -713,6 +743,18 @@ esp_err_t web_server_start(void)
|
||||
web_httpd_register_optional_get(server, &s_display_operation_get_uri) == ESP_OK &&
|
||||
web_httpd_register_optional(server, &s_display_operation_post_uri) != ESP_OK)
|
||||
(void)httpd_unregister_uri_handler(server, s_display_operation_get_uri.uri, HTTP_GET);
|
||||
if (web_httpd_register_optional_get(server, &s_broker_uri) == ESP_OK &&
|
||||
web_httpd_register_optional_get(server, &s_broker_operation_get_uri) == ESP_OK &&
|
||||
web_httpd_register_optional(server, &s_broker_operation_post_uri) != ESP_OK)
|
||||
(void)httpd_unregister_uri_handler(server, s_broker_operation_get_uri.uri, HTTP_GET);
|
||||
if (web_httpd_register_optional_get(server, &s_ssh_settings_uri) == ESP_OK &&
|
||||
web_httpd_register_optional_get(server, &s_ssh_operation_get_uri) == ESP_OK &&
|
||||
web_httpd_register_optional(server, &s_ssh_operation_post_uri) != ESP_OK)
|
||||
(void)httpd_unregister_uri_handler(server, s_ssh_operation_get_uri.uri, HTTP_GET);
|
||||
if (web_httpd_register_optional_get(server, &s_lifecycle_settings_uri) == ESP_OK &&
|
||||
web_httpd_register_optional_get(server, &s_lifecycle_operation_get_uri) == ESP_OK &&
|
||||
web_httpd_register_optional(server, &s_lifecycle_operation_post_uri) != ESP_OK)
|
||||
(void)httpd_unregister_uri_handler(server, s_lifecycle_operation_get_uri.uri, HTTP_GET);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
web_cookie_auth_stop();
|
||||
@@ -722,6 +764,7 @@ esp_err_t web_server_start(void)
|
||||
if (cleanup_error == ESP_OK) cleanup_error = httpd_ssl_stop(server);
|
||||
if (cleanup_error == ESP_OK) {
|
||||
web_httpd_idle_stopped(server);
|
||||
web_lifecycle_settings_stopped(server);
|
||||
server = NULL;
|
||||
} else {
|
||||
/* Retain ownership so stop can retry and start cannot allocate a second server. */
|
||||
@@ -747,14 +790,23 @@ esp_err_t web_server_start(void)
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_server_stop(void)
|
||||
esp_err_t web_server_start(void)
|
||||
{
|
||||
esp_err_t error = web_server_init();
|
||||
return error == ESP_OK ? start_server(false) : error;
|
||||
}
|
||||
|
||||
static esp_err_t stop_server(uint32_t expected_generation, bool restart, bool reserved)
|
||||
{
|
||||
if (s_server_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
if (s_server == NULL || s_transitioning) {
|
||||
if (xSemaphoreTake(s_server_mutex, expected_generation ? 0U : portMAX_DELAY) != pdTRUE)
|
||||
return ESP_ERR_TIMEOUT;
|
||||
if (s_server == NULL || s_transitioning != reserved ||
|
||||
(expected_generation && (expected_generation != s_generation ||
|
||||
s_generation == UINT32_MAX || s_last_error != ESP_OK))) {
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
@@ -763,6 +815,7 @@ esp_err_t web_server_stop(void)
|
||||
bool admin_transport_owned = s_admin_transport_owned;
|
||||
esp_err_t serial_transport_error = s_serial_transport_error;
|
||||
s_transitioning = true;
|
||||
if (s_generation != UINT32_MAX) ++s_generation;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
|
||||
web_cookie_auth_stop();
|
||||
@@ -801,7 +854,10 @@ esp_err_t web_server_stop(void)
|
||||
}
|
||||
|
||||
esp_err_t error = httpd_ssl_stop(server);
|
||||
if (error == ESP_OK) web_httpd_idle_stopped(server);
|
||||
if (error == ESP_OK) {
|
||||
web_httpd_idle_stopped(server);
|
||||
web_lifecycle_settings_stopped(server);
|
||||
}
|
||||
if (error == ESP_OK && admin_transport_owned) web_admin_transport_stopped(server);
|
||||
if (error != ESP_OK && serial_transport_attached) {
|
||||
/* Stay detached: old HTTPD work may still be reading static TX storage. */
|
||||
@@ -809,7 +865,8 @@ esp_err_t web_server_stop(void)
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
s_transitioning = false;
|
||||
/* Do not expose a stopped/unreserved gap to another lifecycle caller. */
|
||||
s_transitioning = error == ESP_OK && restart;
|
||||
s_last_error = error;
|
||||
s_serial_transport_error = serial_transport_error;
|
||||
s_serial_transport_attached = false;
|
||||
@@ -819,9 +876,106 @@ esp_err_t web_server_stop(void)
|
||||
++s_counters.stops;
|
||||
}
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return error == ESP_OK && restart ? start_server(true) : error;
|
||||
}
|
||||
|
||||
esp_err_t web_server_replace_identity(uint32_t expected_service_generation,
|
||||
uint32_t expected_identity_generation, bool reset, bool *committed)
|
||||
{
|
||||
if (!committed || (!!expected_service_generation != !!expected_identity_generation) ||
|
||||
(reset && expected_service_generation)) return ESP_ERR_INVALID_ARG;
|
||||
*committed = false;
|
||||
/* Conditional dispatcher admission must not wait in the legacy initializer. */
|
||||
esp_err_t error = expected_service_generation
|
||||
? (s_server_mutex ? ESP_OK : ESP_ERR_INVALID_STATE) : web_server_init();
|
||||
if (error != ESP_OK) return error;
|
||||
if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
if (s_transitioning || (expected_service_generation &&
|
||||
(!s_server || s_last_error != ESP_OK || s_generation == UINT32_MAX ||
|
||||
expected_service_generation != s_generation))) {
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
bool running = s_server != NULL;
|
||||
s_transitioning = true;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
|
||||
uint32_t token = 0;
|
||||
error = web_security_reserve_identity(expected_identity_generation, reset, &token);
|
||||
if (error == ESP_OK) {
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
if (s_generation != UINT32_MAX) ++s_generation;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
error = web_security_replace_reserved(token);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
*committed = true;
|
||||
if (running) error = stop_server(0, true, true);
|
||||
else if (reset) error = start_server(true);
|
||||
else {
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
s_transitioning = false;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
}
|
||||
} else {
|
||||
/* No identity publication: leave HTTPD and its logins untouched. */
|
||||
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
|
||||
s_transitioning = false;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
}
|
||||
web_security_release_identity(token);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_server_stop(void)
|
||||
{
|
||||
return stop_server(0U, false, false);
|
||||
}
|
||||
|
||||
esp_err_t web_server_stop_current(uint32_t expected_generation)
|
||||
{
|
||||
if (!expected_generation) return ESP_ERR_INVALID_ARG;
|
||||
return stop_server(expected_generation, false, false);
|
||||
}
|
||||
|
||||
esp_err_t web_server_restart_current(uint32_t expected_generation)
|
||||
{
|
||||
if (!expected_generation) return ESP_ERR_INVALID_ARG;
|
||||
return stop_server(expected_generation, true, false);
|
||||
}
|
||||
|
||||
esp_err_t web_server_reboot_current(uint32_t expected_generation)
|
||||
{
|
||||
if (!expected_generation) return ESP_ERR_INVALID_ARG;
|
||||
if (s_server_mutex == NULL) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
if (s_server == NULL || s_transitioning || s_last_error != ESP_OK ||
|
||||
s_generation == UINT32_MAX || expected_generation != s_generation) {
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_transitioning = true;
|
||||
++s_generation;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
esp_restart();
|
||||
return ESP_FAIL; /* Defensive only: reset normally never returns. */
|
||||
}
|
||||
|
||||
esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) return ESP_ERR_INVALID_ARG;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
if (s_server_mutex == NULL) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
snapshot->generation = s_generation;
|
||||
snapshot->running = s_server != NULL;
|
||||
snapshot->transitioning = s_transitioning;
|
||||
snapshot->controllable = s_initialized && s_server != NULL && !s_transitioning &&
|
||||
s_last_error == ESP_OK && s_generation != UINT32_MAX;
|
||||
xSemaphoreGive(s_server_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_server_get_snapshot(web_server_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) {
|
||||
|
||||
+33
-1
@@ -39,7 +39,39 @@ typedef struct {
|
||||
/* Initialize runtime state without requiring valid certificate material. */
|
||||
esp_err_t web_server_init(void);
|
||||
|
||||
/* Start one TLS-only server on all active network interfaces. */
|
||||
typedef struct {
|
||||
uint32_t generation;
|
||||
bool running;
|
||||
bool transitioning;
|
||||
bool controllable;
|
||||
} web_server_management_snapshot_t;
|
||||
|
||||
/* Zero-wait, secret-free projection; controllable excludes failed cleanup and
|
||||
* exhausted generations. No HTTPD work or owner wait is performed. */
|
||||
esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *snapshot);
|
||||
|
||||
/* Conditional lifecycle admission under the canonical server mutex. Call only
|
||||
* off HTTPD, after caller-owned authorization and bounded ACK handoff. These
|
||||
* APIs do not authenticate, acknowledge, cancel on revocation, or bound HTTPD
|
||||
* shutdown time. Restart reserves the lifecycle through stop and start; a failed
|
||||
* stop never starts another server. Stale/exhausted/unclean state rejects without
|
||||
* side effects. Canonical stop/start below remain the recovery path. */
|
||||
esp_err_t web_server_stop_current(uint32_t expected_generation);
|
||||
esp_err_t web_server_restart_current(uint32_t expected_generation);
|
||||
/* Reserves this HTTPS generation before canonical whole-device esp_restart().
|
||||
* Admission cannot be cancelled; normally does not return. Same caller rules. */
|
||||
esp_err_t web_server_reboot_current(uint32_t expected_generation);
|
||||
|
||||
/* Combined identity/service owner operation, off HTTPD only. Nonzero expected
|
||||
* generations select healthy running conditional rotation; both zero select CLI.
|
||||
* reset is CLI-only and starts a stopped service; ordinary rotation leaves it stopped.
|
||||
* committed reports irreversible NVS publication even when stop/start later fails.
|
||||
* Reservation covers generation checks, crypto/commit and canonical stop/start. */
|
||||
esp_err_t web_server_replace_identity(uint32_t expected_service_generation,
|
||||
uint32_t expected_identity_generation, bool reset, bool *committed);
|
||||
|
||||
/* Start one TLS-only server on all active network interfaces.
|
||||
* Start/stop may wait for HTTPD; never call from its task or queued callbacks. */
|
||||
esp_err_t web_server_start(void);
|
||||
esp_err_t web_server_stop(void);
|
||||
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_ssh_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "ssh_security.h"
|
||||
#include "mbedtls/base64.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
|
||||
enum { IDLE, PENDING, OK, FAILED, CANCELLED, CONFLICT };
|
||||
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "conflict"};
|
||||
enum { ROTATE = 3 };
|
||||
static const char *const s_actions[] = {"start", "stop", "disconnect", "rotate"};
|
||||
typedef struct {
|
||||
uint32_t id;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
int64_t deadline;
|
||||
uint32_t generation, target, identity_generation;
|
||||
unsigned action;
|
||||
unsigned state;
|
||||
} ssh_operation_t;
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static ssh_operation_t s_operation;
|
||||
static uint32_t s_next_id;
|
||||
|
||||
/* Three fields for service actions; rotation additionally requires identity_generation.
|
||||
* No escapes, duplicates, extra fields or coercion. */
|
||||
static bool parse(const char *body, size_t length, ssh_operation_t *operation)
|
||||
{
|
||||
const char *keys[] = {"action", "generation", "target", "identity_generation"};
|
||||
unsigned seen = 0;
|
||||
size_t pos = 0;
|
||||
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
|
||||
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
|
||||
TAKE('{');
|
||||
for (unsigned field = 0; field < 4; ++field) {
|
||||
if (field) { TAKE(','); }
|
||||
TAKE('"');
|
||||
size_t start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
unsigned key = 0;
|
||||
for (; key < 4; ++key)
|
||||
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
||||
if (key == 4 || (seen & (1U << key))) return false;
|
||||
++pos; TAKE(':'); SPACE();
|
||||
if (key == 0) {
|
||||
TAKE('"'); start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
unsigned action = 0;
|
||||
for (; action < 4; ++action)
|
||||
if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break;
|
||||
if (action == 4) return false;
|
||||
operation->action = action;
|
||||
++pos;
|
||||
} else {
|
||||
uint32_t number = 0;
|
||||
start = pos;
|
||||
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
|
||||
unsigned digit = (unsigned)(body[pos++] - '0');
|
||||
if (number > (UINT32_MAX - digit) / 10U) return false;
|
||||
number = number * 10U + digit;
|
||||
}
|
||||
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
||||
if (key == 1) operation->generation = number;
|
||||
else if (key == 2) operation->target = number;
|
||||
else operation->identity_generation = number;
|
||||
}
|
||||
seen |= 1U << key;
|
||||
SPACE();
|
||||
if (pos < length && body[pos] == '}') break;
|
||||
}
|
||||
TAKE('}'); SPACE();
|
||||
#undef TAKE
|
||||
#undef SPACE
|
||||
return pos == length &&
|
||||
(operation->action == ROTATE ? seen == 15 && operation->identity_generation &&
|
||||
operation->identity_generation != UINT32_MAX : seen == 7) && operation->generation &&
|
||||
operation->generation != UINT32_MAX &&
|
||||
((operation->action == SSH_TRANSPORT_MANAGE_DISCONNECT) == (operation->target != 0U));
|
||||
}
|
||||
|
||||
void web_ssh_settings_execute(uint32_t id)
|
||||
{
|
||||
ssh_operation_t operation;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
operation = s_operation;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!id || operation.id != id || operation.state != PENDING) {
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
return;
|
||||
}
|
||||
bool current = false;
|
||||
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t);
|
||||
unsigned state = CANCELLED;
|
||||
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
||||
esp_timer_get_time() < operation.deadline) {
|
||||
bool committed = false;
|
||||
error = operation.action == ROTATE
|
||||
? ssh_transport_replace_identity(operation.generation, operation.identity_generation, false, &committed)
|
||||
: ssh_transport_manage_current(operation.action, operation.target, operation.generation);
|
||||
state = error == ESP_OK ? OK :
|
||||
(operation.action == ROTATE) ? FAILED :
|
||||
(error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.id == id && s_operation.state == PENDING) {
|
||||
s_operation.state = state;
|
||||
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
}
|
||||
|
||||
static esp_err_t respond(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; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
esp_err_t web_ssh_operation_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
bool mutation = request->method == HTTP_POST;
|
||||
esp_err_t error = mutation
|
||||
? web_cookie_auth_require_json(request, 256, &view, &allowed)
|
||||
: web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
ssh_operation_t operation = {0};
|
||||
if (mutation) {
|
||||
char type[40] = {0}, body[256];
|
||||
size_t received = 0;
|
||||
bool valid = request->content_len && request->content_len <= sizeof(body) &&
|
||||
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
|
||||
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
|
||||
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
|
||||
int count = httpd_req_recv(request, body + received, request->content_len - received);
|
||||
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
|
||||
else received += (size_t)count;
|
||||
}
|
||||
valid = valid && received == request->content_len && parse(body, received, &operation);
|
||||
secure_wipe(body, sizeof(body));
|
||||
if (!valid) {
|
||||
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_ssh_request\"}");
|
||||
goto done;
|
||||
}
|
||||
operation.session = view.id;
|
||||
operation.principal = view.principal;
|
||||
operation.deadline = esp_timer_get_time() + 30000000LL;
|
||||
operation.state = PENDING;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
|
||||
if (!busy) {
|
||||
operation.id = ++s_next_id;
|
||||
s_operation = operation;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (busy || admin_ssh_console_submit_ssh_settings(operation.id) != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
error = httpd_resp_set_hdr(request, "Retry-After", "1");
|
||||
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
goto done;
|
||||
}
|
||||
} else {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.session == view.id) {
|
||||
operation.id = s_operation.id;
|
||||
operation.state = s_operation.state;
|
||||
operation.action = s_operation.action;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
char response[96];
|
||||
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
|
||||
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
||||
respond(request, mutation ? "202 Accepted" : "200 OK", response);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
done:
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_ssh_settings_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
ssh_transport_management_snapshot_t snapshot;
|
||||
error = ssh_transport_get_management_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"ssh_unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
ssh_security_identity_snapshot_t identity = {0};
|
||||
unsigned char fingerprint[48] = {0};
|
||||
size_t fingerprint_length = 0;
|
||||
bool have_identity = ssh_security_get_identity_snapshot(&identity) == ESP_OK;
|
||||
if (have_identity && mbedtls_base64_encode(fingerprint, sizeof(fingerprint), &fingerprint_length,
|
||||
identity.metadata.sha256_fingerprint, sizeof(identity.metadata.sha256_fingerprint)) != 0) {
|
||||
error = ESP_FAIL;
|
||||
goto done;
|
||||
}
|
||||
while (fingerprint_length && fingerprint[fingerprint_length - 1] == '=') --fingerprint_length;
|
||||
fingerprint[fingerprint_length] = 0;
|
||||
char response[768];
|
||||
int written = snprintf(response, sizeof(response),
|
||||
"{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,"
|
||||
"\"identity_generation\":%" PRIu32 ",\"algorithm\":\"%s\",\"fingerprint\":\"%s%s\",\"rotatable\":%s,\"sessions\":[",
|
||||
snapshot.generation, snapshot.running ? "true" : "false", snapshot.transitioning ? "true" : "false",
|
||||
have_identity ? identity.metadata.generation : 0, SSH_SECURITY_KEY_TYPE,
|
||||
have_identity ? "SHA256:" : "", fingerprint,
|
||||
have_identity && !identity.busy && identity.metadata.generation != UINT32_MAX &&
|
||||
!snapshot.transitioning && snapshot.generation != UINT32_MAX ? "true" : "false");
|
||||
if (written < 0 || (size_t)written >= sizeof(response)) { error = ESP_FAIL; goto done; }
|
||||
size_t used = (size_t)written;
|
||||
unsigned count = 0;
|
||||
for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
|
||||
const ssh_transport_session_snapshot_t *session = &snapshot.sessions[i];
|
||||
if (!session->active) continue;
|
||||
char name[USER_DATABASE_USERNAME_CAPACITY * 2 + 1];
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
size_t n = 0;
|
||||
for (; session->principal_valid && n < USER_DATABASE_USERNAME_CAPACITY && session->username[n]; ++n) {
|
||||
unsigned byte = (unsigned char)session->username[n];
|
||||
name[n * 2] = hex[byte >> 4]; name[n * 2 + 1] = hex[byte & 15];
|
||||
}
|
||||
name[n * 2] = 0;
|
||||
written = snprintf(response + used, sizeof(response) - used,
|
||||
"%s{\"id\":%" PRIu32 ",\"state\":%u,\"route\":%u,\"name_hex\":\"%s\",\"closing\":%s}",
|
||||
count++ ? "," : "", session->session_id, (unsigned)session->state,
|
||||
(unsigned)session->route, name, session->close_requested ? "true" : "false");
|
||||
if (written < 0 || (size_t)written >= sizeof(response) - used) { error = ESP_FAIL; goto done; }
|
||||
used += (size_t)written;
|
||||
}
|
||||
written = snprintf(response + used, sizeof(response) - used, "]}");
|
||||
error = written < 0 || (size_t)written >= sizeof(response) - used ? ESP_FAIL :
|
||||
respond(request, "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/* Optional admin-only SSH status and login-isolated ordinary controls. */
|
||||
esp_err_t web_ssh_settings_handler(httpd_req_t *request);
|
||||
esp_err_t web_ssh_operation_handler(httpd_req_t *request);
|
||||
void web_ssh_settings_execute(uint32_t id);
|
||||
+419
-16
@@ -26,6 +26,9 @@ static const char s_index_html[] =
|
||||
"--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n"
|
||||
"*{box-sizing:border-box}\n"
|
||||
"[hidden]{display:none!important}\n"
|
||||
".quick-trigger{background:none;border:0;padding:0;color:inherit;text-align:left;cursor:pointer}.quick-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:3px}\n"
|
||||
".settings-page[data-quick='true']{position:fixed;z-index:10;top:8px;right:8px;width:min(600px,calc(100vw - 16px));max-height:calc(100vh - 16px);max-height:calc(100dvh - 16px);overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;box-shadow:0 8px 32px #0008}\n"
|
||||
"[data-quick='true']>#settings-navigation,[data-quick='true'] #network-password-mode-label,[data-quick='true'] #network-password-label,[data-quick='true'] #network-password-status{display:none}\n"
|
||||
"html,body{height:100%;margin:0;overflow:hidden}\n"
|
||||
"body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);"
|
||||
"color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"
|
||||
@@ -150,13 +153,13 @@ static const char s_index_html[] =
|
||||
"<div class=\"status-item\"><span class=\"label\">Broker client</span>"
|
||||
"<span id=\"client-id\" class=\"value\">—</span></div>\n"
|
||||
"<div class=\"status-item\"><span class=\"label\">Active writer</span>"
|
||||
"<span id=\"writer-id\" class=\"value\">None</span></div>\n"
|
||||
"<button type=\"button\" id=\"quick-writer\" class=\"quick-trigger\" aria-label=\"Active writer management\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"writer-id\" class=\"value\">None</span></button></div>\n"
|
||||
"<div class=\"status-item\"><span class=\"label\">Broker clients</span>"
|
||||
"<span id=\"broker-clients\" class=\"value\">—</span></div>\n"
|
||||
"<button type=\"button\" id=\"quick-broker\" class=\"quick-trigger\" aria-label=\"Live broker clients\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"broker-clients\" class=\"value\">—</span></button></div>\n"
|
||||
"<div class=\"status-item wide\"><span class=\"label\">Wi-Fi</span>"
|
||||
"<span id=\"wifi-summary\" class=\"value\">Loading…</span></div>\n"
|
||||
"<button type=\"button\" id=\"quick-network\" class=\"quick-trigger\" aria-label=\"Wi-Fi quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"wifi-summary\" class=\"value\">Loading…</span></button></div>\n"
|
||||
"<div class=\"status-item wide\"><span class=\"label\">Serial</span>"
|
||||
"<span id=\"serial-summary\" class=\"value\">Loading…</span></div>\n"
|
||||
"<button type=\"button\" id=\"quick-serial\" class=\"quick-trigger\" aria-label=\"Serial quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"serial-summary\" class=\"value\">Loading…</span></button></div>\n"
|
||||
"</div>\n"
|
||||
"<div class=\"panel controls\">\n"
|
||||
"<div class=\"button-row\">\n"
|
||||
@@ -183,10 +186,22 @@ static const char s_index_html[] =
|
||||
"<div id=\"terminal\" class=\"terminal-host\"></div>\n"
|
||||
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
|
||||
"<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>"
|
||||
"<div class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
|
||||
"<div id=\"quick-header\" hidden><h2 id=\"quick-title\">Quick settings</h2><button id=\"quick-close\" class=\"button\" type=\"button\">Close quick settings</button> <a id=\"quick-full\" href=\"#serial-settings\">Open full settings page</a><p id=\"quick-help\" class=\"connection-detail\">Opening and selecting never applies changes. Apply changes RAM; Save persists working device values, not drafts. Closing discards drafts; admitted work is not cancelled. Password changes are available only on the full Network page.</p></div>"
|
||||
"<div id=\"settings-navigation\" class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
|
||||
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
|
||||
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
|
||||
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button></div>"
|
||||
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button><button id=\"settings-broker\" class=\"button\" type=\"button\" aria-pressed=\"false\">Broker</button><button id=\"settings-ssh\" class=\"button\" type=\"button\" aria-pressed=\"false\">SSH</button><button id=\"settings-lifecycle\" class=\"button\" type=\"button\" aria-pressed=\"false\">HTTPS / Reboot</button></div>"
|
||||
"<div id=\"lifecycle-settings\" hidden><h2>HTTPS identity, service and device reboot</h2>"
|
||||
"<p class=\"connection-detail\">Stop/Restart HTTPS closes ALL web logins and both browser terminal routes, including clients admitted before execution. Settings and certificate identity are unchanged; HTTPS restart preserves device working configuration. Save unsaved browser drafts first. Recover a stopped web service with <code>web start</code> through UART0 or still-running, reachable admin SSH. USB remains UART1 serial access, not a web administration console.</p>"
|
||||
"<p class=\"connection-detail\">Reboot interrupts ALL clients and the entire device, including SSH, USB and UART operation during restart. Unsaved RAM-only working configuration and browser drafts can be lost. Saved configuration and identities are not reset. After boot, restore network reachability, reload and sign in explicitly; inspect the outcome before another action.</p>"
|
||||
"<p class=\"connection-detail\">Rotate replaces and persists the HTTPS certificate AND private key, changes browser trust, and disconnects all web logins/terminals. No SSH identity or user/configuration change. Verify the NEW SHA-256 certificate fingerprint using trusted UART0 (<code>web certificate info</code>) before accepting browser trust; a certificate warning is not verification. Reload and sign in freshly. Native USB remains independent UART1 serial access, not administration. No browser TLS reset/recovery or key/certificate export.</p>"
|
||||
"<p id=\"lifecycle-identity\" class=\"connection-detail\"></p>"
|
||||
"<button id=\"lifecycle-network\" class=\"button\" type=\"button\">Open existing Network / Wi-Fi controls</button>"
|
||||
"<button id=\"lifecycle-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"lifecycle-detail\" class=\"connection-detail\" role=\"status\"></p>"
|
||||
"<div class=\"serial-actions\"><button id=\"lifecycle-stop\" class=\"button\" type=\"button\">Stop HTTPS…</button><button id=\"lifecycle-restart\" class=\"button\" type=\"button\">Restart HTTPS…</button><button id=\"lifecycle-reboot\" class=\"button\" type=\"button\">Reboot device…</button><button id=\"lifecycle-rotate\" class=\"button\" type=\"button\">Rotate HTTPS identity…</button><button id=\"lifecycle-result\" class=\"button\" type=\"button\">Check Operation Result</button></div>"
|
||||
"<p id=\"lifecycle-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Acknowledgement is not peer receipt or completion. Connection loss, expiry, revocation or timeout does not prove cancellation after admission. No automatic mutation retry or restore. Check Result, inspect state, then act explicitly.</p><a href=\"/\">Reload / sign in after recovery</a></div>\n"
|
||||
"<div id=\"ssh-settings\" hidden><h2>SSH service and sessions</h2><p class=\"connection-detail\">SSH only, TCP port 22. Start/Stop do not change saved settings or host identity. Stop closes all SSH sessions, including any admitted before execution; an SSH administrator's already executing command may finish. HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped. Targeted disconnect affects only the selected SSH connection, not all logins for its account. Viewing or selecting never changes services or writer ownership.</p><button id=\"ssh-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"ssh-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"ssh-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Disconnect SSH session<select id=\"ssh-target\"><option value=\"\">Select a session</option><option id=\"ssh-option-0\" hidden disabled></option><option id=\"ssh-option-1\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"ssh-start\" class=\"button\" type=\"button\">Start SSH…</button><button id=\"ssh-stop\" class=\"button\" type=\"button\">Stop SSH…</button><button id=\"ssh-rotate\" class=\"button\" type=\"button\">Rotate SSH host identity…</button><button id=\"ssh-disconnect\" class=\"button\" type=\"button\">Disconnect selected…</button><button id=\"ssh-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"ssh-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. After submission use Check Operation Result, then Refresh. Navigation or timeout does not cancel admitted work. No automatic mutation retry.</p></div>\n"
|
||||
"<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh retains explicit selection without renewing its lease token. Stale selections require choosing the blank option then the target again. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</p></div>\n"
|
||||
"<div id=\"display-settings\" hidden><h2>Display</h2>\n"
|
||||
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 0–86400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
|
||||
"<p class=\"connection-detail\">Apply and Defaults change RAM only. Save persists the working snapshot, not browser drafts. Load discards drafts and uses stored settings, or defaults if storage is absent/incompatible; it does not change NVS. Reset saves defaults and applies them. Refresh discards drafts. Intervening configuration edits reject stale operations: Refresh and review before retrying.</p>\n"
|
||||
@@ -208,7 +223,7 @@ static const char s_index_html[] =
|
||||
"Start/Stop also change RAM enabled-at-boot; Save persists that policy. Reconnect/Next do nothing while stopped. "
|
||||
"Next selects the next enabled profile in priority order, wrapping.</p>"
|
||||
"<p class=\"connection-detail\">Network changes may disconnect HTTPS, SSH and both browser terminals before acknowledgement. Accepted is NOT connected. "
|
||||
"Recover through STA/AP, UART0 or network-independent native USB serial. Navigation itself preserves terminals and writer lease.</p>"
|
||||
"Restore STA/AP reachability or use UART0 administration; SSH recovery requires a reachable running SSH service. Native USB preserves network-independent UART1 serial access, not Wi-Fi administration. Navigation itself preserves terminals and writer lease.</p>"
|
||||
"<button id=\"network-refresh\" class=\"button\" type=\"button\">Refresh</button>"
|
||||
"<p id=\"network-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"network-summary\" class=\"settings-values\"></dl>"
|
||||
"<div id=\"network-edit\" hidden><h3>Wi-Fi working configuration</h3><div class=\"settings-edit\">"
|
||||
@@ -223,9 +238,9 @@ static const char s_index_html[] =
|
||||
"<label id=\"network-security-label\">Security<select id=\"network-security\"><option value=\"mixed\">WPA2 or stronger (mixed)</option><option value=\"wpa3\">WPA3</option></select></label>"
|
||||
"<label>SSID editor<select id=\"network-ssid-mode\"><option value=\"text\">Ordinary text (UTF-8)</option><option value=\"hex\">Exact bytes (hex)</option></select></label>"
|
||||
"<label>SSID<input id=\"network-ssid\" maxlength=\"256\" autocomplete=\"off\" spellcheck=\"false\"></label>"
|
||||
"<label>Password action<select id=\"network-password-mode\"><option value=\"keep\">Keep existing (default)</option>"
|
||||
"<label id=\"network-password-mode-label\">Password action<select id=\"network-password-mode\"><option value=\"keep\">Keep existing (default)</option>"
|
||||
"<option value=\"replace\">Replace</option><option id=\"network-password-clear\" value=\"clear\">Clear (disabled STA only)</option></select></label>"
|
||||
"<label>New password<input id=\"network-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\" disabled></label></div>"
|
||||
"<label id=\"network-password-label\">New password<input id=\"network-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\" disabled></label></div>"
|
||||
"<p id=\"network-password-status\" class=\"connection-detail\"></p><p id=\"network-ssid-detail\" class=\"connection-detail\" role=\"status\"></p>"
|
||||
"<p class=\"connection-detail\">SSID maximum: 32 bytes, not characters. Text entry encodes UTF-8 once; hex preserves arbitrary bytes including zero. "
|
||||
"Switching modes is lossless or refused. Password replacement requires 8–63 printable ASCII characters; blank never clears. "
|
||||
@@ -392,8 +407,63 @@ static const char s_app_js[] =
|
||||
" element('refresh-settings').disabled = busy;\n"
|
||||
" element('serial-result').disabled = busy;\n"
|
||||
"}\n"
|
||||
"let quick = null, quickLeaveTimer = 0, quickSuppressFocus = false, quickDismissed = null;\n"
|
||||
"const quickDomains = ['serial','network','broker','writer'];\n"
|
||||
"const quickTitle = domain => ({serial:'Serial quick settings',network:'Wi-Fi quick settings',broker:'Live broker clients',writer:'Active writer management'})[domain];\n"
|
||||
"function resetQuick() {\n"
|
||||
" window.clearTimeout(quickLeaveTimer); quickLeaveTimer = 0; quick = null;\n"
|
||||
" settingsHost.setAttribute('data-quick', 'false'); settingsHost.setAttribute('role', 'region');\n"
|
||||
" element('quick-header').hidden = true; settingsHost.setAttribute('aria-label', 'Settings');\n"
|
||||
" stopBrokerLive();\n"
|
||||
" for (const domain of quickDomains) element('quick-' + domain).setAttribute('aria-expanded', 'false');\n"
|
||||
"}\n"
|
||||
"function closeQuick(focus = false) {\n"
|
||||
" if (!quick) return;\n"
|
||||
" const {previous, trigger} = quick; quickDismissed = trigger; resetQuick(); selectTerminal(previous, true, false);\n"
|
||||
" if (focus) { quickSuppressFocus = true; trigger.focus(); quickSuppressFocus = false; }\n"
|
||||
"}\n"
|
||||
"function openQuick(domain, activate = false) {\n"
|
||||
" if (quickSuppressFocus || !sessionVerified || accountRole !== 'admin' || suspended || unloading || navigating || loggingOut) return;\n"
|
||||
" if (!activate && quickDismissed === element('quick-' + domain)) return;\n"
|
||||
" if (!activate && quick && settingsHost.contains(document.activeElement)) return;\n"
|
||||
" if (activate) quickDismissed = null;\n"
|
||||
" if (quick?.domain === domain) { if (activate) element('quick-close').focus(); return; }\n"
|
||||
" if (quick) closeQuick();\n"
|
||||
" // Broker contexts never navigate away from full-page drafts; Serial/Wi-Fi retain their explicit navigation.\n"
|
||||
" const brokerContext = domain === 'broker' || domain === 'writer';\n"
|
||||
" if (selected === 'settings') { if (activate) { if (!brokerContext) selectSettingsDomain(domain); element('settings-' + (brokerContext ? settingsDomain : domain)).focus(); } return; }\n"
|
||||
" const previous = selected, trigger = element('quick-' + domain);\n"
|
||||
" selectTerminal('settings', false); selectSettingsDomain(brokerContext ? 'broker' : domain, false); clearNetworkSecret();\n"
|
||||
" quick = {domain, previous, trigger}; settingsHost.setAttribute('data-quick', 'true');\n"
|
||||
" settingsHost.setAttribute('role', 'dialog'); settingsHost.setAttribute('aria-label', quickTitle(domain));\n"
|
||||
" element('quick-title').textContent = quickTitle(domain);\n"
|
||||
" element('quick-help').textContent = brokerContext ? 'Live clients share one writer and isolated observation. Opening, refreshing and selecting never transfers the lease. Assign writer requires separate confirmation. Closing does not cancel admitted work; after uncertainty use Check Result, never automatic retry.' : 'Opening and selecting never applies changes. Apply changes RAM; Save persists working device values, not drafts. Closing discards drafts; admitted work is not cancelled. Password changes are available only on the full Network page.';\n"
|
||||
" element('quick-header').hidden = false; trigger.setAttribute('aria-expanded', 'true');\n"
|
||||
" terminalHost.hidden = previous !== 'serial'; adminHost.hidden = previous !== 'admin';\n"
|
||||
" refreshSettings(); if (activate) element('quick-close').focus();\n"
|
||||
"}\n"
|
||||
"function quickContains(target) { return !!quick && !!target && (settingsHost.contains(target) || quick.trigger.contains(target)); }\n"
|
||||
"function quickLeave() {\n"
|
||||
" window.clearTimeout(quickLeaveTimer); if (!quick) return;\n"
|
||||
" quickLeaveTimer = window.setTimeout(() => { quickLeaveTimer = 0; if (quick && !quickContains(document.activeElement) && !settingsHost.matches(':hover') && !quick.trigger.matches(':hover')) closeQuick(); }, 250);\n"
|
||||
"}\n"
|
||||
"for (const domain of quickDomains) {\n"
|
||||
" const trigger = element('quick-' + domain);\n"
|
||||
" if (domain === 'broker' || domain === 'writer') trigger.setAttribute('title', 'Open client/writer management. When a full Settings page is open, use its Broker navigation or return to a terminal first; contextual triggers preserve that page and its drafts.');\n"
|
||||
" trigger.addEventListener('pointerenter', event => { if (event.pointerType === 'mouse') openQuick(domain); });\n"
|
||||
" trigger.addEventListener('focus', () => openQuick(domain));\n"
|
||||
" trigger.addEventListener('click', () => openQuick(domain, true));\n"
|
||||
" trigger.addEventListener('pointerleave', () => { if (quickDismissed === trigger) quickDismissed = null; quickLeave(); }); trigger.addEventListener('focusout', () => { if (quickDismissed === trigger) quickDismissed = null; quickLeave(); });\n"
|
||||
"}\n"
|
||||
"settingsHost.addEventListener('pointerenter', () => window.clearTimeout(quickLeaveTimer));\n"
|
||||
"settingsHost.addEventListener('pointerleave', quickLeave); settingsHost.addEventListener('focusout', quickLeave);\n"
|
||||
"element('quick-close').addEventListener('click', () => closeQuick(true));\n"
|
||||
"element('quick-full').addEventListener('click', event => { event.preventDefault(); if (!quick) return; resetQuick(); clearNetworkSecret(); networkButtons(); terminalHost.hidden = adminHost.hidden = true; element('settings-' + settingsDomain).focus(); });\n"
|
||||
"window.addEventListener('pointerdown', event => { if (quick && !quickContains(event.target)) closeQuick(); });\n"
|
||||
"window.addEventListener('keydown', event => { if (quick && event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeQuick(true); } });\n"
|
||||
"function clearSettings() {\n"
|
||||
" clearAccounts(); clearNetwork(); clearDisplay();\n"
|
||||
" resetQuick();\n"
|
||||
" clearAccounts(); clearNetwork(); clearDisplay(); clearBroker(); clearSsh(); clearLifecycle();\n"
|
||||
" if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n"
|
||||
" stopSerialAuto(true);\n"
|
||||
" if (settingsAbort) settingsAbort.abort();\n"
|
||||
@@ -408,6 +478,9 @@ static const char s_app_js[] =
|
||||
" if (settingsDomain === 'accounts') return refreshAccounts();\n"
|
||||
" if (settingsDomain === 'network') return refreshNetwork();\n"
|
||||
" if (settingsDomain === 'display') return refreshDisplay();\n"
|
||||
" if (settingsDomain === 'broker') return refreshBroker();\n"
|
||||
" if (settingsDomain === 'ssh') return refreshSsh();\n"
|
||||
" if (settingsDomain === 'lifecycle') return lifecycleRequest(null, true);\n"
|
||||
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
|
||||
" settingsHost.hidden = false;\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
|
||||
@@ -638,6 +711,331 @@ static const char s_app_js[] =
|
||||
"element('display-refresh').addEventListener('click', refreshDisplay);\n"
|
||||
"element('display-result').addEventListener('click', () => displayOperation(null));\n"
|
||||
"for (const action of displayActions) element('display-' + action).addEventListener('click', () => displayOperation(action));\n"
|
||||
"const brokerDetail = element('broker-detail');\n"
|
||||
"let brokerClients = [], brokerWriter = 0, brokerSelection = null, brokerLiveTimer = 0;\n"
|
||||
"function stopBrokerLive() { window.clearTimeout(brokerLiveTimer); brokerLiveTimer = 0; }\n"
|
||||
"function scheduleBrokerLive() {\n"
|
||||
" stopBrokerLive();\n"
|
||||
" if (!quick || settingsDomain !== 'broker' || brokerAbort || brokerAuto || brokerOperationPending) return;\n"
|
||||
" brokerLiveTimer = window.setTimeout(() => { brokerLiveTimer = 0; refreshBroker(); }, 5000);\n"
|
||||
"}\n"
|
||||
"function brokerSelectionCurrent() {\n"
|
||||
" return brokerSelection && !brokerSelection.stale && brokerSelection.generation === brokerGeneration && brokerClients.some(c => c.id === brokerSelection.target && c.type === brokerSelection.type && c.name_hex === brokerSelection.name_hex);\n"
|
||||
"}\n"
|
||||
"let brokerAbort = null, brokerGeneration = 0, brokerOperationAction = '';\n"
|
||||
"const brokerActions = ['assign'];\n"
|
||||
"const brokerUint = v => Number.isInteger(v) && v >= 0 && v <= 4294967295;\n"
|
||||
"function brokerValid(v) {\n"
|
||||
" return brokerUint(v.writer) && Array.isArray(v.clients) && v.clients.length <= 8 &&\n"
|
||||
" new Set(v.clients.map(c => c.id)).size === v.clients.length &&\n"
|
||||
" (!v.writer || v.clients.some(c => c.id === v.writer)) && v.clients.every(c =>\n"
|
||||
" c && Object.keys(c).length === 6 && brokerUint(c.id) && c.id > 0 &&\n"
|
||||
" Number.isInteger(c.type) && c.type >= 0 && c.type <= 4 &&\n"
|
||||
" typeof c.name_hex === 'string' && /^(?:[0-9a-f]{2}){0,23}$/.test(c.name_hex) &&\n"
|
||||
" Number.isInteger(c.pending) && c.pending >= 0 && c.pending <= 4096 &&\n"
|
||||
" Number.isInteger(c.high_water) && c.high_water >= c.pending && c.high_water <= 4096 &&\n"
|
||||
" typeof c.dropped === 'string' && /^(0|[1-9][0-9]{0,19})$/.test(c.dropped) && BigInt(c.dropped) <= 18446744073709551615n);\n"
|
||||
"}\n"
|
||||
"const brokerName = c => new TextDecoder().decode(Uint8Array.from(c.name_hex.match(/../g) || [], h => parseInt(h, 16)));\n"
|
||||
"const brokerLabel = c => String(c.id) + ' / ' + ['Console','USB','Web','SSH','Internal'][c.type] + ' / ' + brokerName(c);\n"
|
||||
"let brokerOperationId = 0, brokerOperationPending = false, brokerAwaitingAck = false;\n"
|
||||
"let brokerOutcomeWarning = '', brokerAuto = null;\n"
|
||||
"function stopBrokerAuto(recovery = false) {\n"
|
||||
" if (!brokerAuto) return;\n"
|
||||
" window.clearTimeout(brokerAuto.timer); window.clearTimeout(brokerAuto.deadline); brokerAuto = null;\n"
|
||||
" if (recovery) element('broker-operation-detail').textContent += ' Automatic checking stopped; outcome still uncertain. Select Check Result; do not resubmit.';\n"
|
||||
"}\n"
|
||||
"function expireBrokerAuto(auto) {\n"
|
||||
" if (brokerAuto !== auto) return;\n"
|
||||
" stopBrokerAuto(true);\n"
|
||||
" if (brokerAbort) brokerAbort.abort();\n"
|
||||
" brokerAbort = null; brokerButtons();\n"
|
||||
"}\n"
|
||||
"function scheduleBrokerCheck() {\n"
|
||||
" const auto = brokerAuto;\n"
|
||||
" if (!auto) return;\n"
|
||||
" if (auto.attempts >= 10) { stopBrokerAuto(true); brokerButtons(); return; }\n"
|
||||
" auto.timer = window.setTimeout(() => {\n"
|
||||
" if (brokerAuto !== auto) return;\n"
|
||||
" if (performance.now() >= auto.until) { expireBrokerAuto(auto); return; }\n"
|
||||
" ++auto.attempts; brokerOperation(null, true);\n"
|
||||
" }, 1000);\n"
|
||||
"}\n"
|
||||
"function startBrokerAuto() {\n"
|
||||
" const auto = {attempts: 0, timer: 0, deadline: 0, until: performance.now() + 15000}; brokerAuto = auto;\n"
|
||||
" auto.deadline = window.setTimeout(() => expireBrokerAuto(auto), 15000);\n"
|
||||
" scheduleBrokerCheck();\n"
|
||||
"}\n"
|
||||
"function brokerButton(id, disabled) {\n"
|
||||
" const button = element('broker-' + id);\n"
|
||||
" button.setAttribute('aria-disabled', String(disabled));\n"
|
||||
" // Preserve keyboard focus during live updates; handlers independently gate all actions.\n"
|
||||
" button.disabled = disabled && document.activeElement !== button;\n"
|
||||
"}\n"
|
||||
"function brokerButtons() {\n"
|
||||
" const busy = !!brokerAbort || !!brokerAuto;\n"
|
||||
" const target = Number(element('broker-target').value);\n"
|
||||
" brokerButton('assign', busy || brokerOperationPending || !brokerSelectionCurrent() || !brokerGeneration || brokerGeneration === 4294967295 || !brokerClients.some(c => c.id === target) || target === brokerWriter);\n"
|
||||
" brokerButton('target', (!!brokerAbort && !brokerAbort.brokerRead) || !!brokerAuto || brokerOperationPending || !brokerGeneration);\n"
|
||||
" brokerButton('refresh', busy);\n"
|
||||
" brokerButton('result', busy);\n"
|
||||
"}\n"
|
||||
"function clearBroker() {\n"
|
||||
" if (!brokerAuto && brokerOperationPending) element('broker-operation-detail').textContent = brokerOutcomeWarning + 'Outcome pending or unknown. Check Result on return; navigation does not cancel work.';\n"
|
||||
" stopBrokerAuto(true);\n"
|
||||
" if (brokerAbort) brokerAbort.abort(); brokerAbort = null; brokerGeneration = 0;\n"
|
||||
" stopBrokerLive(); brokerSelection = null;\n"
|
||||
" brokerClients = []; brokerWriter = 0; element('broker-target').value = '';\n"
|
||||
" element('broker-values').textContent = '';\n"
|
||||
" for (let i = 0; i < 8; ++i) { const o = element('broker-option-' + i); o.textContent = ''; o.hidden = o.disabled = true; }\n"
|
||||
" brokerDetail.textContent = 'Select Refresh to read current values.'; brokerButtons();\n"
|
||||
"}\n"
|
||||
"async function refreshBroker() {\n"
|
||||
" if (settingsDomain !== 'broker' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || brokerAbort || brokerAuto) return;\n"
|
||||
" stopBrokerLive();\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; controller.brokerRead = true; brokerAbort = controller; brokerButtons();\n"
|
||||
" const deadline = window.setTimeout(() => { if (brokerAbort !== controller) return; controller.abort(); brokerAbort = null; brokerGeneration = 0; if (brokerSelection) brokerSelection.stale = true; brokerDetail.textContent = 'Live refresh timed out. Selection stale; Refresh and reselect. Automatic refresh stopped.'; brokerButtons(); }, 5000);\n"
|
||||
" controller.signal.addEventListener('abort', () => window.clearTimeout(deadline), {once:true});\n"
|
||||
" const current = () => brokerAbort === controller && selected === 'settings' && settingsDomain === 'broker';\n"
|
||||
" brokerDetail.textContent = 'Reading broker clients... Previous snapshot is stale until refreshed.';\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
||||
" const {status, payload: v} = await api('/api/settings/broker', generation, {signal: controller.signal, limit: 2048, current});\n"
|
||||
" if (status !== 200 || !v || Object.keys(v).length !== 3 || !Number.isInteger(v.generation) || v.generation < 1 || v.generation > 4294967295 || !brokerValid(v)) throw new Error('Invalid snapshot');\n"
|
||||
" brokerGeneration = v.generation; brokerClients = v.clients; brokerWriter = v.writer;\n"
|
||||
" if (brokerSelection && !brokerSelectionCurrent()) brokerSelection.stale = true;\n"
|
||||
" if (brokerSelection && !brokerClients.some(c => c.id === brokerSelection.target)) brokerSelection.missing = true;\n"
|
||||
" const list = element('broker-values'); list.textContent = '';\n"
|
||||
" for (let i = 0; i < 8; ++i) {\n"
|
||||
" const c = brokerClients[i], o = element('broker-option-' + i); o.hidden = o.disabled = !c;\n"
|
||||
" o.value = c ? String(c.id) : ''; o.textContent = c ? brokerLabel(c) : '';\n"
|
||||
" if (c) {\n"
|
||||
" const dt = document.createElement('dt'), dd = document.createElement('dd');\n"
|
||||
" dt.textContent = brokerLabel(c);\n"
|
||||
" dd.textContent = (c.id === brokerWriter ? 'Writer' : 'Observer') + ' — pending ' + c.pending + ' B; high-water ' + c.high_water + ' B; dropped ' + c.dropped + ' B';\n"
|
||||
" list.appendChild(dt); list.appendChild(dd);\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" element('broker-target').value = brokerSelection && !brokerSelection.missing && brokerClients.some(c => c.id === brokerSelection.target) ? String(brokerSelection.target) : '';\n"
|
||||
" brokerDetail.textContent = (brokerOperationPending ? 'Outcome pending or unknown. ' : '') + 'Writer: ' + (brokerWriter || 'None') + '. ' + brokerClients.length + ' connected clients. ' + (brokerSelection?.stale ? 'Selection stale: lease or client changed. Choose the blank option then explicitly select again before confirmation. ' : 'Refresh preserves selection, never renews its confirmation token. ') + (quick ? 'Live refresh every 5 seconds after completion. ' : '') + (brokerGeneration === 4294967295 ? 'Confirmation generation exhausted; use the admin shell.' : '');\n"
|
||||
" } catch (error) {\n"
|
||||
" if (live(generation) && current()) { brokerGeneration = 0; if (brokerSelection) brokerSelection.stale = true; brokerDetail.textContent = (error.status ? error.message : 'Broker snapshot unavailable or invalid.') + ' Selection stale. Select Refresh and reselect to retry.'; }\n"
|
||||
" } finally { window.clearTimeout(deadline); if (current()) { brokerAbort = null; brokerButtons(); if (brokerGeneration) scheduleBrokerLive(); } }\n"
|
||||
"}\n"
|
||||
"async function brokerOperation(action, automatic = false) {\n"
|
||||
" if (settingsDomain !== 'broker') return;\n"
|
||||
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || brokerAbort || (action && brokerOperationPending)) return;\n"
|
||||
" if (!automatic && brokerAuto) return;\n"
|
||||
" const detail = element('broker-operation-detail');\n"
|
||||
" let refresh = false, poll = false;\n"
|
||||
" let body;\n"
|
||||
" if (action) {\n"
|
||||
" const target = Number(element('broker-target').value), client = brokerClients.find(c => c.id === target);\n"
|
||||
" if (!brokerSelectionCurrent()) { detail.textContent = 'Selection stale or missing. Refresh, explicitly select and confirm again. No assignment submitted.'; return; }\n"
|
||||
" if (action !== 'assign' || !client || !brokerGeneration || brokerGeneration === 4294967295 || target === brokerWriter || target !== brokerSelection.target) return;\n"
|
||||
" const value = {action, generation: brokerSelection.generation, target};\n"
|
||||
" if (!window.confirm('Assign the writer lease to ' + brokerLabel(client) + '? Current writer: ' + (brokerWriter || 'None') + '. The previous writer becomes an observer. Already queued UART bytes are not recalled.')) return;\n"
|
||||
" body = JSON.stringify(value);\n"
|
||||
" if (new TextEncoder().encode(body).length > 256) return;\n"
|
||||
" }\n"
|
||||
" stopBrokerLive(); if (action && brokerSelection) brokerSelection.stale = true;\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; brokerAbort = controller;\n"
|
||||
" const auto = automatic ? brokerAuto : null;\n"
|
||||
" const current = () => {\n"
|
||||
" if (auto && brokerAuto === auto && performance.now() >= auto.until) expireBrokerAuto(auto);\n"
|
||||
" return brokerAbort === controller && selected === 'settings' && settingsDomain === 'broker';\n"
|
||||
" };\n"
|
||||
" brokerButtons();\n"
|
||||
" detail.textContent = brokerOutcomeWarning + (action ? 'Assigning... Submitting once; completion will be checked automatically.' : 'Reading latest result for this login...');\n"
|
||||
" brokerDetail.textContent = 'Snapshot stale: operation pending or outcome not yet checked.';\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false)) throw new Error('Session check cancelled');\n"
|
||||
" if (!current()) return;\n"
|
||||
" if (action) { brokerOperationPending = true; brokerAwaitingAck = true; }\n"
|
||||
" const {status, payload: result} = await api('/api/settings/broker-operation', generation, {method: action ? 'POST' : 'GET', body, signal: controller.signal, limit: 96, current});\n"
|
||||
" if (status !== (action ? 202 : 200) || !result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 ||\n"
|
||||
" !['none', ...brokerActions].includes(result.action) || !['idle','pending','ok','failed','cancelled','conflict'].includes(result.state) ||\n"
|
||||
" ((result.id === 0) !== (result.state === 'idle')) || ((result.id === 0) !== (result.action === 'none')) ||\n"
|
||||
" (action && (!result.id || result.action !== action || result.state !== 'pending'))) throw new Error('Invalid operation response');\n"
|
||||
" if (!action && brokerOperationId && brokerOperationId === result.id && brokerOperationAction && brokerOperationAction !== result.action) throw new Error('Operation action changed for the same ID');\n"
|
||||
" const uncertain = !action && brokerAwaitingAck;\n"
|
||||
" const replaced = !action && brokerOperationId && brokerOperationId !== result.id;\n"
|
||||
" if (action) brokerOutcomeWarning = '';\n"
|
||||
" else if (uncertain) brokerOutcomeWarning = 'Submission acknowledgement was lost; this latest result may belong to an earlier operation or another tab. Inspect before retrying. ';\n"
|
||||
" else if (replaced) brokerOutcomeWarning = 'Previous result was replaced or unavailable; its outcome is unknown. ';\n"
|
||||
" brokerOperationId = result.id; brokerOperationAction = result.action; brokerOperationPending = result.state === 'pending'; brokerAwaitingAck = false;\n"
|
||||
" const messages = {idle: 'No retained result. Outcome may be unknown; refresh clients and inspect the current writer before retrying.',\n"
|
||||
" pending: 'Pending: queued or executing; do not resubmit. Automatic checks are bounded; Check Result is available for recovery.',\n"
|
||||
" ok: 'Assignment completed at execution time. Another client may subsequently change the lease; refresh to inspect.',\n"
|
||||
" failed: 'Assignment failed; refresh and inspect before retrying.',\n"
|
||||
" conflict: 'Writer lease changed, target disconnected, or broker unavailable. No lease change by this operation. Refresh, reselect and confirm again.',\n"
|
||||
" cancelled: 'Operation rejected before execution because the login or queue deadline was no longer current.'};\n"
|
||||
" detail.textContent = brokerOutcomeWarning + result.action + ': ' + messages[result.state];\n"
|
||||
" poll = !replaced && result.state === 'pending' && (!!action || automatic);\n"
|
||||
" refresh = !action && result.state !== 'pending' && result.state !== 'idle';\n"
|
||||
" } catch (error) {\n"
|
||||
" if (live(generation) && current()) detail.textContent = brokerOutcomeWarning + (error.status ? error.message : 'Operation outcome unknown.') + ' Check Result and Refresh before any explicit retry. No automatic retry.';\n"
|
||||
" } finally {\n"
|
||||
" if (current()) {\n"
|
||||
" brokerAbort = null;\n"
|
||||
" if (poll) { if (action) startBrokerAuto(); else scheduleBrokerCheck(); }\n"
|
||||
" else stopBrokerAuto();\n"
|
||||
" brokerButtons();\n"
|
||||
" if (refresh) await refreshBroker();\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"element('settings-broker').addEventListener('click', () => selectSettingsDomain('broker'));\n"
|
||||
"element('broker-refresh').addEventListener('click', refreshBroker);\n"
|
||||
"element('broker-result').addEventListener('click', () => brokerOperation(null));\n"
|
||||
"for (const action of brokerActions) element('broker-' + action).addEventListener('click', () => brokerOperation(action));\n"
|
||||
"element('broker-target').addEventListener('change', () => {\n"
|
||||
" const client = brokerClients.find(c => c.id === Number(element('broker-target').value));\n"
|
||||
" brokerSelection = client && (!brokerAbort || brokerAbort.brokerRead) && !brokerOperationPending && brokerGeneration ? {target:client.id, type:client.type, name_hex:client.name_hex, generation:brokerGeneration, stale:false} : null;\n"
|
||||
" if (brokerSelection) brokerDetail.textContent = 'Explicit selection: ' + brokerLabel(client) + '. Confirm assignment separately; refresh never renews this lease token.';\n"
|
||||
" brokerButtons();\n"
|
||||
"});\n"
|
||||
"const lifecycleActions = ['stop','restart','reboot','rotate'];\n"
|
||||
"let lifecycleSnapshot = null, lifecycleAbort = null, lifecyclePending = false, lifecycleAwaitingAck = false, lifecycleId = 0, lifecycleAction = '';\n"
|
||||
"const lifecycleRecovery = 'Outcome may be unknown; no automatic retry. HTTPS stop: use UART0 or reachable admin SSH web start. HTTPS restart expires this login; reload and sign in again. Reboot interrupts every client, including USB; restore network after boot, reload/sign in and inspect before acting again. Rotation may have persisted a NEW identity even when stop/start fails; no rollback. The stored fingerprint may differ from a retained old server certificate. Inspect with trusted UART0 web certificate info, verify the new fingerprint before renewing trust, then reload and sign in freshly. SSH and native USB UART1 access are not stopped by rotation. A stuck ACK handoff requires canonical web stop then web start; this closes all web clients.';\n"
|
||||
"function lifecycleButtons() {\n"
|
||||
" const busy = !!lifecycleAbort;\n"
|
||||
" element('lifecycle-refresh').disabled = element('lifecycle-result').disabled = busy;\n"
|
||||
" for (const action of lifecycleActions) element('lifecycle-' + action).disabled = busy || lifecyclePending || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable);\n"
|
||||
"}\n"
|
||||
"function clearLifecycle() {\n"
|
||||
" if (lifecycleAbort) lifecycleAbort.abort(); lifecycleAbort = null; lifecycleSnapshot = null;\n"
|
||||
" element('lifecycle-detail').textContent = 'Select Refresh to inspect HTTPS state.';\n"
|
||||
" element('lifecycle-identity').textContent = '';\n"
|
||||
" if (lifecyclePending) element('lifecycle-operation-detail').textContent = lifecycleRecovery + ' Navigation does not cancel admitted work.';\n"
|
||||
" lifecycleButtons();\n"
|
||||
"}\n"
|
||||
"async function lifecycleRequest(action, snapshotRead = false) {\n"
|
||||
" if (settingsDomain !== 'lifecycle' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || lifecycleAbort || (action && lifecyclePending)) return;\n"
|
||||
" let body; const detail = element(snapshotRead ? 'lifecycle-detail' : 'lifecycle-operation-detail');\n"
|
||||
" if (action) {\n"
|
||||
" if (!lifecycleActions.includes(action) || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable)) return;\n"
|
||||
" const value = {action, generation:lifecycleSnapshot.generation};\n"
|
||||
" if (action === 'rotate') value.identity_generation = lifecycleSnapshot.identity_generation;\n"
|
||||
" const scope = action === 'rotate' ? 'Rotate and persist the HTTPS certificate and private key? CURRENT stored SHA-256 fingerprint: ' + lifecycleSnapshot.fingerprint + '. Identity generation ' + value.identity_generation + ', service generation ' + value.generation + '. ALL web logins and BOTH browser terminals disconnect. Save browser drafts first. Browser trust changes; SSH identity and device settings remain unchanged.' : action === 'reboot' ? 'Reboot the ENTIRE device? ALL clients disconnect; SSH, USB and UART operation are interrupted during restart. Unsaved RAM-only working configuration and browser drafts may be lost.' : (action === 'stop' ? 'Stop HTTPS?' : 'Restart HTTPS?') + ' ALL web logins and BOTH browser terminal routes disconnect, including clients admitted before execution. Device working configuration and identity are unchanged; save unsaved browser drafts first. SSH, USB and UART0 are not stopped.';\n"
|
||||
" if (!window.confirm(scope + ' ' + lifecycleRecovery)) return;\n"
|
||||
" body = JSON.stringify(value);\n"
|
||||
" }\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; lifecycleAbort = controller; lifecycleButtons();\n"
|
||||
" const current = () => lifecycleAbort === controller && settingsDomain === 'lifecycle' && selected === 'settings';\n"
|
||||
" const deadline = window.setTimeout(() => { if (!current()) return; controller.abort(); lifecycleAbort = null; lifecycleSnapshot = null; detail.textContent = 'Request timed out. ' + lifecycleRecovery; lifecycleButtons(); }, 15000);\n"
|
||||
" controller.signal.addEventListener('abort', () => window.clearTimeout(deadline), {once:true});\n"
|
||||
" detail.textContent = snapshotRead ? 'Reading HTTPS; previous snapshot is stale.' : 'Checking/submitting once. ' + lifecycleRecovery;\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
||||
" if (action) { lifecyclePending = true; lifecycleAwaitingAck = true; lifecycleId = 0; lifecycleAction = action; lifecycleSnapshot = null; lifecycleButtons(); }\n"
|
||||
" const {status, payload:v} = await api(snapshotRead ? '/api/settings/lifecycle' : '/api/settings/lifecycle-operation', generation, {method:action ? 'POST' : 'GET', body, signal:controller.signal, limit:snapshotRead ? 320 : 96, current});\n"
|
||||
" if (snapshotRead) {\n"
|
||||
" if (status !== 200 || !v || Object.keys(v).length !== 7 || !brokerUint(v.identity_generation) || typeof v.fingerprint !== 'string' || !(v.identity_generation ? /^[0-9a-f]{64}$/.test(v.fingerprint) : v.fingerprint === '') || typeof v.rotatable !== 'boolean' || (v.rotatable && (!v.controllable || !v.identity_generation || v.identity_generation === 4294967295)) || !brokerUint(v.generation) || !v.generation || typeof v.running !== 'boolean' || typeof v.transitioning !== 'boolean' || typeof v.controllable !== 'boolean' || (v.controllable && (!v.running || v.transitioning || v.generation === 4294967295))) throw new Error('Invalid lifecycle snapshot');\n"
|
||||
" lifecycleSnapshot = v;\n"
|
||||
" element('lifecycle-identity').textContent = v.identity_generation ? 'Stored HTTPS identity generation ' + v.identity_generation + '; service generation ' + v.generation + '; SHA-256 certificate fingerprint: ' + v.fingerprint + '. Public metadata, not proof of the currently served certificate or trusted verification.' : 'HTTPS identity metadata unavailable; rotation disabled. Use UART0 recovery.';\n"
|
||||
" detail.textContent = (v.running ? 'HTTPS running. ' : 'HTTPS stopped. ') + (v.controllable ? 'Explicit confirmation required.' : 'Transition, failed cleanup or exhausted generation: use UART0/admin SSH recovery.');\n"
|
||||
" } else {\n"
|
||||
" if (status !== (action ? 202 : 200) || !v || Object.keys(v).length !== 3 || !brokerUint(v.id) || !['none',...lifecycleActions].includes(v.action) || !['idle','pending','ok','failed','cancelled'].includes(v.state) || ((v.id === 0) !== (v.state === 'idle')) || ((v.id === 0) !== (v.action === 'none')) || (action && (!v.id || v.action !== action || v.state !== 'pending'))) throw new Error('Invalid lifecycle result');\n"
|
||||
" const matched = !!action || (!lifecycleAwaitingAck && lifecycleId === v.id && lifecycleAction === v.action);\n"
|
||||
" if (!matched && lifecyclePending) { detail.textContent = 'Result cannot be matched to this submission (lost ACK or replaced result). ' + lifecycleRecovery; return; }\n"
|
||||
" lifecycleId = v.id; lifecycleAction = v.action; lifecyclePending = v.state === 'pending'; lifecycleAwaitingAck = false; lifecycleSnapshot = null;\n"
|
||||
" const messages = {idle:'No retained result. Inspect before any new action.',pending:'ACK handoff, queued or executing; do not resubmit. Check Result explicitly.',ok:'Completed at execution time; not proof of peer receipt.',failed:'Operation failed or admission rejected; identity may already be persisted even if stop/start failed. No rollback. Inspect via UART0 before retrying.',cancelled:'Not executed: ACK/queue handoff, deadline or original login/currentness rejected before lifecycle admission.'};\n"
|
||||
" detail.textContent = v.action + ': ' + messages[v.state] + ' ' + lifecycleRecovery;\n"
|
||||
" element('lifecycle-detail').textContent = 'Snapshot stale. Refresh to inspect state; this never repeats a mutation.';\n"
|
||||
" }\n"
|
||||
" } catch (error) {\n"
|
||||
" if (live(generation) && current()) { lifecycleSnapshot = null; detail.textContent = (error.status ? error.message + ' ' : '') + lifecycleRecovery; }\n"
|
||||
" } finally { window.clearTimeout(deadline); if (current()) { lifecycleAbort = null; lifecycleButtons(); } }\n"
|
||||
"}\n"
|
||||
"element('settings-lifecycle').addEventListener('click', () => selectSettingsDomain('lifecycle'));\n"
|
||||
"element('lifecycle-network').addEventListener('click', () => selectSettingsDomain('network'));\n"
|
||||
"element('lifecycle-refresh').addEventListener('click', () => lifecycleRequest(null, true));\n"
|
||||
"element('lifecycle-result').addEventListener('click', () => lifecycleRequest(null));\n"
|
||||
"for (const action of lifecycleActions) element('lifecycle-' + action).addEventListener('click', () => lifecycleRequest(action));\n"
|
||||
"const sshActions = ['start','stop','disconnect','rotate'];\n"
|
||||
"let sshSnapshot = null, sshSelection = null, sshAbort = null, sshPending = false, sshAwaitingAck = false, sshId = 0, sshAction = '';\n"
|
||||
"const sshLabel = s => String(s.id) + ' / ' + ['Handshake','Serial','Admin console'][s.route] + ' / ' + (brokerName(s) || 'not authenticated');\n"
|
||||
"function sshButtons() {\n"
|
||||
" const busy = !!sshAbort, available = sshSnapshot && !sshSnapshot.transitioning && sshSnapshot.generation < 4294967295;\n"
|
||||
" element('ssh-refresh').disabled = element('ssh-result').disabled = busy;\n"
|
||||
" element('ssh-target').disabled = busy || sshPending || !available;\n"
|
||||
" element('ssh-start').disabled = busy || sshPending || !available || sshSnapshot.running;\n"
|
||||
" element('ssh-stop').disabled = busy || sshPending || !available || !sshSnapshot.running;\n"
|
||||
" element('ssh-disconnect').disabled = busy || sshPending || !available || !sshSelection || sshSelection.stale;\n"
|
||||
" element('ssh-rotate').disabled = busy || sshPending || !available || !sshSnapshot.rotatable;\n"
|
||||
"}\n"
|
||||
"function clearSsh() {\n"
|
||||
" if (sshAbort) sshAbort.abort(); sshAbort = null; sshSnapshot = null; sshSelection = null;\n"
|
||||
" element('ssh-target').value = ''; element('ssh-values').textContent = '';\n"
|
||||
" for (let i = 0; i < 2; ++i) { const o = element('ssh-option-' + i); o.textContent = ''; o.hidden = o.disabled = true; }\n"
|
||||
" element('ssh-detail').textContent = 'Select Refresh to read current SSH state.';\n"
|
||||
" if (sshPending) element('ssh-operation-detail').textContent = 'Outcome pending or unknown. Check Result on return; navigation does not cancel admitted work.';\n"
|
||||
" sshButtons();\n"
|
||||
"}\n"
|
||||
"function sshValid(v) {\n"
|
||||
" return v && Object.keys(v).length === 8 && brokerUint(v.identity_generation) && v.algorithm === 'ecdsa-sha2-nistp256' && typeof v.rotatable === 'boolean' && typeof v.fingerprint === 'string' && (v.identity_generation ? /^SHA256:[A-Za-z0-9+/]{43}$/.test(v.fingerprint) : v.fingerprint === '') && (!v.rotatable || (v.identity_generation > 0 && v.identity_generation < 4294967295 && !v.transitioning && v.generation < 4294967295)) && brokerUint(v.generation) && v.generation > 0 && typeof v.running === 'boolean' && typeof v.transitioning === 'boolean' &&\n"
|
||||
" Array.isArray(v.sessions) && v.sessions.length <= 2 && new Set(v.sessions.map(s => s.id)).size === v.sessions.length && v.sessions.every(s =>\n"
|
||||
" s && Object.keys(s).length === 5 && brokerUint(s.id) && s.id > 0 && Number.isInteger(s.state) && s.state >= 1 && s.state <= 3 &&\n"
|
||||
" Number.isInteger(s.route) && s.route >= 0 && s.route <= 2 && typeof s.closing === 'boolean' && typeof s.name_hex === 'string' && /^(?:[0-9a-f]{2}){0,16}$/.test(s.name_hex));\n"
|
||||
"}\n"
|
||||
"async function sshRequest(action, snapshotRead = false) {\n"
|
||||
" if (settingsDomain !== 'ssh' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || sshAbort || (action && sshPending)) return;\n"
|
||||
" let body; const detail = element(snapshotRead ? 'ssh-detail' : 'ssh-operation-detail');\n"
|
||||
" if (action) {\n"
|
||||
" if (!sshActions.includes(action) || !sshSnapshot || sshSnapshot.transitioning || sshSnapshot.generation === 4294967295) return;\n"
|
||||
" const target = Number(element('ssh-target').value), row = sshSnapshot.sessions.find(s => s.id === target);\n"
|
||||
" if (action === 'disconnect' && (!sshSelection || sshSelection.stale || !row || row.closing || row.state === 3 || sshSelection.target !== target || sshSelection.generation !== sshSnapshot.generation)) return;\n"
|
||||
" if ((action === 'start' && sshSnapshot.running) || (action === 'stop' && !sshSnapshot.running)) return;\n"
|
||||
" const value = {action, generation: action === 'disconnect' ? sshSelection.generation : sshSnapshot.generation, target: action === 'disconnect' ? target : 0};\n"
|
||||
" if (action === 'rotate') { if (!sshSnapshot.rotatable) return; value.identity_generation = sshSnapshot.identity_generation; }\n"
|
||||
" const scope = action === 'disconnect' ? 'Disconnect only SSH session ' + sshLabel(row) + '?' : action === 'stop' ? 'Stop SSH and close ALL SSH sessions, including sessions admitted before execution?' : 'Start the SSH listener on TCP port 22?';\n"
|
||||
" const rotation = 'Rotate and persist SSH host identity ' + sshSnapshot.algorithm + ', ' + sshSnapshot.fingerprint + ', identity generation ' + sshSnapshot.identity_generation + ', service generation ' + sshSnapshot.generation + '? ALL SSH sessions close, including those admitted before execution. Stopped SSH remains stopped. known_hosts trust changes: verify the NEW fingerprint over trusted UART0 using ssh host-key info BEFORE accepting it. Do not blindly remove known_hosts warnings. Stop/persistence/restart can fail after partial effects: SSH may be disconnected even if persistence fails, or the new identity may be saved while restart fails. HTTPS stays accessible; accounts, Wi-Fi, USB UART1 and UART0 are unchanged. Already executing SSH commands may finish. No automatic retry.';\n"
|
||||
" if (!window.confirm(action === 'rotate' ? rotation : scope + ' Settings and host identity are unchanged. HTTPS, Wi-Fi, USB and UART0 remain available. Already executing SSH commands may finish.')) return;\n"
|
||||
" body = JSON.stringify(value);\n"
|
||||
" }\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; sshAbort = controller; sshButtons();\n"
|
||||
" const current = () => sshAbort === controller && settingsDomain === 'ssh' && selected === 'settings';\n"
|
||||
" const deadline = window.setTimeout(() => { if (!current()) return; controller.abort(); sshAbort = null; sshSnapshot = null; if (sshSelection) sshSelection.stale = true; detail.textContent = 'Request timed out. Outcome may be unknown. Check Result and Refresh; no automatic retry.'; sshButtons(); }, 15000);\n"
|
||||
" controller.signal.addEventListener('abort', () => window.clearTimeout(deadline), {once:true});\n"
|
||||
" detail.textContent = snapshotRead ? 'Reading SSH; previous snapshot is stale until refreshed.' : 'Reading/submitting once. No automatic mutation retry.';\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
||||
" if (action) { sshPending = true; sshAwaitingAck = true; if (sshSelection) sshSelection.stale = true; }\n"
|
||||
" const {status, payload: v} = await api(snapshotRead ? '/api/settings/ssh' : '/api/settings/ssh-operation', generation, {method: action ? 'POST' : 'GET', body, signal: controller.signal, limit: snapshotRead ? 768 : 96, current});\n"
|
||||
" if (snapshotRead) {\n"
|
||||
" if (status !== 200 || !sshValid(v)) throw new Error('Invalid SSH snapshot');\n"
|
||||
" sshSnapshot = v;\n"
|
||||
" if (sshSelection && (sshSelection.generation !== v.generation || v.transitioning || !v.sessions.some(s => s.id === sshSelection.target && s.route === sshSelection.route && s.name_hex === sshSelection.name_hex && !s.closing && s.state !== 3))) sshSelection.stale = true;\n"
|
||||
" const list = element('ssh-values'); list.textContent = '';\n"
|
||||
" for (const [label, value] of [['Stored host algorithm',v.algorithm],['Stored fingerprint',v.fingerprint || 'Unavailable'],['Identity generation',v.identity_generation],['Service generation',v.generation]]) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = label; dd.textContent = String(value); list.appendChild(dt); list.appendChild(dd); }\n"
|
||||
" for (let i = 0; i < 2; ++i) {\n"
|
||||
" const s = v.sessions[i], o = element('ssh-option-' + i); o.hidden = !s; o.disabled = !s || s.closing || s.state === 3; o.value = s ? String(s.id) : ''; o.textContent = s ? sshLabel(s) : '';\n"
|
||||
" if (s) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = sshLabel(s); dd.textContent = s.closing ? 'Close requested' : ['Free','Handshake','Active','Closing'][s.state]; list.appendChild(dt); list.appendChild(dd); }\n"
|
||||
" }\n"
|
||||
" element('ssh-target').value = sshSelection && !sshSelection.stale ? String(sshSelection.target) : '';\n"
|
||||
" detail.textContent = (v.running ? 'SSH running' : 'SSH stopped') + (v.transitioning ? ' — transitioning or cleanup pending; use the admin shell for recovery.' : '') + '. ' + v.sessions.length + ' sessions. Refresh never renews selected target identity. ' + (sshSelection?.stale ? 'Selection stale; explicitly reselect.' : '') + (v.generation === 4294967295 ? ' Control generation exhausted; use the admin shell.' : '');\n"
|
||||
" } else {\n"
|
||||
" if (status !== (action ? 202 : 200) || !v || Object.keys(v).length !== 3 || !brokerUint(v.id) || !['none',...sshActions].includes(v.action) || !['idle','pending','ok','failed','cancelled','conflict'].includes(v.state) || ((v.id === 0) !== (v.state === 'idle')) || ((v.id === 0) !== (v.action === 'none')) || (action && (!v.id || v.action !== action || v.state !== 'pending')) || (!action && sshId && sshId === v.id && sshAction !== v.action)) throw new Error('Invalid SSH result');\n"
|
||||
" const warning = !action && sshAwaitingAck ? 'Acknowledgement was lost; latest result may belong to another tab or earlier operation. ' : !action && sshId && sshId !== v.id ? 'Previous result replaced/unavailable; previous outcome unknown. ' : '';\n"
|
||||
" sshId = v.id; sshAction = v.action; sshPending = v.state === 'pending'; sshAwaitingAck = false; sshSnapshot = null; if (sshSelection) sshSelection.stale = true;\n"
|
||||
" const messages = {idle:'No retained result; inspect SSH before retrying.',pending:'Queued or executing. Select Check Operation Result; do not resubmit.',ok:'Accepted/completed at execution time. Disconnect acknowledgement is an owner close request, not proof of peer closure. Refresh to inspect.',failed:'Failed or lifecycle timed out. Admitted work may still finish. Refresh and inspect before retrying.',conflict:'Service changed, unavailable, or target absent/closing. No action admitted; Refresh and reselect.',cancelled:'Rejected before execution: login/currentness or queue deadline expired.'};\n"
|
||||
" detail.textContent = warning + v.action + ': ' + messages[v.state] + (v.action === 'rotate' ? ' SSH may have disconnected even if persistence failed; a new key may be persisted even if restart failed. HTTPS stays accessible. Check stored identity and service state; verify the NEW fingerprint with trusted UART0 ssh host-key info before accepting changed known_hosts trust. No automatic retry.' : '');\n"
|
||||
" element('ssh-detail').textContent = 'Snapshot stale. Select Refresh to inspect current service state.';\n"
|
||||
" }\n"
|
||||
" } catch (error) {\n"
|
||||
" if (live(generation) && current()) { sshSnapshot = null; if (sshSelection) sshSelection.stale = true; detail.textContent = (error.status ? error.message : 'SSH request unavailable or outcome unknown.') + ' Check Result and Refresh before any explicit retry. No automatic retry.'; }\n"
|
||||
" } finally { window.clearTimeout(deadline); if (current()) { sshAbort = null; sshButtons(); } }\n"
|
||||
"}\n"
|
||||
"function refreshSsh() { return sshRequest(null, true); }\n"
|
||||
"element('settings-ssh').addEventListener('click', () => selectSettingsDomain('ssh'));\n"
|
||||
"element('ssh-refresh').addEventListener('click', refreshSsh);\n"
|
||||
"element('ssh-result').addEventListener('click', () => sshRequest(null));\n"
|
||||
"for (const action of sshActions) element('ssh-' + action).addEventListener('click', () => sshRequest(action));\n"
|
||||
"element('ssh-target').addEventListener('change', () => {\n"
|
||||
" const s = sshSnapshot?.sessions.find(s => s.id === Number(element('ssh-target').value));\n"
|
||||
" sshSelection = s && !s.closing && s.state !== 3 && !sshAbort && !sshPending && !sshSnapshot.transitioning ? {target:s.id, generation:sshSnapshot.generation, route:s.route, name_hex:s.name_hex, stale:false} : null; sshButtons();\n"
|
||||
"});\n"
|
||||
"let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n"
|
||||
"let keysAbort = null, accountKeys = [], keysIdentity = '';\n"
|
||||
"function keyIdentity() { const t = accounts[Number(element('account-target').value)]; return t ? JSON.stringify([t.username,t.user_id,t.auth_generation]) : ''; }\n"
|
||||
@@ -712,14 +1110,17 @@ static const char s_app_js[] =
|
||||
" if (accountPending) element('account-operation-detail').textContent = accountWarning + 'Outcome pending or unknown. Check Result on return; navigation does not cancel backend work.';\n"
|
||||
" accountButtons();\n"
|
||||
"}\n"
|
||||
"function selectSettingsDomain(domain) {\n"
|
||||
"function selectSettingsDomain(domain, read = true) {\n"
|
||||
" if (!sessionVerified || accountRole !== 'admin' || selected !== 'settings' || domain === settingsDomain) return;\n"
|
||||
" clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\n"
|
||||
" element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts'; element('network-settings').hidden = domain !== 'network';\n"
|
||||
" element('display-settings').hidden = domain !== 'display'; element('settings-display').setAttribute('aria-pressed', String(domain === 'display'));\n"
|
||||
" element('ssh-settings').hidden = domain !== 'ssh'; element('settings-ssh').setAttribute('aria-pressed', String(domain === 'ssh'));\n"
|
||||
" element('lifecycle-settings').hidden = domain !== 'lifecycle'; element('settings-lifecycle').setAttribute('aria-pressed', String(domain === 'lifecycle'));\n"
|
||||
" element('broker-settings').hidden = domain !== 'broker'; element('settings-broker').setAttribute('aria-pressed', String(domain === 'broker'));\n"
|
||||
" element('settings-network').setAttribute('aria-pressed', String(domain === 'network'));\n"
|
||||
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
|
||||
" refreshSettings();\n"
|
||||
" if (read) refreshSettings();\n"
|
||||
"}\n"
|
||||
"async function refreshAccounts() {\n"
|
||||
" if (!accountsLive() || accountsAbort) return;\n"
|
||||
@@ -881,6 +1282,7 @@ static const char s_app_js[] =
|
||||
" net('password').disabled = blocked || net('password-mode').value !== 'replace';\n"
|
||||
" net('refresh').disabled = net('result').disabled = busy;\n"
|
||||
" net('password-clear').hidden = net('password-clear').disabled = net('target').value === 'ap';\n"
|
||||
" if (quick) { clearNetworkSecret(); net('password-mode').disabled = true; }\n"
|
||||
"}\n"
|
||||
"function clearNetwork() {\n"
|
||||
" clearNetworkSecret();\n"
|
||||
@@ -1018,7 +1420,7 @@ static const char s_app_js[] =
|
||||
" if (!networkActions.includes(action)) throw new Error('Invalid action.');\n"
|
||||
" request = networkRequest(action); body = networkWire(request);\n"
|
||||
" const disruptive = ['start','stop','reconnect','next-profile','wifi-load'].includes(action) || action === 'wifi-patch' && Object.keys(request).some(k => !['action','generation','enabled_at_boot'].includes(k)) || action === 'profile-patch' && (networkTarget().enabled || net('enabled').checked);\n"
|
||||
" if ((disruptive || ['mdns-load','mdns-defaults'].includes(action)) && !window.confirm(action + ': ' + (disruptive ? 'May disconnect HTTPS/SSH and BOTH browser terminals before acknowledgement. Accepted is NOT online. Recover through STA/AP, UART0 or native USB. ' : 'Replace working mDNS with loaded/default settings and request reannouncement. ') + 'RAM changes require explicit Save. Continue?')) { body = undefined; return; }\n"
|
||||
" if ((disruptive || ['mdns-load','mdns-defaults'].includes(action)) && !window.confirm(action + ': ' + (disruptive ? 'May disconnect HTTPS/SSH and BOTH browser terminals before acknowledgement. Accepted is NOT online. Restore STA/AP reachability or use UART0 administration; SSH may also be unreachable. Native USB remains UART1 serial, not network administration. Unsaved browser drafts may be lost. ' : 'Replace working mDNS with loaded/default settings and request reannouncement. ') + 'RAM changes require explicit Save. Continue?')) { body = undefined; return; }\n"
|
||||
" } catch (error) { body = undefined; detail.textContent = 'Not submitted. ' + error.message; return; }\n"
|
||||
" finally { clearNetworkSecret(); if (request) request.password = ''; request = null; }\n"
|
||||
" } else clearNetworkSecret();\n"
|
||||
@@ -1136,7 +1538,7 @@ static const char s_app_js[] =
|
||||
" adminDetail.textContent = detail;\n"
|
||||
" updateControls();\n"
|
||||
"}\n"
|
||||
"function selectTerminal(mode) {\n"
|
||||
"function selectTerminal(mode, read = true, focus = true) {\n"
|
||||
" if (mode === selected) return;\n"
|
||||
" if (unloading || navigating || loggingOut || !sessionVerified || (mode !== 'serial' && accountRole !== 'admin')) return;\n"
|
||||
" clearSettings();\n"
|
||||
@@ -1161,7 +1563,7 @@ static const char s_app_js[] =
|
||||
" settingsHost.hidden = mode !== 'settings';\n"
|
||||
" element('terminal-title').textContent = mode === 'serial' ? 'Live serial stream' : mode === 'admin' ? 'Administration shell' : 'Settings';\n"
|
||||
" lastFitWidth = lastFitHeight = 0; updateControls(); scheduleFit();\n"
|
||||
" if (mode === 'settings') refreshSettings(); else (mode === 'serial' ? terminal : adminTerminal).focus();\n"
|
||||
" if (mode === 'settings') { if (read) refreshSettings(); } else if (focus) (mode === 'serial' ? terminal : adminTerminal).focus();\n"
|
||||
"}\n"
|
||||
"async function openAdmin() {\n"
|
||||
" if (accountRole !== 'admin' || selected !== 'admin' || unloading || navigating || loggingOut || suspended || !csrf || adminSocket || adminAbort) return;\n"
|
||||
@@ -1334,8 +1736,9 @@ static const char s_app_js[] =
|
||||
" accountRole = payload.role;\n"
|
||||
" element('terminal-selector').hidden = accountRole !== 'admin';\n"
|
||||
" if (accountRole !== 'admin') { closeAdmin(); selectTerminal('serial'); }\n"
|
||||
" terminalHost.hidden = selected !== 'serial'; adminHost.hidden = selected !== 'admin';\n"
|
||||
" terminalHost.hidden = (quick?.previous || selected) !== 'serial'; adminHost.hidden = (quick?.previous || selected) !== 'admin';\n"
|
||||
" settingsHost.hidden = selected !== 'settings' || loggingOut || suspended;\n"
|
||||
" for (const domain of quickDomains) element('quick-' + domain).disabled = accountRole !== 'admin' || loggingOut || suspended;\n"
|
||||
" scheduleFit();\n"
|
||||
" const deadline = Date.now() + payload.expires_in * 1000;\n"
|
||||
" sessionDeadline = sessionDeadline ? Math.min(sessionDeadline, deadline) : deadline;\n"
|
||||
|
||||
@@ -31,6 +31,12 @@ typedef int *SemaphoreHandle_t;
|
||||
#define pdMS_TO_TICKS(x) (x)
|
||||
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
|
||||
static unsigned lock_depth, ticks, runs, actions;
|
||||
static uint32_t lifecycle_settings_executed;
|
||||
static void web_lifecycle_settings_execute(uint32_t id) { assert(!lock_depth); lifecycle_settings_executed = id; }
|
||||
static uint32_t ssh_settings_executed;
|
||||
static void web_ssh_settings_execute(uint32_t id) { assert(!lock_depth); ssh_settings_executed = id; }
|
||||
static uint32_t broker_settings_executed;
|
||||
static void web_broker_settings_execute(uint32_t id) { assert(!lock_depth); broker_settings_executed = id; }
|
||||
static uint32_t serial_settings_executed, account_settings_executed, network_settings_executed, display_settings_executed;
|
||||
static void web_display_settings_execute(uint32_t id) { assert(!lock_depth); display_settings_executed = id; }
|
||||
static void web_network_settings_execute(uint32_t id) { assert(!lock_depth); network_settings_executed = id; }
|
||||
|
||||
@@ -379,5 +379,50 @@ int main(void)
|
||||
assert(serial_settings_executed == 41 && network_settings_executed == 42 && account_settings_executed == 43 && display_settings_executed == 44);
|
||||
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
|
||||
puts("PASS: typed Display IDs share all four unchanged queue slots; full/not-ready admission fails, routing never invokes command runner");
|
||||
assert(admin_ssh_console_submit_broker_settings(0) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = false;
|
||||
assert(admin_ssh_console_submit_broker_settings(1) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = true; queue_full = true;
|
||||
assert(admin_ssh_console_submit_broker_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
|
||||
queue_full = false;
|
||||
assert(admin_ssh_console_submit_broker_settings(51) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_display_settings(52) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_network_settings(53) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_account_settings(54) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_broker_settings(55) == ESP_ERR_TIMEOUT);
|
||||
pump(worker_task);
|
||||
assert(broker_settings_executed == 51 && display_settings_executed == 52 && network_settings_executed == 53 && account_settings_executed == 54);
|
||||
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
|
||||
puts("PASS: Broker typed IDs, not-ready/full queue, routing and unchanged dispatcher capacity");
|
||||
assert(admin_ssh_console_submit_ssh_settings(0) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = false;
|
||||
assert(admin_ssh_console_submit_ssh_settings(1) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = true; queue_full = true;
|
||||
assert(admin_ssh_console_submit_ssh_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
|
||||
queue_full = false;
|
||||
assert(admin_ssh_console_submit_ssh_settings(61) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_broker_settings(62) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_network_settings(63) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_account_settings(64) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_ssh_settings(65) == ESP_ERR_TIMEOUT);
|
||||
pump(worker_task);
|
||||
assert(ssh_settings_executed == 61 && broker_settings_executed == 62 && network_settings_executed == 63 && account_settings_executed == 64);
|
||||
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
|
||||
puts("PASS: SSH typed ID dispatcher routing, zero-wait/full/not-ready, no command runner or capacity growth");
|
||||
assert(admin_ssh_console_submit_lifecycle_settings(0) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = false;
|
||||
assert(admin_ssh_console_submit_lifecycle_settings(1) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = true; queue_full = true;
|
||||
assert(admin_ssh_console_submit_lifecycle_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
|
||||
queue_full = false;
|
||||
assert(admin_ssh_console_submit_lifecycle_settings(71) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_ssh_settings(72) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_network_settings(73) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_account_settings(74) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_lifecycle_settings(75) == ESP_ERR_TIMEOUT);
|
||||
pump(worker_task);
|
||||
assert(lifecycle_settings_executed == 71 && ssh_settings_executed == 72 && network_settings_executed == 73 && account_settings_executed == 74);
|
||||
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
|
||||
puts("PASS: Lifecycle typed ID routing, zero-wait/full/not-ready; unchanged runner and four-entry queue");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -150,6 +150,61 @@ int main(void) {
|
||||
comparison(1); comparison(2);
|
||||
assert(allocations == initial_allocations);
|
||||
puts("PASS no post-init broker allocations; 7 diagnostic groups passed");
|
||||
session_broker_management_snapshot_t management;
|
||||
assert(session_broker_get_management_snapshot(NULL) == ESP_ERR_INVALID_ARG);
|
||||
mutex = 1; assert(session_broker_get_management_snapshot(&management) == ESP_ERR_TIMEOUT); mutex = 0;
|
||||
session_broker_client_id_t usb = connect_type(SESSION_BROKER_CLIENT_USB);
|
||||
session_broker_client_id_t ssh = connect_type(SESSION_BROKER_CLIENT_SSH);
|
||||
session_broker_client_id_t web = connect_type(SESSION_BROKER_CLIENT_WEB);
|
||||
assert(session_broker_request_writer(usb) == ESP_OK);
|
||||
feed(128);
|
||||
assert(session_broker_get_management_snapshot(&management) == ESP_OK);
|
||||
assert(management.count == 3 && management.writer_id == usb && management.clients[0].pending == 128);
|
||||
uint32_t generation = management.generation;
|
||||
before = global();
|
||||
assert(session_broker_assign_writer_current(0, generation) == ESP_ERR_INVALID_ARG);
|
||||
assert(session_broker_assign_writer_current(ssh, 0) == ESP_ERR_INVALID_ARG);
|
||||
assert(session_broker_get_management_snapshot(&management) == ESP_OK && management.generation == generation);
|
||||
assert(global().latest_event_sequence == before.latest_event_sequence && snapshot(usb).output_bytes_pending == 128);
|
||||
assert(session_broker_assign_writer_current(ssh, generation) == ESP_OK);
|
||||
assert(global().writer_id == ssh && !snapshot(usb).is_writer && snapshot(ssh).is_writer);
|
||||
before = global();
|
||||
assert(session_broker_assign_writer_current(web, generation) == ESP_ERR_INVALID_STATE);
|
||||
assert(global().writer_id == ssh && global().latest_event_sequence == before.latest_event_sequence);
|
||||
assert(session_broker_get_management_snapshot(&management) == ESP_OK); generation = management.generation;
|
||||
assert(session_broker_release_writer(ssh) == ESP_OK && session_broker_request_writer(ssh) == ESP_OK);
|
||||
assert(session_broker_assign_writer_current(web, generation) == ESP_ERR_INVALID_STATE);
|
||||
assert(session_broker_get_management_snapshot(&management) == ESP_OK); generation = management.generation;
|
||||
assert(session_broker_disconnect(web) == ESP_OK);
|
||||
session_broker_client_id_t reused = connect_type(SESSION_BROKER_CLIENT_WEB);
|
||||
assert(reused != web && (reused & 7) == (web & 7));
|
||||
before = global();
|
||||
assert(session_broker_assign_writer_current(web, generation) == ESP_ERR_NOT_FOUND);
|
||||
assert(global().writer_id == ssh && global().latest_event_sequence == before.latest_event_sequence);
|
||||
assert(session_broker_clear_counters() == ESP_OK);
|
||||
assert(session_broker_get_management_snapshot(&management) == ESP_OK && management.generation == generation);
|
||||
assert(session_broker_assign_writer_current(reused, generation) == ESP_OK);
|
||||
assert(session_broker_get_management_snapshot(&management) == ESP_OK); generation = management.generation;
|
||||
assert(session_broker_force_release_writer(reused) == ESP_OK);
|
||||
assert(session_broker_assign_writer_current(usb, generation) == ESP_ERR_INVALID_STATE);
|
||||
assert(session_broker_request_writer(usb) == ESP_OK);
|
||||
assert(session_broker_get_management_snapshot(&management) == ESP_OK); generation = management.generation;
|
||||
assert(session_broker_disconnect(usb) == ESP_OK);
|
||||
assert(session_broker_assign_writer_current(ssh, generation) == ESP_ERR_INVALID_STATE);
|
||||
puts("PASS Broker management: atomic nonconsuming snapshot, one writer, USB/SSH/Web interleavings, stale target/reuse/ABA and counter-clear fencing");
|
||||
s_writer_generation = UINT32_MAX - 1;
|
||||
assert(session_broker_request_writer(ssh) == ESP_OK && s_writer_generation == UINT32_MAX);
|
||||
assert(session_broker_assign_writer_current(reused, UINT32_MAX) == ESP_ERR_INVALID_STATE);
|
||||
assert(session_broker_force_writer(reused) == ESP_OK && s_writer_generation == UINT32_MAX);
|
||||
disconnect_all();
|
||||
for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) s_slots[i].generation = SESSION_BROKER_MAX_GENERATION;
|
||||
s_slots[7].generation--;
|
||||
session_broker_client_id_t last = connect_type(SESSION_BROKER_CLIENT_USB);
|
||||
assert(last == UINT32_MAX);
|
||||
assert(session_broker_disconnect(last) == ESP_OK);
|
||||
assert(session_broker_connect(SESSION_BROKER_CLIENT_USB, "exhausted", &last) == ESP_ERR_NO_MEM);
|
||||
assert(!global().connected_clients && !global().writer_id);
|
||||
puts("PASS Broker wrap: saturated confirmation rejects, ordinary recovery remains; all 29-bit client generations retire without reuse");
|
||||
cleanup_allocations();
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exact SSH management/lifecycle/close/slot-selection functions, deterministic RTOS.
|
||||
No wolfSSH, sockets, real scheduling or target execution is claimed.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
source = (ROOT / 'src/ssh_transport.c').read_text()
|
||||
def function(name):
|
||||
match = re.search(r'^(?:static )?[^\n]+\b' + name + r'\([^;]*?\n\{.*?^\}', source, re.M | re.S)
|
||||
assert match, name
|
||||
return match.group() + '\n'
|
||||
header = '\n'.join(line for line in (ROOT / 'src/ssh_transport.h').read_text().splitlines() if not line.startswith(('#include', '#pragma once')))
|
||||
constants = '\n'.join(re.search(r'^#define ' + name + r' .+$', source, re.M).group() for name in ('SSH_TRANSPORT_GENERATION_MAX', 'SSH_TRANSPORT_COMMAND_TIMEOUT_MS', 'SSH_TRANSPORT_MAX_PENDING_HANDSHAKES'))
|
||||
username = re.search(r'^#define USER_DATABASE_USERNAME_CAPACITY .+$', (ROOT / 'src/user_database.h').read_text(), re.M).group()
|
||||
fakes = r'''
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE, ESP_ERR_TIMEOUT, ESP_ERR_NOT_FOUND };
|
||||
typedef uint32_t session_broker_client_id_t;
|
||||
typedef int user_role_t;
|
||||
typedef int user_auth_method_t;
|
||||
'''
|
||||
state = r'''
|
||||
typedef struct { ssh_transport_session_state_t state; uint32_t generation, session_id; } ssh_slot_t;
|
||||
static ssh_slot_t s_slots[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static ssh_transport_session_snapshot_t s_session_snapshots[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static uint32_t s_external_close_id[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static unsigned depth, mutex_storage, notifications, ticks;
|
||||
static unsigned *s_command_mutex = &mutex_storage;
|
||||
static bool s_initialized, s_running, s_transitioning, s_cleanup_pending, s_desired_running;
|
||||
static uint32_t s_management_generation, s_requested_sequence, s_completed_sequence;
|
||||
static int s_command_result;
|
||||
static bool owner_stalled, owner_fail;
|
||||
static uint32_t identity_generation = 3, identity_token;
|
||||
static unsigned replacements;
|
||||
static bool persist_fail;
|
||||
static esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token) {
|
||||
(void)reset; assert(mutex_storage && !depth); *token = 0;
|
||||
if (identity_token || (generation && generation != identity_generation)) return ESP_ERR_INVALID_STATE;
|
||||
*token = identity_token = 1; return ESP_OK;
|
||||
}
|
||||
static esp_err_t ssh_security_replace_reserved(uint32_t token) {
|
||||
assert(token == identity_token && mutex_storage && !depth && !s_running && !s_cleanup_pending);
|
||||
++replacements; if (persist_fail) return ESP_FAIL; ++identity_generation; return ESP_OK;
|
||||
}
|
||||
static void ssh_security_release_identity(uint32_t token) { if (token) { assert(identity_token == token && mutex_storage && !depth); identity_token = 0; } }
|
||||
static int s_lock;
|
||||
#define taskENTER_CRITICAL(p) do { (void)(p); assert(depth++ == 0); } while(0)
|
||||
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(--depth == 0); } while(0)
|
||||
#define pdTRUE 1
|
||||
#define portMAX_DELAY 99999U
|
||||
#define pdMS_TO_TICKS(n) (n)
|
||||
typedef unsigned TickType_t;
|
||||
static int xSemaphoreTake(unsigned *m, unsigned wait) { assert(!depth); (void)wait; if (*m) return 0; *m = 1; return 1; }
|
||||
static void xSemaphoreGive(unsigned *m) { assert(!depth && *m); *m = 0; }
|
||||
static void notify_task(void) { assert(!depth); ++notifications; }
|
||||
static unsigned xTaskGetTickCount(void) { return ticks; }
|
||||
static void vTaskDelay(unsigned n) {
|
||||
assert(!depth && mutex_storage); ticks += n;
|
||||
if (!owner_stalled) {
|
||||
s_completed_sequence = s_requested_sequence; s_command_result = owner_fail ? ESP_FAIL : ESP_OK;
|
||||
s_running = owner_fail ? false : s_desired_running; s_transitioning = false; s_cleanup_pending = owner_fail;
|
||||
}
|
||||
}
|
||||
'''
|
||||
tests = r'''
|
||||
static void reset(void) {
|
||||
memset(s_slots, 0, sizeof(s_slots)); memset(s_session_snapshots, 0, sizeof(s_session_snapshots));
|
||||
memset(s_external_close_id, 0, sizeof(s_external_close_id));
|
||||
s_initialized = s_running = true; s_transitioning = s_cleanup_pending = owner_stalled = owner_fail = false;
|
||||
mutex_storage = notifications = ticks = 0; s_management_generation = 7; s_requested_sequence = s_completed_sequence = 0;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
s_slots[i] = (ssh_slot_t){SSH_TRANSPORT_SESSION_ACTIVE, 2, make_session_id(i, 2)};
|
||||
s_session_snapshots[i] = (ssh_transport_session_snapshot_t){.active=true, .session_id=s_slots[i].session_id, .generation=2, .state=SSH_TRANSPORT_SESSION_ACTIVE};
|
||||
}
|
||||
}
|
||||
int main(void) {
|
||||
reset(); bool committed = true;
|
||||
assert(ssh_transport_replace_identity(6,3,false,&committed)==ESP_ERR_INVALID_STATE && !committed && !notifications && !replacements);
|
||||
assert(ssh_transport_replace_identity(7,2,false,&committed)==ESP_ERR_INVALID_STATE && !notifications && !replacements);
|
||||
identity_token=1; assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_ERR_INVALID_STATE && !notifications); identity_token=0;
|
||||
mutex_storage=1; assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_ERR_TIMEOUT && !notifications); mutex_storage=0;
|
||||
owner_fail=true; assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_FAIL && !committed && !replacements && notifications==1 && s_cleanup_pending && !identity_token);
|
||||
assert(ssh_transport_start()==ESP_ERR_INVALID_STATE && notifications==1);
|
||||
reset(); owner_stalled=true;
|
||||
assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_ERR_TIMEOUT && !committed && !replacements && notifications==1 && s_transitioning && !identity_token);
|
||||
reset(); persist_fail=true;
|
||||
assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_FAIL && !committed && replacements==1 && notifications==2 && s_running && identity_generation==3);
|
||||
reset(); persist_fail=false;
|
||||
assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_OK && committed && s_running && identity_generation==4 && notifications==2);
|
||||
assert(ssh_transport_replace_identity(7,3,false,&committed)==ESP_ERR_INVALID_STATE && !committed && notifications==2);
|
||||
reset(); s_running=false;
|
||||
assert(ssh_transport_replace_identity(7,4,false,&committed)==ESP_OK && committed && !s_running && !notifications);
|
||||
assert(ssh_transport_replace_host_key(true)==ESP_OK && s_running && notifications==1);
|
||||
puts("PASS SSH combined generation/service-owner admission, competing reservation, failed-stop no mutation/start, timeout retention, persistence recovery, replay fence and stopped/reset semantics");
|
||||
reset(); ssh_transport_management_snapshot_t v;
|
||||
assert(ssh_transport_get_management_snapshot(NULL) == ESP_ERR_INVALID_ARG);
|
||||
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.generation == 7 && v.running && !v.transitioning);
|
||||
assert(!notifications && !depth && !mutex_storage);
|
||||
s_initialized = false; assert(ssh_transport_get_management_snapshot(&v) == ESP_ERR_INVALID_STATE); s_initialized = true;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_OK);
|
||||
assert(s_external_close_id[0] == 9 && !s_external_close_id[1] && notifications == 1);
|
||||
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.sessions[0].close_requested && !v.sessions[1].close_requested);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_NOT_FOUND);
|
||||
assert(consume_external_close(&s_slots[0], 0) && !s_external_close_id[0] && s_session_snapshots[0].close_requested);
|
||||
assert(!consume_external_close(&s_slots[1], 1));
|
||||
puts("PASS SSH atomic published snapshot/target close, duplicate rejection and unrelated-slot isolation");
|
||||
reset();
|
||||
s_external_close_id[0] = 5; assert(!consume_external_close(&s_slots[0], 0));
|
||||
s_slots[0].state = SSH_TRANSPORT_SESSION_FREE; assert(!consume_external_close(&s_slots[0], 0) && !s_external_close_id[0]);
|
||||
s_session_snapshots[0].active = false;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_NOT_FOUND);
|
||||
s_session_snapshots[0].active = true; s_session_snapshots[0].session_id = 13;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_NOT_FOUND);
|
||||
assert(!s_external_close_id[0] && !notifications);
|
||||
size_t index; s_slots[0].generation = SSH_TRANSPORT_GENERATION_MAX; s_slots[1].state = SSH_TRANSPORT_SESSION_FREE;
|
||||
assert(find_free_slot(&index) == &s_slots[1] && index == 1);
|
||||
s_slots[1].generation = SSH_TRANSPORT_GENERATION_MAX; assert(find_free_slot(&index) == NULL);
|
||||
assert(make_session_id(1, SSH_TRANSPORT_GENERATION_MAX) != 0);
|
||||
puts("PASS SSH disconnect/reuse/late owner close safety and generation exhaustion retires slots");
|
||||
reset();
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 6) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 7) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 9, 7) == ESP_ERR_INVALID_ARG);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 0, 7) == ESP_ERR_INVALID_ARG);
|
||||
assert(ssh_transport_manage_current(99, 0, 7) == ESP_ERR_INVALID_ARG);
|
||||
mutex_storage = 1; assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_ERR_TIMEOUT); mutex_storage = 0;
|
||||
s_transitioning = true; assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_INVALID_STATE); s_transitioning = false;
|
||||
assert(!notifications);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_OK && !s_running && s_management_generation == 8);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 7) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 8) == ESP_OK && s_running && s_management_generation == 9);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_stop() == ESP_OK && s_management_generation == 10);
|
||||
assert(ssh_transport_start() == ESP_OK && s_management_generation == 11);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 9) == ESP_ERR_INVALID_STATE);
|
||||
puts("PASS SSH conditional start/stop, command mutex, CLI transitions and stop/start ABA fencing");
|
||||
reset(); owner_stalled = true;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_ERR_TIMEOUT && s_transitioning && !mutex_storage && s_management_generation == 8);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 8) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.transitioning);
|
||||
reset(); owner_fail = true;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_FAIL && s_cleanup_pending);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 8) == ESP_ERR_INVALID_STATE);
|
||||
owner_fail = false; assert(ssh_transport_stop() == ESP_OK && !s_cleanup_pending);
|
||||
s_management_generation = UINT32_MAX - 1;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, UINT32_MAX - 1) == ESP_OK && s_management_generation == UINT32_MAX);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, UINT32_MAX) == ESP_ERR_INVALID_ARG);
|
||||
assert(ssh_transport_stop() == ESP_OK && s_management_generation == UINT32_MAX);
|
||||
puts("PASS SSH admitted timeout is not cancellation; failed cleanup and saturated versions preserve CLI recovery");
|
||||
}
|
||||
'''
|
||||
names = ('next_generation', 'make_session_id', 'consume_external_close', 'find_free_slot', 'request_running_locked', 'request_running', 'ssh_transport_start', 'ssh_transport_stop', 'ssh_transport_replace_identity', 'ssh_transport_replace_host_key', 'ssh_transport_get_management_snapshot', 'ssh_transport_manage_current')
|
||||
# Guard the accept path, which is not executed with the socket double here.
|
||||
assert 'uint32_t generation = slot->generation + 1U;' in function('accept_connections')
|
||||
assert 'next_generation(slot->generation)' not in source
|
||||
with tempfile.TemporaryDirectory(prefix='ssh-management-') as directory:
|
||||
tmp = Path(directory)
|
||||
unit = fakes + username + '\n' + header + '\n' + constants + '\n' + state + '\n'.join(function(n) for n in names) + tests
|
||||
(tmp / 'test.c').write_text(unit)
|
||||
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', str(tmp / 'test.c'), '-o', str(tmp / 'test')], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / 'test')], check=True, timeout=10)
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exact production stop/start/process-slot functions with retained-resource doubles."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
root=Path(__file__).resolve().parents[2]
|
||||
source=(root/'src/ssh_transport.c').read_text()
|
||||
def function(name):
|
||||
m=re.search(r'^static [^\n]+\b'+name+r'\([^;]*?\n\{.*?^\}',source,re.M|re.S)
|
||||
assert m,name
|
||||
return m.group()+'\n'
|
||||
fakes=r'''
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#define SSH_TRANSPORT_MAX_SESSIONS 2
|
||||
#define SSH_TRANSPORT_SESSION_FREE 0
|
||||
#define SSH_TRANSPORT_SESSION_CLOSING 3
|
||||
#define SSH_TRANSPORT_SESSION_HANDSHAKE 1
|
||||
#define SSH_TRANSPORT_SESSION_ACTIVE 2
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL 1
|
||||
#define ESP_ERR_TIMEOUT 2
|
||||
#define ESP_ERR_INVALID_STATE 3
|
||||
#define pdMS_TO_TICKS(n) (n)
|
||||
typedef int esp_err_t;
|
||||
typedef struct { int state; bool close_requested; } ssh_slot_t;
|
||||
static ssh_slot_t s_slots[2];
|
||||
static void *s_context;
|
||||
static int s_listen_fd=-1, s_lock;
|
||||
static unsigned depth, frees, creates;
|
||||
static bool s_running, s_cleanup_pending, cleanup_fail, listener_fail;
|
||||
#define taskENTER_CRITICAL(p) do { (void)(p); assert(!depth++); } while(0)
|
||||
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(!--depth); } while(0)
|
||||
static void wolfSSH_CTX_free(void *p) { assert(!depth && p==s_context); for(unsigned i=0;i<2;++i) assert(!s_slots[i].state); ++frees; }
|
||||
static void close_socket(int *fd) { assert(!depth); *fd=-1; }
|
||||
static void vTaskDelay(unsigned n) { (void)n; assert(!depth); }
|
||||
static void request_slot_close(ssh_slot_t *s,bool revoked) { (void)revoked; if(s->state)s->close_requested=true; }
|
||||
static bool cleanup_slot(ssh_slot_t *s) { if(cleanup_fail)return false; s->state=0;return true; }
|
||||
static void publish_slot(ssh_slot_t *s,size_t i) { (void)s;(void)i; }
|
||||
static bool consume_external_close(ssh_slot_t *s,size_t i) { (void)s;(void)i;return false; }
|
||||
static void process_handshake(ssh_slot_t *s,size_t i) { (void)s;(void)i;assert(0); }
|
||||
static void process_active(ssh_slot_t *s,size_t i) { (void)s;(void)i;assert(0); }
|
||||
static esp_err_t create_context(void) { assert(!s_context && !depth); ++creates;s_context=(void *)1;return ESP_OK; }
|
||||
static esp_err_t create_listener(void) { if(listener_fail)return ESP_FAIL;s_listen_fd=22;return ESP_OK; }
|
||||
'''
|
||||
tests=r'''
|
||||
int main(void) {
|
||||
assert(start_runtime()==ESP_OK && creates==1);
|
||||
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1 && !frees);
|
||||
s_slots[0].state=2;cleanup_fail=true;
|
||||
assert(stop_runtime()==ESP_ERR_TIMEOUT && s_context && !frees && s_listen_fd==-1);
|
||||
s_cleanup_pending=true;s_running=false;
|
||||
assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==1);
|
||||
process_slots();assert(s_context && s_cleanup_pending && !frees);
|
||||
cleanup_fail=false;process_slots();assert(!s_context && !s_cleanup_pending && frees==1);
|
||||
process_slots();assert(frees==1);
|
||||
assert(start_runtime()==ESP_OK && creates==2);assert(stop_runtime()==ESP_OK && frees==2);
|
||||
listener_fail=true;assert(start_runtime()==ESP_FAIL && !s_context && frees==3 && s_listen_fd==-1);
|
||||
s_slots[1].state=2;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);s_slots[1].state=0;
|
||||
s_listen_fd=22;assert(start_runtime()==ESP_ERR_INVALID_STATE && creates==3);
|
||||
puts("PASS SSH actual runtime stop failure retains context, rejects orphan overwrite, owner retires only after all slots free, failed listener frees context exactly once");
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix='ssh-runtime-') as directory:
|
||||
out=Path(directory)
|
||||
(out/'test.c').write_text(fakes+''.join(function(n) for n in ('start_runtime','stop_runtime','process_slots'))+tests)
|
||||
subprocess.run(['cc','-std=c11','-Wall','-Wextra','-Werror',str(out/'test.c'),'-o',str(out/'test')],check=True,timeout=30)
|
||||
subprocess.run([str(out/'test')],check=True,timeout=10)
|
||||
@@ -0,0 +1,152 @@
|
||||
/* Full canonical storage/crypto; no hardware/power-loss/scheduler claims. */
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/random.h>
|
||||
#include "../../src/ssh_security.c"
|
||||
static unsigned depth, handles, wipes;
|
||||
static bool locked, busy, rng_fail, command_locked;
|
||||
static SemaphoreHandle_t s_command_mutex = (void *)2;
|
||||
static int s_lock;
|
||||
static bool s_initialized=true, s_running=true, s_transitioning, s_cleanup_pending, s_desired_running;
|
||||
static uint32_t s_management_generation=7, s_requested_sequence, s_completed_sequence;
|
||||
static esp_err_t s_command_result;
|
||||
static unsigned ticks, notifications;
|
||||
static bool stop_fail, start_fail;
|
||||
typedef unsigned TickType_t;
|
||||
#define pdMS_TO_TICKS(n) (n)
|
||||
#define SSH_TRANSPORT_COMMAND_TIMEOUT_MS 100
|
||||
static unsigned xTaskGetTickCount(void) { return ticks; }
|
||||
static void notify_task(void) { assert(!depth && command_locked); ++notifications; }
|
||||
static int fault;
|
||||
static void *task = (void *)1;
|
||||
static void (*hook)(void);
|
||||
static ssh_security_blob_t stored, pending, before;
|
||||
static bool present, staged;
|
||||
void enter(void) { assert(!depth++); }
|
||||
void leave(void) { assert(!--depth); }
|
||||
TaskHandle_t xTaskGetCurrentTaskHandle(void) { return task; }
|
||||
void vTaskDelay(unsigned n) {
|
||||
assert(command_locked && !depth && !locked); ticks+=n;
|
||||
s_completed_sequence=s_requested_sequence;
|
||||
s_command_result=(s_desired_running ? start_fail : stop_fail) ? ESP_FAIL : ESP_OK;
|
||||
s_running=s_command_result==ESP_OK && s_desired_running;
|
||||
s_cleanup_pending=s_command_result!=ESP_OK; s_transitioning=false;
|
||||
}
|
||||
SemaphoreHandle_t xSemaphoreCreateMutex(void) { assert(!depth); return (void *)1; }
|
||||
int xSemaphoreTake(SemaphoreHandle_t m, unsigned wait) { if(m==s_command_mutex) { assert(!depth);if(command_locked){assert(!wait);return 0;}command_locked=true;return 1;} assert(m && !depth && !locked); if (busy) { assert(!wait); return 0; } locked = true; return 1; }
|
||||
int xSemaphoreGive(SemaphoreHandle_t m) { if(m==s_command_mutex){assert(!depth && command_locked);command_locked=false;return 1;} assert(m && locked && !depth); locked = false; return 1; }
|
||||
void secure_wipe(void *p, size_t n) { volatile unsigned char *b = p; for (size_t i=0;i<n;++i) b[i]=0; ++wipes; }
|
||||
esp_err_t secure_random_init(void) { assert(!depth); return ESP_OK; }
|
||||
esp_err_t secure_random_fill(void *p, size_t n) {
|
||||
assert(!depth && (!s_identity_token || !locked));
|
||||
if (hook) { void (*f)(void)=hook; hook=NULL; f(); }
|
||||
return !rng_fail && getrandom(p,n,0)==(ssize_t)n ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
int secure_random_mbedtls(void *ctx, unsigned char *p, size_t n) { (void)ctx; return secure_random_fill(p,n)==ESP_OK ? 0 : -1; }
|
||||
esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *h) {
|
||||
assert(!depth && !strcmp(name,SSH_SECURITY_NVS_NAMESPACE));
|
||||
if (fault==1 && mode==NVS_READWRITE) return ESP_FAIL;
|
||||
assert(!handles++); *h=mode; return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_get_blob(nvs_handle_t h,const char *key,void *p,size_t *n) {
|
||||
assert(handles && h==NVS_READONLY && !strcmp(key,"material"));
|
||||
if (!present) return ESP_ERR_NVS_NOT_FOUND;
|
||||
if (p) { assert(*n>=sizeof(stored)); memcpy(p,&stored,sizeof(stored)); }
|
||||
*n=sizeof(stored); return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_set_blob(nvs_handle_t h,const char *key,const void *p,size_t n) {
|
||||
assert(handles && h==NVS_READWRITE && !strcmp(key,"material") && n==312 && !depth);
|
||||
if (s_identity_token) assert(!locked && !memcmp(&s_material,&before,sizeof(before)));
|
||||
if (fault==2) return ESP_FAIL;
|
||||
memcpy(&pending,p,n); staged=true; return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_commit(nvs_handle_t h) {
|
||||
assert(handles && h==NVS_READWRITE && staged && !depth);
|
||||
if (fault==3) return ESP_FAIL;
|
||||
stored=pending; present=true; return ESP_OK;
|
||||
}
|
||||
void nvs_close(nvs_handle_t h) { (void)h; assert(handles--==1); staged=false; secure_wipe(&pending,sizeof(pending)); }
|
||||
#include "owner.inc"
|
||||
static void competitor(void) {
|
||||
assert(!depth && !locked);
|
||||
ssh_security_identity_snapshot_t v;
|
||||
assert(ssh_security_get_identity_snapshot(&v)==ESP_OK && v.busy);
|
||||
assert(v.metadata.generation==before.generation);
|
||||
assert(!memcmp(v.metadata.sha256_fingerprint,before.sha256_fingerprint,32));
|
||||
assert(ssh_security_rotate()==ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_security_reset()==ESP_ERR_INVALID_STATE);
|
||||
task=(void *)2;
|
||||
if(command_locked) {
|
||||
bool committed;
|
||||
assert(ssh_transport_replace_identity(s_management_generation,before.generation,false,&committed)==ESP_ERR_TIMEOUT);
|
||||
assert(ssh_transport_replace_host_key(true)==ESP_ERR_TIMEOUT);
|
||||
|
||||
}
|
||||
assert(ssh_security_replace_reserved(s_identity_token)==ESP_ERR_INVALID_STATE);
|
||||
uint32_t token=s_identity_token; ssh_security_release_identity(token); assert(s_identity_token==token);
|
||||
task=(void *)1;
|
||||
}
|
||||
int main(void) {
|
||||
ssh_security_load_result_t result;
|
||||
assert(ssh_security_init(&result)==ESP_OK && result==SSH_SECURITY_LOAD_GENERATED_MISSING);
|
||||
assert(s_material.generation==1 && validate_blob(&s_material)==ESP_OK && !handles);
|
||||
before=s_material;
|
||||
uint8_t der[256]; size_t size=0;
|
||||
assert(ssh_security_copy_private_key(der,sizeof(der),&size)==ESP_OK && size==before.private_key_length);
|
||||
assert(!memcmp(der,before.private_key_der,size)); secure_wipe(der,sizeof(der));
|
||||
s_material_ready=false; assert(ssh_security_init(&result)==ESP_OK && !memcmp(&s_material,&before,sizeof(before)));
|
||||
puts("PASS SSH real P256 generation/validation, bounded DER copy, exact persisted reload and handle closure");
|
||||
for (fault=1;fault<=3;++fault) {
|
||||
before=s_material; hook=competitor;
|
||||
assert(ssh_security_rotate()!=ESP_OK && !s_identity_token && !handles);
|
||||
assert(!memcmp(&s_material,&before,sizeof(before)) && !memcmp(&stored,&before,sizeof(before)));
|
||||
}
|
||||
fault=0; rng_fail=true; before=s_material;
|
||||
assert(ssh_security_rotate()!=ESP_OK && !s_identity_token && !handles);
|
||||
assert(!memcmp(&s_material,&before,sizeof(before))); rng_fail=false;
|
||||
puts("PASS SSH real crypto RNG/NVS open-set-commit faults, unchanged live/stored bytes, reservation exclusion outside locks and wipes");
|
||||
bool committed;
|
||||
before=s_material;
|
||||
unsigned old_notifications=notifications;
|
||||
assert(ssh_transport_replace_identity(6,1,false,&committed)==ESP_ERR_INVALID_STATE && notifications==old_notifications);
|
||||
assert(ssh_transport_replace_identity(7,2,false,&committed)==ESP_ERR_INVALID_STATE && notifications==old_notifications);
|
||||
stop_fail=true;
|
||||
assert(ssh_transport_replace_identity(7,1,false,&committed)==ESP_FAIL && !committed && notifications==old_notifications+1);
|
||||
assert(!memcmp(&s_material,&before,sizeof(before)) && !memcmp(&stored,&before,sizeof(before)));
|
||||
stop_fail=false;assert(ssh_transport_stop()==ESP_OK);assert(ssh_transport_start()==ESP_OK);
|
||||
for(fault=1;fault<=3;++fault) {
|
||||
before=s_material;hook=competitor;
|
||||
assert(ssh_transport_replace_identity(s_management_generation,1,false,&committed)==ESP_FAIL && !committed && s_running);
|
||||
assert(!memcmp(&s_material,&before,sizeof(before)) && !memcmp(&stored,&before,sizeof(before)) && !handles);
|
||||
}
|
||||
fault=0;start_fail=true;before=s_material;hook=competitor;
|
||||
assert(ssh_transport_replace_identity(s_management_generation,1,false,&committed)==ESP_FAIL && committed && !s_running);
|
||||
assert(s_material.generation==2 && !memcmp(&stored,&s_material,sizeof(stored)));
|
||||
start_fail=false;assert(ssh_transport_stop()==ESP_OK);assert(ssh_transport_start()==ESP_OK);
|
||||
puts("PASS integrated canonical SSH owner + real crypto/NVS: stale admission untouched, failed stop skips crypto/start, persistence failures restore old identity, committed restart failure never rolls back, competing CLI/direct owners excluded");
|
||||
before=s_material; hook=competitor; assert(ssh_security_rotate()==ESP_OK);
|
||||
assert(s_material.generation==3 && memcmp(before.sha256_fingerprint,s_material.sha256_fingerprint,32));
|
||||
assert(validate_blob(&s_material)==ESP_OK && !memcmp(&stored,&s_material,sizeof(stored)));
|
||||
uint32_t token=0, newer=0;
|
||||
assert(ssh_security_reserve_identity(1,false,&token)==ESP_ERR_INVALID_STATE && !token);
|
||||
assert(ssh_security_reserve_identity(3,false,&token)==ESP_OK);
|
||||
ssh_security_release_identity(token);
|
||||
assert(ssh_security_reserve_identity(3,false,&newer)==ESP_OK && newer!=token);
|
||||
ssh_security_release_identity(token); assert(s_identity_token==newer);
|
||||
assert(ssh_security_replace_reserved(token)==ESP_ERR_INVALID_STATE);
|
||||
before=s_material; assert(ssh_security_replace_reserved(newer)==ESP_OK);
|
||||
assert(ssh_security_replace_reserved(newer)==ESP_ERR_INVALID_STATE); ssh_security_release_identity(newer);
|
||||
puts("PASS SSH expected generation, owner-only nonreused token, stale release/replace and one-shot replacement");
|
||||
ssh_security_identity_snapshot_t v;
|
||||
busy=true; assert(ssh_security_get_identity_snapshot(&v)==ESP_ERR_TIMEOUT && !v.metadata.generation);
|
||||
busy=false; s_next_identity_token=UINT32_MAX;
|
||||
assert(ssh_security_get_identity_snapshot(&v)==ESP_OK && v.busy);
|
||||
assert(ssh_security_rotate()==ESP_ERR_INVALID_STATE);
|
||||
s_next_identity_token=0; s_material.generation=UINT32_MAX;
|
||||
assert(ssh_security_reset()==ESP_ERR_INVALID_STATE);
|
||||
s_material_ready=false; stored.schema_version=99;
|
||||
assert(ssh_security_init(NULL)==ESP_ERR_INVALID_VERSION && stored.schema_version==99);
|
||||
before=s_material; assert(ssh_security_reset()==ESP_OK && s_material.generation==1 && validate_blob(&s_material)==ESP_OK);
|
||||
assert(!handles && !locked && !depth && wipes);
|
||||
puts("PASS SSH zero-wait public snapshot, saturation, corrupt-material fail-closed and canonical reset recovery");
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Full production SSH security with real mbedTLS and fault-injected NVS/RTOS."""
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
tree = ast.parse((ROOT / 'tests/web_security/run.py').read_text())
|
||||
headers = ast.literal_eval(next(n.value for n in tree.body if isinstance(n, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'HEADERS' for t in n.targets)))
|
||||
headers['freertos/FreeRTOS.h'] += '''
|
||||
typedef int portMUX_TYPE;
|
||||
#define portMUX_INITIALIZER_UNLOCKED 0
|
||||
void enter(void);
|
||||
void leave(void);
|
||||
#define taskENTER_CRITICAL(p) do { (void)(p); enter(); } while (0)
|
||||
#define taskEXIT_CRITICAL(p) do { (void)(p); leave(); } while (0)
|
||||
'''
|
||||
headers['freertos/task.h'] = '''#pragma once
|
||||
typedef void *TaskHandle_t;
|
||||
TaskHandle_t xTaskGetCurrentTaskHandle(void);
|
||||
void vTaskDelay(unsigned);
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix='ssh-security-') as directory:
|
||||
out = Path(directory)
|
||||
for name, text in headers.items():
|
||||
path = out / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
source = (ROOT/'src/ssh_transport.c').read_text()
|
||||
names = ('next_generation','request_running_locked','request_running','ssh_transport_start','ssh_transport_stop','ssh_transport_replace_identity','ssh_transport_replace_host_key')
|
||||
owner = ''.join(re.search(r'^(?:static )?[^\n]+\b'+name+r'\([^;]*?\n\{.*?^\}',source,re.M|re.S).group()+'\n' for name in names)
|
||||
(out/'owner.inc').write_text(re.search(r'^#define SSH_TRANSPORT_GENERATION_MAX .+$',source,re.M).group()+'\n'+owner)
|
||||
subprocess.run(['cc','-std=c11','-Wall','-Wextra','-Werror','-g','-I'+str(out),'-I'+str(ROOT/'src'),str(ROOT/'tests/ssh_management/security.c'),'-lmbedcrypto','-o',str(out/'test')],check=True,timeout=30)
|
||||
subprocess.run([str(out/'test')],check=True,timeout=30)
|
||||
@@ -17,6 +17,13 @@ static esp_err_t web_server_start(void) {
|
||||
}
|
||||
static void esp_restart(void) { OUTSIDE(); assert(!httpd_owner); ++reboots; }
|
||||
esp_err_t web_server_stop(void) { OUTSIDE(); assert(!httpd_owner); ++web_stops; return web_stop_result; }
|
||||
static esp_err_t web_server_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
|
||||
assert(!service && !identity && !reset); *committed = false;
|
||||
esp_err_t error = web_security_rotate_certificate();
|
||||
if (error != ESP_OK) return error;
|
||||
*committed = true; error = web_server_stop();
|
||||
return error == ESP_OK ? web_server_start() : error;
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/* Compiled after real web_server lifecycle. HTTP/auth/scheduler dependencies are
|
||||
* doubles; ACK/operation functions below are extracted verbatim from production. */
|
||||
typedef uint64_t web_session_id_t;
|
||||
typedef struct { unsigned role; } user_principal_t;
|
||||
#define USER_ROLE_ADMIN 1
|
||||
#define portMUX_INITIALIZER_UNLOCKED 0
|
||||
typedef unsigned portMUX_TYPE;
|
||||
#define taskENTER_CRITICAL(lock) do { assert(!*(lock)); ++*(lock); } while (0)
|
||||
#define taskEXIT_CRITICAL(lock) do { assert(*(lock) == 1); --*(lock); } while (0)
|
||||
static int64_t pipeline_now;
|
||||
static bool pipeline_current = true, pipeline_queue_fail;
|
||||
static unsigned pipeline_reboots, pipeline_submits;
|
||||
static uint32_t pipeline_id;
|
||||
static void (*validation_hook)(void);
|
||||
static int64_t esp_timer_get_time(void) { return pipeline_now; }
|
||||
static esp_err_t web_session_store_check_principal(web_session_id_t session, const user_principal_t *principal, bool *current) {
|
||||
assert(!locked && session == 1 && principal->role == USER_ROLE_ADMIN);
|
||||
if (validation_hook) { void (*hook)(void) = validation_hook; validation_hook = NULL; hook(); }
|
||||
*current = pipeline_current && auth_live; return ESP_OK;
|
||||
}
|
||||
static esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id) {
|
||||
assert(!locked && id); ++pipeline_submits;
|
||||
if (pipeline_queue_fail) return ESP_ERR_TIMEOUT;
|
||||
pipeline_id = id; return ESP_OK;
|
||||
}
|
||||
static void esp_restart(void) {
|
||||
assert(!locked && s_transitioning && s_server == SERVER);
|
||||
++pipeline_reboots;
|
||||
}
|
||||
/* PRODUCTION_PIPELINE */
|
||||
static void pipeline_reset(void) {
|
||||
memset(&s_operation, 0, sizeof(s_operation)); s_ack_id = 0; s_ack_server = NULL;
|
||||
pipeline_now = 0; pipeline_current = true; pipeline_queue_fail = false;
|
||||
pipeline_reboots = pipeline_submits = 0; pipeline_id = 0; validation_hook = NULL;
|
||||
identity_generation = 11; identity_token = 0; identity_replacements = 0; identity_error = ESP_OK; identity_hook = NULL;
|
||||
reset(); start();
|
||||
}
|
||||
static uint32_t pipeline_admit(unsigned action) {
|
||||
assert(s_operation.state != PENDING && s_operation.state != EXECUTING && !s_ack_id);
|
||||
s_operation = (lifecycle_operation_t){.id=++s_next_id, .generation=s_generation, .identity_generation=identity_generation,
|
||||
.session=1, .principal={USER_ROLE_ADMIN}, .ack_deadline=pipeline_now+2000000,
|
||||
.deadline=pipeline_now+30000000, .action=action, .state=PENDING};
|
||||
s_ack_id = s_operation.id; s_ack_server = SERVER;
|
||||
return s_operation.id;
|
||||
}
|
||||
static void pipeline_callback(uint32_t id) {
|
||||
unsigned starts = ssl_starts, stops = ssl_stops, reboots = pipeline_reboots;
|
||||
ack_handoff((void *)(uintptr_t)id);
|
||||
assert(ssl_starts == starts && ssl_stops == stops && pipeline_reboots == reboots);
|
||||
}
|
||||
static void validation_aba(void) {
|
||||
assert(web_server_stop() == ESP_OK); fresh_registration(); start();
|
||||
}
|
||||
static void identity_interleave(void) {
|
||||
assert(!locked && s_transitioning && identity_token);
|
||||
bool committed = true;
|
||||
assert(web_server_replace_identity(0, 0, false, &committed) == ESP_ERR_INVALID_STATE && !committed);
|
||||
assert(web_server_replace_identity(0, 0, true, &committed) == ESP_ERR_INVALID_STATE && !committed);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && web_server_stop() == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_restart_current(s_generation) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_reboot_current(s_generation) == ESP_ERR_INVALID_STATE);
|
||||
}
|
||||
static void pipeline_tests(void) {
|
||||
for (unsigned mode = 0; mode < 8; ++mode) {
|
||||
pipeline_reset(); uint32_t id = pipeline_admit(3);
|
||||
unsigned before_starts = ssl_starts;
|
||||
if (mode == 0) ++s_generation;
|
||||
if (mode == 1) ++identity_generation;
|
||||
if (mode == 2) s_generation = UINT32_MAX;
|
||||
if (mode == 3) identity_generation = UINT32_MAX;
|
||||
if (mode == 4) s_last_error = ESP_FAIL;
|
||||
if (mode == 5) identity_token = 99;
|
||||
if (mode == 6) pipeline_current = false;
|
||||
if (mode == 7) pipeline_now = 2000000;
|
||||
pipeline_callback(id); web_lifecycle_settings_execute(id);
|
||||
assert(!identity_replacements && !ssl_stops && ssl_starts == before_starts);
|
||||
assert(s_operation.state == (mode >= 6 ? CANCELLED : FAILED));
|
||||
}
|
||||
puts("PASS HTTPS rotation original-login/ACK expiry and stale identity/service/saturation/CLI reservation reject before identity mutation");
|
||||
for (unsigned failure = 0; failure < 7; ++failure) {
|
||||
pipeline_reset(); uint32_t id = pipeline_admit(3), generation = s_generation;
|
||||
identity_hook = identity_interleave;
|
||||
if (failure == 1) identity_error = ESP_FAIL;
|
||||
if (failure == 2) idle_detach_error = ESP_ERR_TIMEOUT;
|
||||
if (failure == 3) admin_detach_error = ESP_ERR_TIMEOUT;
|
||||
if (failure == 4) serial_detach_error = ESP_ERR_INVALID_STATE;
|
||||
if (failure == 5) ssl_stop_error = ESP_FAIL;
|
||||
if (failure == 6) ssl_start_error = ESP_FAIL;
|
||||
web_lifecycle_settings_execute(id); assert(!identity_replacements);
|
||||
pipeline_callback(id); assert(!identity_replacements);
|
||||
fresh_registration(); web_lifecycle_settings_execute(id);
|
||||
assert(identity_replacements == 1 && !identity_token && !s_transitioning);
|
||||
assert(s_operation.state == (failure ? FAILED : OK));
|
||||
assert(identity_generation == (failure == 1 ? 11U : 12U));
|
||||
if (failure == 1) assert(!ssl_stops && ssl_starts == 1 && auth_live);
|
||||
if (failure >= 2 && failure <= 5) assert(s_server == SERVER && ssl_starts == 1 && !auth_live);
|
||||
if (failure == 6) assert(!s_server && !auth_live && ssl_starts == 2);
|
||||
if (!failure) assert(auth_live && s_server == SERVER && s_generation == generation + 3);
|
||||
web_lifecycle_settings_execute(id); assert(identity_replacements == 1);
|
||||
}
|
||||
puts("PASS ACK dispatch rotation holds common owner reservation through crypto/commit/stop/start; precommit failure keeps logins, postcommit failure never rolls back");
|
||||
pipeline_reset(); assert(web_server_stop() == ESP_OK);
|
||||
bool committed = false; unsigned starts = ssl_starts;
|
||||
assert(web_server_replace_identity(0, 0, false, &committed) == ESP_OK && committed);
|
||||
assert(!s_server && ssl_starts == starts && identity_generation == 12);
|
||||
fresh_registration();
|
||||
assert(web_server_replace_identity(0, 0, true, &committed) == ESP_OK && committed);
|
||||
assert(s_server == SERVER && ssl_starts == starts + 1 && identity_generation == 13);
|
||||
puts("PASS canonical stopped rotation remains stopped; CLI TLS reset starts stopped HTTPS without account/config reset");
|
||||
for (unsigned action = 0; action < 3; ++action) {
|
||||
pipeline_reset(); uint32_t id = pipeline_admit(action), generation = s_generation;
|
||||
web_lifecycle_settings_execute(id); assert(!ssl_stops && !pipeline_reboots);
|
||||
pipeline_callback(id); assert(pipeline_id == id && s_operation.queued && !s_ack_id);
|
||||
pipeline_callback(id); assert(pipeline_submits == 1);
|
||||
if (action == 1) fresh_registration();
|
||||
web_lifecycle_settings_execute(id);
|
||||
assert(s_operation.state == (action == 2 ? FAILED : OK));
|
||||
assert(s_generation == generation + (action == 1 ? 2 : 1));
|
||||
if (action == 0) assert(!s_server && !auth_live && !ssl_live);
|
||||
if (action == 1) assert(auth_live && ssl_live && !s_transitioning && ssl_starts == 2);
|
||||
if (action == 2) assert(pipeline_reboots == 1 && s_transitioning && !ssl_stops);
|
||||
web_lifecycle_settings_execute(id); pipeline_callback(id);
|
||||
assert(pipeline_submits == 1 && pipeline_reboots == (action == 2 ? 1U : 0U));
|
||||
}
|
||||
puts("PASS real ACK-ID-dispatch-to-canonical stop/reserved restart/reboot; no lifecycle on callback or duplicate IDs");
|
||||
|
||||
for (unsigned failure = 0; failure < 4; ++failure) {
|
||||
pipeline_reset(); uint32_t id = pipeline_admit(1);
|
||||
if (failure == 0) idle_detach_error = ESP_ERR_TIMEOUT;
|
||||
if (failure == 1) admin_detach_error = ESP_ERR_TIMEOUT;
|
||||
if (failure == 2) serial_detach_error = ESP_ERR_INVALID_STATE;
|
||||
if (failure == 3) ssl_stop_error = ESP_FAIL;
|
||||
pipeline_callback(id); web_lifecycle_settings_execute(id);
|
||||
assert(s_operation.state == FAILED && s_server == SERVER && !auth_live && ssl_starts == 1);
|
||||
assert(!s_transitioning && !idle_stoppeds && !admin_stoppeds);
|
||||
web_lifecycle_settings_execute(id); assert(ssl_starts == 1);
|
||||
idle_detach_error = admin_detach_error = serial_detach_error = ssl_stop_error = ESP_OK;
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS real ACK dispatcher lifecycle failure retains owners, skips restart and never mislabels admitted invalid-state as cancellation");
|
||||
|
||||
pipeline_reset(); uint32_t old = pipeline_admit(0); pipeline_now = 2000000;
|
||||
expire_locked(pipeline_now); assert(s_operation.state == CANCELLED && s_ack_id == old);
|
||||
ssl_stop_error = ESP_FAIL; assert(web_server_stop() == ESP_FAIL && s_ack_id == old);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK && !s_ack_id);
|
||||
fresh_registration(); start(); uint32_t next = pipeline_admit(1);
|
||||
pipeline_callback(old); assert(s_ack_id == next && !pipeline_submits);
|
||||
pipeline_callback(next); fresh_registration(); web_lifecycle_settings_execute(next);
|
||||
assert(s_operation.state == OK && ssl_starts == 3);
|
||||
puts("PASS lost/delayed ACK reservation survives failed stop, retires only after successful destruction; same-handle callback ABA inert");
|
||||
|
||||
for (unsigned mode = 0; mode < 5; ++mode) {
|
||||
pipeline_reset(); uint32_t id = pipeline_admit(2);
|
||||
if (mode == 0) pipeline_now = 2000000;
|
||||
if (mode == 1) pipeline_queue_fail = true;
|
||||
pipeline_callback(id);
|
||||
if (mode == 2) pipeline_current = false;
|
||||
if (mode == 3) pipeline_now = 30000000;
|
||||
if (mode == 4) validation_hook = validation_aba;
|
||||
web_lifecycle_settings_execute(id);
|
||||
assert(!pipeline_reboots && s_operation.state == (mode == 4 ? FAILED : CANCELLED));
|
||||
assert(ssl_starts == (mode == 4 ? 2U : 1U));
|
||||
}
|
||||
puts("PASS actual reboot denied on ACK/dequeue expiry, queue failure, revoked login and canonical ABA during validation");
|
||||
|
||||
pipeline_reset(); uint32_t generation = s_generation;
|
||||
assert(web_server_reboot_current(0) == ESP_ERR_INVALID_ARG);
|
||||
mutex_busy = true; assert(web_server_reboot_current(generation) == ESP_ERR_TIMEOUT); mutex_busy = false;
|
||||
assert(web_server_reboot_current(generation + 1) == ESP_ERR_INVALID_STATE);
|
||||
s_transitioning = true; assert(web_server_reboot_current(generation) == ESP_ERR_INVALID_STATE); s_transitioning = false;
|
||||
s_last_error = ESP_FAIL; assert(web_server_reboot_current(generation) == ESP_ERR_INVALID_STATE); s_last_error = ESP_OK;
|
||||
s_generation = UINT32_MAX; assert(web_server_reboot_current(UINT32_MAX) == ESP_ERR_INVALID_STATE);
|
||||
s_generation = generation; assert(!pipeline_reboots);
|
||||
assert(web_server_reboot_current(generation) == ESP_FAIL && pipeline_reboots == 1);
|
||||
assert(web_server_reboot_current(generation + 1) == ESP_ERR_INVALID_STATE && pipeline_reboots == 1);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && web_server_stop() == ESP_ERR_INVALID_STATE);
|
||||
puts("PASS canonical reboot zero-wait/current-generation admission reserves transition before esp_restart and cannot duplicate on unexpected return");
|
||||
|
||||
for (unsigned failure = 1; failure <= 6; ++failure) {
|
||||
reset(); lifecycle_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(lifecycle_calls == failed_route && lifecycle_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
||||
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
|
||||
assert(!method_route("/api/settings/lifecycle-operation", HTTP_GET));
|
||||
assert(!!method_route("/api/settings/lifecycle", HTTP_GET) == (failed_route != 1));
|
||||
other_domains_complete(); network_complete(); display_complete(); broker_complete(); ssh_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
lifecycle_fail_at = 0; fresh_registration(); start(); assert(registered_count == 39);
|
||||
assert(method_route("/api/settings/lifecycle-operation", HTTP_POST)->handler == web_lifecycle_operation_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six lifecycle route allocation positions preserve other domains and restart recovers the complete optional API");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); lifecycle_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 38 && unregister_calls == 1);
|
||||
assert(method_route("/api/settings/lifecycle-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
|
||||
other_domains_complete(); ssh_complete();
|
||||
ssl_stop_error = ESP_FAIL; assert(web_server_stop() == ESP_FAIL && s_server == SERVER);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; lifecycle_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39 && web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS lifecycle failed unregister leaves reads only; failed shutdown preserves ownership before successful restart");
|
||||
}
|
||||
@@ -19,7 +19,7 @@ source = SOURCE.read_text()
|
||||
|
||||
|
||||
def function(name):
|
||||
match = re.search(r'^(?:static )?esp_err_t ' + name + r'\(void\)\n\{.*?^\}',
|
||||
match = re.search(r'^(?:static )?esp_err_t ' + name + r'\([^;{}]*\)\n\{.*?^\}',
|
||||
source, re.M | re.S)
|
||||
if not match:
|
||||
raise RuntimeError('Production function shape changed: ' + name)
|
||||
@@ -37,8 +37,8 @@ def define(path, name):
|
||||
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) != 27:
|
||||
raise RuntimeError('Review URI extraction: expected 25 descriptors and two tables')
|
||||
if len(uri_tables) != 36:
|
||||
raise RuntimeError('Review URI extraction: expected 34 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()
|
||||
@@ -61,7 +61,8 @@ FAKES = r'''
|
||||
#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 };
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_STATE, ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_INVALID_ARG };
|
||||
#define pdTRUE 1
|
||||
typedef void *SemaphoreHandle_t;
|
||||
typedef void *httpd_handle_t;
|
||||
typedef struct { int unused; } httpd_req_t;
|
||||
@@ -83,10 +84,16 @@ typedef struct {
|
||||
} 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
|
||||
#define portMAX_DELAY 99
|
||||
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 void esp_restart(void);
|
||||
void web_lifecycle_settings_stopped(httpd_handle_t server);
|
||||
static bool mutex_fail, auth_live, ssl_live, admin_owned, serial_live, mutex_busy;
|
||||
static void (*unlock_hook)(void);
|
||||
static esp_err_t serial_detach_error;
|
||||
static unsigned ssl_stop_fail_at;
|
||||
static void web_cookie_auth_clear_counters(void) {}
|
||||
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;
|
||||
@@ -97,13 +104,35 @@ static bool unregister_fail;
|
||||
static bool settings_fail;
|
||||
static unsigned settings_calls;
|
||||
static unsigned operation_calls, operation_fail_at;
|
||||
static const httpd_uri_t *registered[32];
|
||||
static const httpd_uri_t *registered[39];
|
||||
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 int xSemaphoreTake(SemaphoreHandle_t m, int wait) {
|
||||
assert(m && !locked);
|
||||
if (mutex_busy) { assert(wait == 0); return 0; }
|
||||
locked = 1; return pdTRUE;
|
||||
}
|
||||
static void xSemaphoreGive(SemaphoreHandle_t m) {
|
||||
assert(m && locked); locked = 0; if (unlock_hook) unlock_hook();
|
||||
}
|
||||
static void secure_wipe(void *p, size_t n) { assert(!locked); memset(p, 0, n); }
|
||||
static uint32_t identity_generation = 11, identity_token;
|
||||
static unsigned identity_replacements;
|
||||
static esp_err_t identity_error;
|
||||
static void (*identity_hook)(void);
|
||||
static esp_err_t web_security_reserve_identity(uint32_t expected, bool reset, uint32_t *token) {
|
||||
assert(!locked); (void)reset; *token = 0;
|
||||
if (identity_token || identity_generation == UINT32_MAX || (expected && expected != identity_generation)) return ESP_ERR_INVALID_STATE;
|
||||
*token = identity_token = 1; return ESP_OK;
|
||||
}
|
||||
static esp_err_t web_security_replace_reserved(uint32_t token) {
|
||||
assert(!locked && token && token == identity_token); ++identity_replacements;
|
||||
if (identity_hook) identity_hook();
|
||||
if (identity_error == ESP_OK) ++identity_generation;
|
||||
return identity_error;
|
||||
}
|
||||
static void web_security_release_identity(uint32_t token) { assert(!locked); if (token == identity_token) identity_token = 0; }
|
||||
#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(traced_ticket_handler)
|
||||
HANDLER(traced_websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
|
||||
@@ -154,6 +183,65 @@ static esp_err_t display_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
registered[registered_count++] = uri;
|
||||
return ESP_OK;
|
||||
}
|
||||
HANDLER(web_lifecycle_settings_handler) HANDLER(web_lifecycle_operation_handler)
|
||||
static unsigned lifecycle_calls, lifecycle_allocations, lifecycle_fail_at;
|
||||
static esp_err_t lifecycle_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked);
|
||||
assert(!uri->is_websocket && !uri->handle_ws_control_frames && !uri->user_ctx);
|
||||
++lifecycle_calls;
|
||||
if (lifecycle_calls == 1) {
|
||||
assert(!strcmp(uri->uri, "/api/settings/lifecycle") && uri->method == HTTP_GET);
|
||||
assert(uri->handler == web_lifecycle_settings_handler);
|
||||
} else {
|
||||
assert(!strcmp(uri->uri, "/api/settings/lifecycle-operation"));
|
||||
assert(uri->method == (lifecycle_calls == 2 ? HTTP_GET : HTTP_POST));
|
||||
assert(uri->handler == web_lifecycle_operation_handler && lifecycle_calls <= 3);
|
||||
}
|
||||
for (unsigned allocation = 0; allocation < 2; ++allocation)
|
||||
if (++lifecycle_allocations == lifecycle_fail_at) return ESP_ERR_NO_MEM;
|
||||
registered[registered_count++] = uri;
|
||||
return ESP_OK;
|
||||
}
|
||||
HANDLER(web_ssh_settings_handler) HANDLER(web_ssh_operation_handler)
|
||||
static unsigned ssh_calls, ssh_allocations, ssh_fail_at;
|
||||
static esp_err_t ssh_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked);
|
||||
assert(!uri->is_websocket && !uri->handle_ws_control_frames && !uri->user_ctx);
|
||||
++ssh_calls;
|
||||
if (ssh_calls == 1) {
|
||||
assert(!strcmp(uri->uri, "/api/settings/ssh") && uri->method == HTTP_GET);
|
||||
assert(uri->handler == web_ssh_settings_handler);
|
||||
} else {
|
||||
assert(!strcmp(uri->uri, "/api/settings/ssh-operation"));
|
||||
assert(uri->method == (ssh_calls == 2 ? HTTP_GET : HTTP_POST));
|
||||
assert(uri->handler == web_ssh_operation_handler && ssh_calls <= 3);
|
||||
}
|
||||
/* Model the adapter's staged descriptor/name allocations, before publication. */
|
||||
for (unsigned allocation = 0; allocation < 2; ++allocation)
|
||||
if (++ssh_allocations == ssh_fail_at) return ESP_ERR_NO_MEM;
|
||||
registered[registered_count++] = uri;
|
||||
return ESP_OK;
|
||||
}
|
||||
HANDLER(web_broker_settings_handler) HANDLER(web_broker_operation_handler)
|
||||
static unsigned broker_calls, broker_allocations, broker_fail_at;
|
||||
static esp_err_t broker_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked);
|
||||
assert(!uri->is_websocket && !uri->handle_ws_control_frames && !uri->user_ctx);
|
||||
++broker_calls;
|
||||
if (broker_calls == 1) {
|
||||
assert(!strcmp(uri->uri, "/api/settings/broker") && uri->method == HTTP_GET);
|
||||
assert(uri->handler == web_broker_settings_handler);
|
||||
} else {
|
||||
assert(!strcmp(uri->uri, "/api/settings/broker-operation"));
|
||||
assert(uri->method == (broker_calls == 2 ? HTTP_GET : HTTP_POST));
|
||||
assert(uri->handler == web_broker_operation_handler && broker_calls <= 3);
|
||||
}
|
||||
/* Model the adapter's staged descriptor/name allocations, before publication. */
|
||||
for (unsigned allocation = 0; allocation < 2; ++allocation)
|
||||
if (++broker_allocations == broker_fail_at) return ESP_ERR_NO_MEM;
|
||||
registered[registered_count++] = uri;
|
||||
return ESP_OK;
|
||||
}
|
||||
static unsigned network_calls, network_allocations, network_fail_at;
|
||||
static esp_err_t network_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked);
|
||||
@@ -189,7 +277,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
|
||||
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 == 30 && config->port_secure == 443);
|
||||
assert(config->httpd.max_uri_handlers == 39 && 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->user_cb == tls_session_callback);
|
||||
@@ -217,7 +305,7 @@ static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t
|
||||
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; }
|
||||
if (error == ESP_OK) { assert(registered_count < 39); registered[registered_count++] = uri; }
|
||||
return error;
|
||||
}
|
||||
static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
@@ -227,6 +315,9 @@ static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
}
|
||||
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(uri->method == HTTP_GET);
|
||||
if (uri->handler == web_lifecycle_settings_handler || uri->handler == web_lifecycle_operation_handler) return lifecycle_register(s, uri);
|
||||
if (uri->handler == web_ssh_settings_handler || uri->handler == web_ssh_operation_handler) return ssh_register(s, uri);
|
||||
if (uri->handler == web_broker_settings_handler || uri->handler == web_broker_operation_handler) return broker_register(s, uri);
|
||||
if (uri->handler == web_display_settings_handler || uri->handler == web_display_operation_handler) return display_register(s, uri);
|
||||
if (uri->handler == web_network_snapshot_handler || uri->handler == web_network_operation_handler)
|
||||
return network_register(s, uri);
|
||||
@@ -235,6 +326,9 @@ static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_u
|
||||
return httpd_register_uri_handler(s, uri);
|
||||
}
|
||||
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
if (uri->handler == web_lifecycle_settings_handler || uri->handler == web_lifecycle_operation_handler) return lifecycle_register(s, uri);
|
||||
if (uri->handler == web_ssh_settings_handler || uri->handler == web_ssh_operation_handler) return ssh_register(s, uri);
|
||||
if (uri->handler == web_broker_settings_handler || uri->handler == web_broker_operation_handler) return broker_register(s, uri);
|
||||
if (uri->handler == web_display_operation_handler) return display_register(s, uri);
|
||||
if (uri->handler == web_network_operation_handler) return network_register(s, uri);
|
||||
if (uri->handler == web_account_keys_handler) {
|
||||
@@ -262,7 +356,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
|
||||
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) ||
|
||||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation")) && method == HTTP_GET));
|
||||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation") || !strcmp(uri, "/api/settings/broker-operation") || !strcmp(uri, "/api/settings/ssh-operation") || !strcmp(uri, "/api/settings/lifecycle-operation")) && method == HTTP_GET));
|
||||
++unregister_calls;
|
||||
for (unsigned i = 0; i < registered_count; ++i) {
|
||||
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
|
||||
@@ -297,10 +391,13 @@ static esp_err_t web_admin_transport_detach(httpd_handle_t s) {
|
||||
}
|
||||
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;
|
||||
event('S'); ++serial_detaches;
|
||||
if (serial_detach_error != ESP_OK && serial_detach_error != ESP_ERR_TIMEOUT) return serial_detach_error;
|
||||
serial_live = false; return serial_detach_error;
|
||||
}
|
||||
static esp_err_t httpd_ssl_stop(httpd_handle_t s) {
|
||||
assert(s == SERVER && ssl_live && !auth_live && idle_fenced); event('H'); ++ssl_stops;
|
||||
if (ssl_stops == ssl_stop_fail_at) return ESP_FAIL;
|
||||
if (ssl_stop_error == ESP_OK) ssl_live = false;
|
||||
return ssl_stop_error;
|
||||
}
|
||||
@@ -315,6 +412,8 @@ 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_generation = 1U; mutex_busy = false; unlock_hook = NULL; serial_detach_error = ESP_OK;
|
||||
ssl_stop_fail_at = 0;
|
||||
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;
|
||||
@@ -332,6 +431,9 @@ static void reset(void) {
|
||||
operation_calls = operation_fail_at = 0;
|
||||
network_calls = network_allocations = network_fail_at = 0;
|
||||
display_calls = display_allocations = display_fail_at = 0;
|
||||
broker_calls = broker_allocations = broker_fail_at = 0;
|
||||
ssh_calls = ssh_allocations = ssh_fail_at = 0;
|
||||
lifecycle_calls = lifecycle_allocations = lifecycle_fail_at = 0;
|
||||
account_calls = account_fail_at = generation_calls = keys_calls = 0;
|
||||
generation_fail = keys_fail = false;
|
||||
}
|
||||
@@ -339,6 +441,9 @@ static void fresh_registration(void) {
|
||||
registration_calls = registered_count = 0;
|
||||
network_calls = network_allocations = 0;
|
||||
display_calls = display_allocations = 0;
|
||||
broker_calls = broker_allocations = 0;
|
||||
ssh_calls = ssh_allocations = 0;
|
||||
lifecycle_calls = lifecycle_allocations = 0;
|
||||
}
|
||||
static void start(void) {
|
||||
assert(web_server_start() == ESP_OK);
|
||||
@@ -368,6 +473,22 @@ static void display_complete(void) {
|
||||
assert(r && r->handler == web_display_operation_handler);
|
||||
}
|
||||
}
|
||||
static void ssh_complete(void) {
|
||||
assert(ssh_calls == 3 && ssh_allocations == 6);
|
||||
assert(route("/api/settings/ssh")->handler == web_ssh_settings_handler);
|
||||
for (int method = HTTP_GET; method <= HTTP_POST; ++method) {
|
||||
const httpd_uri_t *r = method_route("/api/settings/ssh-operation", method);
|
||||
assert(r && r->handler == web_ssh_operation_handler);
|
||||
}
|
||||
}
|
||||
static void broker_complete(void) {
|
||||
assert(broker_calls == 3 && broker_allocations == 6);
|
||||
assert(route("/api/settings/broker")->handler == web_broker_settings_handler);
|
||||
for (int method = HTTP_GET; method <= HTTP_POST; ++method) {
|
||||
const httpd_uri_t *r = method_route("/api/settings/broker-operation", method);
|
||||
assert(r && r->handler == web_broker_operation_handler);
|
||||
}
|
||||
}
|
||||
static void network_complete(void) {
|
||||
assert(network_calls == 3 && network_allocations == 6);
|
||||
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
||||
@@ -410,7 +531,7 @@ int main(void) {
|
||||
}
|
||||
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
||||
|
||||
reset(); start(); assert(registered_count == 30 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
reset(); start(); assert(registered_count == 39 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
@@ -464,7 +585,7 @@ int main(void) {
|
||||
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 == 28 && unregister_calls == failure - 17);
|
||||
assert(registered_count == 37 && 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 == traced_websocket_handler);
|
||||
@@ -473,13 +594,13 @@ int main(void) {
|
||||
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 == 30 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(registered_count == 39 && 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 == 29);
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 38);
|
||||
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");
|
||||
@@ -491,7 +612,7 @@ int main(void) {
|
||||
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 == 30 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
assert(registered_count == 39 && 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;
|
||||
@@ -513,7 +634,7 @@ int main(void) {
|
||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
|
||||
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
|
||||
reset(); settings_fail = true; start();
|
||||
assert(settings_calls == 1 && registered_count == 29);
|
||||
assert(settings_calls == 1 && registered_count == 38);
|
||||
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
|
||||
settings_fail = false; fresh_registration(); start();
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
@@ -521,7 +642,7 @@ int main(void) {
|
||||
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
|
||||
for (unsigned failure = 1; failure <= 2; ++failure) {
|
||||
reset(); operation_fail_at = failure; start();
|
||||
assert(registered_count == 28 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(registered_count == 37 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
@@ -529,7 +650,7 @@ int main(void) {
|
||||
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
|
||||
for (unsigned failure = 1; failure <= 3; ++failure) {
|
||||
reset(); account_calls = 0; account_fail_at = failure; start();
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 27 : 28));
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 36 : 37));
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
@@ -537,17 +658,17 @@ int main(void) {
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
|
||||
assert(registered_count == 30 && account_calls == 3);
|
||||
assert(registered_count == 39 && account_calls == 3);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
|
||||
assert(registered_count == 29 && auth_live && serial_live && admin_owned);
|
||||
assert(registered_count == 38 && auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
|
||||
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
|
||||
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
|
||||
reset(); generation_fail = true; start();
|
||||
assert(generation_calls == 1 && registered_count == 29 && account_calls == 3);
|
||||
assert(generation_calls == 1 && registered_count == 38 && account_calls == 3);
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
@@ -559,12 +680,12 @@ int main(void) {
|
||||
}
|
||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||
generation_fail = false; fresh_registration(); start();
|
||||
assert(generation_calls == 2 && registered_count == 30);
|
||||
assert(generation_calls == 2 && registered_count == 39);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
|
||||
reset(); keys_fail = true; start();
|
||||
assert(keys_calls == 1 && registered_count == 29 && account_calls == 3 && generation_calls == 1);
|
||||
assert(keys_calls == 1 && registered_count == 38 && account_calls == 3 && generation_calls == 1);
|
||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
@@ -579,7 +700,7 @@ int main(void) {
|
||||
}
|
||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||
keys_fail = false; fresh_registration(); start();
|
||||
assert(keys_calls == 2 && registered_count == 30);
|
||||
assert(keys_calls == 2 && registered_count == 39);
|
||||
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
|
||||
@@ -604,7 +725,7 @@ int main(void) {
|
||||
reset(); network_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(network_calls == failed_route && network_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 27 : 28));
|
||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||
@@ -612,13 +733,13 @@ int main(void) {
|
||||
other_domains_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
network_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 30); network_complete();
|
||||
assert(registered_count == 39); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); network_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 29 && unregister_calls == 1);
|
||||
assert(registered_count == 38 && unregister_calls == 1);
|
||||
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
||||
assert(method_route("/api/settings/network-operation", HTTP_GET)->handler == web_network_operation_handler);
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||
@@ -628,7 +749,7 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 30); network_complete();
|
||||
assert(registered_count == 39); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
@@ -636,7 +757,7 @@ int main(void) {
|
||||
reset(); display_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(display_calls == failed_route && display_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 27 : 28));
|
||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/display-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
||||
@@ -644,13 +765,13 @@ int main(void) {
|
||||
other_domains_complete(); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
display_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 30); display_complete();
|
||||
assert(registered_count == 39); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Display descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); display_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 29 && unregister_calls == 1);
|
||||
assert(registered_count == 38 && unregister_calls == 1);
|
||||
assert(route("/api/settings/display")->handler == web_display_settings_handler);
|
||||
assert(method_route("/api/settings/display-operation", HTTP_GET)->handler == web_display_operation_handler);
|
||||
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
||||
@@ -660,10 +781,74 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; display_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 30); display_complete();
|
||||
assert(registered_count == 39); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Display result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
for (unsigned failure = 1; failure <= 6; ++failure) {
|
||||
reset(); broker_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(broker_calls == failed_route && broker_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/broker-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
|
||||
assert(!!method_route("/api/settings/broker", HTTP_GET) == (failed_route != 1));
|
||||
other_domains_complete(); network_complete(); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
broker_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39); broker_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Broker descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); broker_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 38 && unregister_calls == 1);
|
||||
assert(route("/api/settings/broker")->handler == web_broker_settings_handler);
|
||||
assert(method_route("/api/settings/broker-operation", HTTP_GET)->handler == web_broker_operation_handler);
|
||||
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
|
||||
other_domains_complete(); network_complete(); display_complete();
|
||||
ssl_stop_error = ESP_FAIL;
|
||||
assert(web_server_stop() == ESP_FAIL && s_server == SERVER);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; broker_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39); broker_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Broker result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
for (unsigned failure = 1; failure <= 6; ++failure) {
|
||||
reset(); ssh_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(ssh_calls == failed_route && ssh_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 36 : 37));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/ssh-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
|
||||
assert(!!method_route("/api/settings/ssh", HTTP_GET) == (failed_route != 1));
|
||||
other_domains_complete(); network_complete(); display_complete(); broker_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
ssh_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39); ssh_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six SSH descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); ssh_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 38 && unregister_calls == 1);
|
||||
assert(route("/api/settings/ssh")->handler == web_ssh_settings_handler);
|
||||
assert(method_route("/api/settings/ssh-operation", HTTP_GET)->handler == web_ssh_operation_handler);
|
||||
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
|
||||
other_domains_complete(); network_complete(); display_complete(); broker_complete();
|
||||
ssl_stop_error = ESP_FAIL;
|
||||
assert(web_server_stop() == ESP_FAIL && s_server == SERVER);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; ssh_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 39); ssh_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed SSH result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
for (unsigned failure = 0; failure < 8; ++failure) {
|
||||
reset();
|
||||
if (failure == 0) settings_fail = true;
|
||||
@@ -671,21 +856,184 @@ int main(void) {
|
||||
else if (failure <= 5) account_fail_at = failure - 2;
|
||||
else if (failure == 6) generation_fail = true;
|
||||
else keys_fail = true;
|
||||
start(); network_complete(); display_complete(); assert(web_server_stop() == ESP_OK);
|
||||
start(); network_complete(); display_complete(); broker_complete(); ssh_complete(); assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS every other settings route failure leaves the complete Network domain available");
|
||||
puts("23 lifecycle groups passed (16 required fatal positions, 16 optional routes, Network/Display allocation positions and failed unregister)");
|
||||
management_tests();
|
||||
pipeline_tests();
|
||||
puts("44 lifecycle groups passed (34 prior owner/route, 7 lifecycle integration, 3 HTTPS identity owner groups)");
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
MANAGEMENT_TESTS = r'''
|
||||
static unsigned reserved_gaps;
|
||||
static void observe_restart_gap(void) {
|
||||
if (s_server || !s_transitioning) return;
|
||||
unlock_hook = NULL;
|
||||
++reserved_gaps;
|
||||
web_server_management_snapshot_t snapshot;
|
||||
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK);
|
||||
assert(!snapshot.running && snapshot.transitioning && !snapshot.controllable);
|
||||
unsigned starts = ssl_starts, stops = ssl_stops, auth = auth_starts;
|
||||
uint32_t generation = snapshot.generation;
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_stop_current(generation) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_restart_current(generation) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssl_starts == starts && ssl_stops == stops && auth_starts == auth);
|
||||
assert(s_generation == generation && s_transitioning && !s_server);
|
||||
}
|
||||
static void management_tests(void) {
|
||||
web_server_management_snapshot_t snapshot;
|
||||
reset(); memset(&snapshot, 0xa5, sizeof(snapshot));
|
||||
assert(web_server_get_management_snapshot(NULL) == ESP_ERR_INVALID_ARG);
|
||||
assert(web_server_get_management_snapshot(&snapshot) == ESP_ERR_INVALID_STATE);
|
||||
assert(!snapshot.generation && !snapshot.running && !snapshot.controllable);
|
||||
assert(web_server_stop_current(0) == ESP_ERR_INVALID_ARG);
|
||||
assert(web_server_restart_current(0) == ESP_ERR_INVALID_ARG);
|
||||
assert(web_server_stop_current(1) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_restart_current(1) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_init() == ESP_OK);
|
||||
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK);
|
||||
assert(snapshot.generation == 1 && !snapshot.running && !snapshot.controllable);
|
||||
start(); assert(web_server_get_management_snapshot(&snapshot) == ESP_OK);
|
||||
assert(snapshot.generation == 2 && snapshot.running && !snapshot.transitioning && snapshot.controllable);
|
||||
mutex_busy = true;
|
||||
assert(web_server_get_management_snapshot(&snapshot) == ESP_ERR_TIMEOUT);
|
||||
assert(!snapshot.generation && !snapshot.running && !snapshot.controllable);
|
||||
assert(web_server_stop_current(2) == ESP_ERR_TIMEOUT);
|
||||
assert(web_server_restart_current(2) == ESP_ERR_TIMEOUT);
|
||||
mutex_busy = false;
|
||||
assert(s_generation == 2 && !auth_stops && !ssl_stops);
|
||||
assert(web_server_stop_current(2) == ESP_OK);
|
||||
puts("PASS management snapshot preinit/zero-wait contention and argument rejection without lifecycle effects");
|
||||
|
||||
reset(); start(); uint32_t original = s_generation;
|
||||
assert(web_server_clear_counters() == ESP_OK && s_generation == original);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
fresh_registration(); start(); assert(s_server == SERVER && s_generation == original + 2);
|
||||
clear_events();
|
||||
assert(web_server_stop_current(original) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_restart_current(original) == ESP_ERR_INVALID_STATE);
|
||||
assert(!event_length && auth_live && ssl_live && s_generation == original + 2);
|
||||
assert(web_server_stop_current(s_generation) == ESP_OK);
|
||||
assert(web_server_stop_current(s_generation) == ESP_ERR_INVALID_STATE);
|
||||
puts("PASS canonical stop/start same-handle ABA and counter-clear generation fences");
|
||||
|
||||
reset(); start(); original = s_generation; reserved_gaps = 0;
|
||||
fresh_registration(); clear_events(); unlock_hook = observe_restart_gap;
|
||||
assert(web_server_restart_current(original) == ESP_OK);
|
||||
assert(reserved_gaps == 1 && !unlock_hook && !strcmp(events, "ADSHR"));
|
||||
assert(s_generation == original + 2 && s_server == SERVER && !s_transitioning);
|
||||
assert(auth_live && ssl_live && admin_owned && serial_live && idle_owned);
|
||||
assert(ssl_starts == 2 && ssl_stops == 1 && serial_inits == 1 && registered_count == 39);
|
||||
assert(web_server_stop_current(original) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_stop_current(s_generation) == ESP_OK);
|
||||
puts("PASS conditional restart reserves stop-to-start gap against canonical and conditional callers");
|
||||
|
||||
for (unsigned failure = 0; failure < 4; ++failure) {
|
||||
reset(); start(); original = s_generation;
|
||||
if (failure == 0) idle_detach_error = ESP_ERR_TIMEOUT;
|
||||
if (failure == 1) admin_detach_error = ESP_ERR_TIMEOUT;
|
||||
if (failure == 2) serial_detach_error = ESP_ERR_INVALID_STATE;
|
||||
if (failure == 3) ssl_stop_error = ESP_FAIL;
|
||||
assert(web_server_restart_current(original) != ESP_OK);
|
||||
assert(s_generation == original + 1 && !s_transitioning && s_server == SERVER);
|
||||
assert(ssl_starts == 1 && auth_starts == 1 && !auth_live && !idle_stoppeds && !admin_stoppeds);
|
||||
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK && !snapshot.controllable);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_init() == ESP_OK);
|
||||
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK && !snapshot.controllable);
|
||||
clear_events();
|
||||
assert(web_server_stop_current(s_generation) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_restart_current(s_generation) == ESP_ERR_INVALID_STATE);
|
||||
assert(!event_length && s_generation == original + 1);
|
||||
idle_detach_error = admin_detach_error = serial_detach_error = ssl_stop_error = ESP_OK;
|
||||
assert(web_server_stop() == ESP_OK && !s_server && !s_transitioning);
|
||||
fresh_registration(); start();
|
||||
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK && snapshot.controllable);
|
||||
assert(web_server_stop_current(snapshot.generation) == ESP_OK);
|
||||
}
|
||||
puts("PASS all stop failure stages skip restart, retain ownership and require canonical cleanup even after init");
|
||||
|
||||
for (unsigned failure = 0; failure < 6; ++failure) {
|
||||
reset(); start(); original = s_generation; fresh_registration();
|
||||
if (failure == 0) auth_error = ESP_FAIL;
|
||||
if (failure == 1) idle_prepare_error = ESP_ERR_NO_MEM;
|
||||
if (failure == 2) ssl_start_error = ESP_FAIL;
|
||||
if (failure == 3 || failure == 5) registration_fail_at = 1;
|
||||
if (failure == 4) idle_attach_error = ESP_FAIL;
|
||||
/* Fail only cleanup of the newly started server, not the initial stop. */
|
||||
if (failure == 5) ssl_stop_fail_at = 2;
|
||||
assert(web_server_restart_current(original) != ESP_OK);
|
||||
assert(!s_transitioning && !auth_live);
|
||||
assert((s_server != NULL) == (failure == 5) && ssl_live == (failure == 5));
|
||||
assert(s_generation == original + 2);
|
||||
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK && !snapshot.controllable);
|
||||
auth_error = idle_prepare_error = ssl_start_error = idle_attach_error = ESP_OK;
|
||||
registration_fail_at = 0;
|
||||
if (failure == 5) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_restart_current(s_generation) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
fresh_registration(); start();
|
||||
assert(web_server_stop_current(s_generation) == ESP_OK);
|
||||
}
|
||||
puts("PASS restart start-side failures retain failed-cleanup ownership and permit canonical recovery");
|
||||
|
||||
reset(); start(); s_generation = UINT32_MAX - 1;
|
||||
fresh_registration();
|
||||
assert(web_server_restart_current(UINT32_MAX - 1) == ESP_OK);
|
||||
assert(s_generation == UINT32_MAX && !s_transitioning && ssl_live);
|
||||
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK && !snapshot.controllable);
|
||||
assert(web_server_clear_counters() == ESP_OK && s_generation == UINT32_MAX);
|
||||
clear_events();
|
||||
assert(web_server_stop_current(UINT32_MAX) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_restart_current(UINT32_MAX) == ESP_ERR_INVALID_STATE);
|
||||
assert(!event_length && s_generation == UINT32_MAX);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
fresh_registration(); start(); assert(s_generation == UINT32_MAX);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS saturated generation never wraps; admitted restart completes and canonical recovery stays available");
|
||||
|
||||
reset(); start(); original = s_generation; s_transitioning = true;
|
||||
clear_events();
|
||||
assert(web_server_get_management_snapshot(&snapshot) == ESP_OK && snapshot.transitioning && !snapshot.controllable);
|
||||
assert(web_server_stop_current(original) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_restart_current(original) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE && web_server_start() == ESP_ERR_INVALID_STATE);
|
||||
assert(!event_length && s_generation == original);
|
||||
s_transitioning = false; serial_detach_error = ESP_ERR_TIMEOUT; fresh_registration();
|
||||
assert(web_server_restart_current(original) == ESP_OK);
|
||||
assert(s_generation == original + 2 && ssl_live && !s_transitioning);
|
||||
serial_detach_error = ESP_OK;
|
||||
assert(web_server_stop_current(s_generation) == ESP_OK);
|
||||
puts("PASS transitions reject stale admission; canonical serial detach timeout still permits successful restart");
|
||||
}
|
||||
'''
|
||||
|
||||
unit = FAKES + header + '\n' + constants + state + '\n'.join(uri_tables)
|
||||
callback = re.search(r'^static void tls_session_callback\(.*?^\}', source, re.M | re.S)
|
||||
assert callback
|
||||
unit += callback.group() + '\n'
|
||||
unit += function('ensure_mutex')
|
||||
unit += ''.join(function(name) for name in ('web_server_init', 'web_server_start', 'web_server_stop'))
|
||||
unit += TESTS
|
||||
unit += ''.join(function(name) for name in (
|
||||
'web_server_init', 'start_server', 'web_server_start', 'stop_server',
|
||||
'web_server_stop', 'web_server_stop_current', 'web_server_restart_current',
|
||||
'web_server_reboot_current', 'web_server_get_management_snapshot', 'web_server_clear_counters',
|
||||
'web_server_replace_identity'))
|
||||
lifecycle_source = (ROOT / 'src/web_lifecycle_settings.c').read_text()
|
||||
pipeline_state = lifecycle_source[lifecycle_source.index('typedef struct {'):lifecycle_source.index('static void cancel_locked')]
|
||||
pipeline_state = 'enum { IDLE, PENDING, EXECUTING, OK, FAILED, CANCELLED };\n' + pipeline_state
|
||||
pipeline_functions = ''
|
||||
for name in ('cancel_locked', 'expire_locked', 'ack_handoff', 'web_lifecycle_settings_stopped', 'web_lifecycle_settings_execute'):
|
||||
match = re.search(r'^(?:static )?void ' + name + r'\([^\n]*\)\n\{.*?^\}', lifecycle_source, re.M | re.S)
|
||||
assert match, name
|
||||
pipeline_functions += match.group() + '\n'
|
||||
unit += 'static void management_tests(void);\nstatic void pipeline_tests(void);\n' + TESTS + MANAGEMENT_TESTS
|
||||
unit += (HERE / 'lifecycle_pipeline.c').read_text().replace('/* PRODUCTION_PIPELINE */', pipeline_state + pipeline_functions)
|
||||
with tempfile.TemporaryDirectory(prefix='web-admin-server-lifecycle-') as directory:
|
||||
temporary = Path(directory)
|
||||
c_file = temporary / 'test.c'
|
||||
@@ -695,3 +1043,123 @@ with tempfile.TemporaryDirectory(prefix='web-admin-server-lifecycle-') as direct
|
||||
'-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.')
|
||||
|
||||
# Second executable links the same production server functions to the COMPLETE
|
||||
# security implementation and real mbedTLS. Only NVS/HTTPD/scheduler are doubles.
|
||||
import ast
|
||||
security_runner = ast.parse((ROOT / 'tests/web_security/run.py').read_text())
|
||||
security_headers = next(ast.literal_eval(node.value) for node in security_runner.body
|
||||
if isinstance(node, ast.Assign) and any(isinstance(t, ast.Name) and t.id == 'HEADERS' for t in node.targets))
|
||||
real_unit = unit.replace('int main(void)', 'int orchestration_regressions(void)')
|
||||
real_unit = real_unit.replace('typedef int esp_err_t;\nenum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_STATE, ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_INVALID_ARG };', '#include "esp_err.h"')
|
||||
for name in ('web_security_reserve_identity', 'web_security_replace_reserved', 'web_security_release_identity', 'web_security_copy_tls_material'):
|
||||
pattern = r'^static (?:esp_err_t|void) ' + name + r'\([^{}]*\) \{.*?^\}' if name != 'web_security_release_identity' else r'^static void web_security_release_identity[^\n]*'
|
||||
match = re.search(pattern, real_unit, re.M | re.S)
|
||||
assert match, name
|
||||
signature = match.group().split('{', 1)[0].replace('static ', '', 1).strip() + ';'
|
||||
real_unit = real_unit[:match.start()] + signature + real_unit[match.end():]
|
||||
real_unit = real_unit.replace('config->servercert_len == 1 && config->servercert[0] == 1', 'config->servercert_len > 100 && config->servercert[0] == 0x30')
|
||||
real_unit = real_unit.replace('config->prvtkey_len == 1 && config->prvtkey_pem[0] == 2', 'config->prvtkey_len > 32 && config->prvtkey_pem[0] == 0x30')
|
||||
real_unit = 'static int real_identity_active;\nstatic void real_runtime_check(void);\n' + real_unit
|
||||
real_unit = real_unit.replace("event('A'); ++auth_stops", "real_runtime_check(); event('A'); ++auth_stops")
|
||||
real_unit = real_unit.replace('assert(!locked && auth_live && !ssl_live); ++ssl_starts;', 'real_runtime_check(); assert(!locked && auth_live && !ssl_live); ++ssl_starts;')
|
||||
real_unit += r'''
|
||||
void identity_fixture_prepare(void);
|
||||
void identity_fixture_fault(unsigned);
|
||||
void identity_fixture_check(bool);
|
||||
void identity_fixture_hook(void (*hook)(void));
|
||||
esp_err_t web_security_rotate_certificate(void);
|
||||
esp_err_t web_security_reset_all(void);
|
||||
static unsigned real_crypto_hooks, real_runtime_checks;
|
||||
static void real_runtime_check(void) {
|
||||
if (!real_identity_active) return;
|
||||
assert(!locked && s_transitioning);
|
||||
++real_runtime_checks;
|
||||
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
|
||||
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
|
||||
}
|
||||
static void real_crypto_interleave(void) {
|
||||
assert(!locked && s_transitioning);
|
||||
++real_crypto_hooks;
|
||||
bool committed = true;
|
||||
assert(web_server_replace_identity(0, 0, true, &committed) == ESP_ERR_INVALID_STATE && !committed);
|
||||
assert(web_server_replace_identity(0, 0, false, &committed) == ESP_ERR_INVALID_STATE && !committed);
|
||||
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
|
||||
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
|
||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE && web_server_start() == ESP_ERR_INVALID_STATE);
|
||||
identity_fixture_check(false);
|
||||
}
|
||||
int main(void) {
|
||||
for (unsigned failure = 0; failure < 7; ++failure) {
|
||||
reset(); identity_fixture_prepare(); start();
|
||||
bool committed = false;
|
||||
if (failure >= 1 && failure <= 4) identity_fixture_fault(failure);
|
||||
if (failure == 5) ssl_stop_error = ESP_FAIL;
|
||||
if (failure == 6) ssl_start_error = ESP_FAIL;
|
||||
identity_fixture_hook(real_crypto_interleave);
|
||||
fresh_registration(); real_identity_active = 1;
|
||||
esp_err_t error = web_server_replace_identity(s_generation, 1, false, &committed);
|
||||
real_identity_active = 0;
|
||||
assert(error == ESP_OK || failure);
|
||||
assert((error == ESP_OK) == (failure == 0));
|
||||
bool changed = !failure || failure >= 5;
|
||||
assert(committed == changed && !s_transitioning);
|
||||
identity_fixture_check(changed);
|
||||
if (failure >= 1 && failure <= 4) assert(auth_live && !ssl_stops && ssl_starts == 1);
|
||||
if (failure == 5) assert(s_server == SERVER && !auth_live && ssl_starts == 1);
|
||||
if (failure == 6) assert(!s_server && !auth_live && ssl_starts == 2);
|
||||
}
|
||||
assert(real_crypto_hooks == 7 && real_runtime_checks >= 5);
|
||||
puts("PASS integrated production HTTPS owner + real mbedTLS/NVS open/set/commit/RNG failures: exact identity/storage unchanged before commit; successful commit survives actual owner stop/start failure without rollback");
|
||||
reset(); identity_fixture_prepare(); start();
|
||||
bool committed = true;
|
||||
assert(web_server_replace_identity(s_generation + 1, 1, false, &committed) == ESP_ERR_INVALID_STATE && !committed);
|
||||
assert(web_server_replace_identity(s_generation, 2, false, &committed) == ESP_ERR_INVALID_STATE && !committed);
|
||||
identity_fixture_check(false);
|
||||
assert(!ssl_stops && ssl_starts == 1 && auth_live);
|
||||
puts("PASS integrated stale service and identity reject without real crypto/NVS effects; real crypto interleavings exclude canonical CLI/reset and direct security mutation");
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
security_fixture = (ROOT / 'tests/web_security/security.c').read_text().replace('"../../src/web_security.c"', '"' + str(ROOT / 'src/web_security.c') + '"')
|
||||
security_fixture = security_fixture[:security_fixture.index('static void put16')].replace('legacy_wipes, groups;', 'legacy_wipes;')
|
||||
security_fixture += r'''
|
||||
static bool fail_during_crypto;
|
||||
static void (*fixture_hook)(void);
|
||||
static void fixture_crypto(void) {
|
||||
assert(!locked && s_identity_token);
|
||||
if (fixture_hook) fixture_hook();
|
||||
if (fail_during_crypto) fail_rng = true;
|
||||
}
|
||||
void identity_fixture_prepare(void) {
|
||||
boot(); stored_size = 0; fail_during_crypto = false; fixture_hook = NULL;
|
||||
assert(web_security_init(NULL) == ESP_OK);
|
||||
expected_live = s_material;
|
||||
}
|
||||
void identity_fixture_fault(unsigned value) {
|
||||
fault = value == 1 ? OPEN_RW : value == 2 ? SET : value == 3 ? COMMIT : 0;
|
||||
fail_during_crypto = value == 4;
|
||||
}
|
||||
void identity_fixture_hook(void (*hook)(void)) { fixture_hook = hook; crypto_hook = fixture_crypto; }
|
||||
void identity_fixture_check(bool changed) {
|
||||
assert(!locked && s_material_ready);
|
||||
assert(s_material.generation == expected_live.generation + (changed ? 1U : 0U));
|
||||
assert(stored_size == sizeof(s_material) && !memcmp(stored, &s_material, sizeof(s_material)));
|
||||
if (!changed) assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
|
||||
else assert(memcmp(s_material.certificate_fingerprint, expected_live.certificate_fingerprint, 32));
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix='https-identity-integration-') as directory:
|
||||
temporary = Path(directory)
|
||||
for name, text in security_headers.items():
|
||||
path = temporary / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
(temporary / 'server.c').write_text(real_unit)
|
||||
(temporary / 'identity.c').write_text(security_fixture)
|
||||
executable = temporary / 'integration'
|
||||
subprocess.run([os.environ.get('CC', 'cc'), '-std=c11', '-Wall', '-Wextra', '-Werror', '-g',
|
||||
'-I', str(temporary), '-I', str(ROOT / 'src'),
|
||||
str(temporary / 'server.c'), str(temporary / 'identity.c'),
|
||||
'-lmbedx509', '-lmbedcrypto', '-o', str(executable)], check=True, timeout=30)
|
||||
subprocess.run([str(executable)], check=True, timeout=30)
|
||||
|
||||
@@ -19,6 +19,9 @@ void esp_restart(void) { assert(false); }
|
||||
esp_err_t web_server_stop(void) { assert(false); return ESP_FAIL; }
|
||||
esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
|
||||
esp_err_t web_security_rotate_certificate(void) { assert(false); return ESP_FAIL; }
|
||||
esp_err_t web_server_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
|
||||
(void)service; (void)identity; (void)reset; (void)committed; assert(false); return ESP_FAIL;
|
||||
}
|
||||
void *heap_caps_calloc(size_t n, size_t size, unsigned caps) {
|
||||
assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); return calloc(n, size);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/* Real HTTP policy/store/parser/operation module, deterministic broker owner double.
|
||||
* Canonical lock/lease/ID tests are in session_broker_diagnostics. */
|
||||
#include "../../src/web_broker_settings.c"
|
||||
static bool on_dispatcher, queue_fail;
|
||||
static uint32_t queued_id;
|
||||
static unsigned assignments, snapshots;
|
||||
static esp_err_t broker_error;
|
||||
static session_broker_management_snapshot_t broker_snapshot;
|
||||
esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *out) {
|
||||
assert(!host_lock_depth && !on_dispatcher); ++snapshots;
|
||||
*out = broker_snapshot; return broker_error;
|
||||
}
|
||||
esp_err_t session_broker_assign_writer_current(session_broker_client_id_t target, uint32_t generation) {
|
||||
assert(on_dispatcher && !host_lock_depth && target == 9 && generation == 7);
|
||||
++assignments; return broker_error;
|
||||
}
|
||||
esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id) {
|
||||
assert(id && !on_dispatcher && !host_lock_depth);
|
||||
if (queue_fail) return ESP_FAIL;
|
||||
queued_id = id; return ESP_OK;
|
||||
}
|
||||
static void operation_begin(const issued_t *identity, const char *body) {
|
||||
begin("/api/settings/broker-operation", body ? HTTP_POST : HTTP_GET, body);
|
||||
same_origin(); if (body) add("Content-Type", "application/json");
|
||||
if (identity) {
|
||||
char cookie[100]; snprintf(cookie, sizeof(cookie), "__Host-sak-session=%s", identity->token);
|
||||
add("Cookie", cookie); if (body) add("X-CSRF-Token", identity->view.csrf);
|
||||
}
|
||||
}
|
||||
static void broker_expect(const char *status, bool snapshot) {
|
||||
unsigned before = assignments;
|
||||
esp_err_t e = snapshot ? web_broker_settings_handler(&req) : web_broker_operation_handler(&req);
|
||||
assert(e == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
|
||||
if (strcmp(response_status, status)) fprintf(stderr, "expected %s got %s: %s\n", status, response_status, output);
|
||||
assert(!strcmp(response_status, status) && assignments == before);
|
||||
assert(strlen(output) < (snapshot ? 2048 : 96)); zero(scratch, sizeof(scratch));
|
||||
}
|
||||
static void execute(void) { on_dispatcher = true; web_broker_settings_execute(queued_id); on_dispatcher = false; }
|
||||
static const char *assign_body = "{\"action\":\"assign\",\"generation\":7,\"target\":9}";
|
||||
static void submit(const issued_t *who) {
|
||||
operation_begin(who, assign_body); broker_expect("202 Accepted", false); assert(s_operation.state == PENDING);
|
||||
}
|
||||
static void broker_settings_tests(void) {
|
||||
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
|
||||
receive_fragment = 64;
|
||||
operation_begin(NULL, assign_body); broker_expect("401 Unauthorized", false);
|
||||
operation_begin(&user, assign_body); broker_expect("403 Forbidden", false);
|
||||
operation_begin(&user, NULL); broker_expect("403 Forbidden", false);
|
||||
for (unsigned mode = 0; mode < 8; ++mode) {
|
||||
operation_begin(&admin, assign_body);
|
||||
if (mode == 0) req.content_len = aux.remaining_len = 257;
|
||||
if (mode == 1) req.uri = "/api/settings/broker-operation?x=1";
|
||||
if (mode == 2) req.method = HTTP_GET;
|
||||
if (mode == 3) add("X-CSRF-Token", "duplicate");
|
||||
if (mode == 4) add("Origin", "https://evil.example");
|
||||
if (mode == 5) add("Transfer-Encoding", "chunked");
|
||||
if (mode == 6) add("Content-Type", "text/plain");
|
||||
if (mode == 7) add("Sec-Fetch-Site", "cross-site");
|
||||
(void)web_broker_operation_handler(&req);
|
||||
assert(response_status[0] == '4' && !s_next_id && !assignments);
|
||||
}
|
||||
puts("PASS Broker HTTP admin/cookie/Origin/CSRF, duplicate/query/body/framing bounds");
|
||||
const char *invalid[] = {"{}", "[]", "{\"action\":\"assign\"}",
|
||||
"{\"action\":\"assign\",\"generation\":0,\"target\":9}",
|
||||
"{\"action\":\"assign\",\"generation\":4294967295,\"target\":9}",
|
||||
"{\"action\":\"assign\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"assign\",\"generation\":7,\"target\":4294967296}",
|
||||
"{\"action\":\"assign\",\"generation\":7,\"target\":09}",
|
||||
"{\"action\":\"assign\",\"generation\":7,\"target\":9.0}",
|
||||
"{\"action\":\"assign\",\"generation\":7,\"target\":9e0}",
|
||||
"{\"action\":\"assign\",\"generation\":7,\"target\":-9}",
|
||||
"{\"action\":\"assign\",\"generation\":7,\"generation\":9}",
|
||||
"{\"action\":\"assign\",\"generation\":7,\"target\":9,\"extra\":1}",
|
||||
"{\"action\":\"as\\u0073ign\",\"generation\":7,\"target\":9}"};
|
||||
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
|
||||
operation_begin(&admin, invalid[i]); broker_expect("400 Bad Request", false);
|
||||
}
|
||||
broker_operation_t parsed = {0};
|
||||
for (size_t n = 0; n < strlen(assign_body); ++n) assert(!parse(assign_body, n, &parsed));
|
||||
assert(parse(assign_body, strlen(assign_body), &parsed));
|
||||
assert(!parse(assign_body, strlen(assign_body)+1, &parsed));
|
||||
const char *reordered = " { \"target\":4294967295, \"generation\":4294967294, \"action\":\"assign\" } ";
|
||||
assert(parse(reordered, strlen(reordered), &parsed));
|
||||
receive_fragment = 1; operation_begin(&admin, assign_body); broker_expect("400 Bad Request", false); assert(body_offset == 4); receive_fragment = 64;
|
||||
char full[257]; memset(full, ' ', 256); memcpy(full, assign_body, strlen(assign_body)); full[256] = 0;
|
||||
queue_fail = true; operation_begin(&admin, full); broker_expect("503 Service Unavailable", false); queue_fail = false;
|
||||
assert(body_offset == 256 && s_operation.state == IDLE);
|
||||
puts("PASS Broker strict parser, truncation/order/integer limits, exact 256 bytes and four receives");
|
||||
broker_snapshot.generation = 7; broker_snapshot.writer_id = 8; broker_snapshot.count = 8;
|
||||
for (unsigned i = 0; i < 8; ++i) {
|
||||
session_broker_management_client_t *c = &broker_snapshot.clients[i];
|
||||
c->id = i + 8; c->type = i % 5; memset(c->name, '\"', 23); c->name[23] = 0;
|
||||
c->pending = c->high_water = 4096; c->dropped = UINT64_MAX;
|
||||
}
|
||||
for (unsigned mode = 0; mode < 5; ++mode) {
|
||||
unsigned before = snapshots;
|
||||
operation_begin(mode == 0 ? NULL : mode == 1 ? &user : &admin, NULL); req.uri = "/api/settings/broker";
|
||||
broker_error = mode >= 3 ? ESP_FAIL : ESP_OK;
|
||||
broker_expect(mode == 0 ? "401 Unauthorized" : mode == 1 ? "403 Forbidden" : mode >= 3 ? "503 Service Unavailable" : "200 OK", true);
|
||||
if (mode < 2) assert(snapshots == before);
|
||||
if (mode == 2) assert(strstr(output, "18446744073709551615") && strstr(output, "2222222222222222222222222222222222222222222222"));
|
||||
}
|
||||
broker_error = ESP_OK;
|
||||
puts("PASS Broker bounded eight-row admin snapshot, exact 64-bit strings/safe name encoding and unavailable owner");
|
||||
submit(&admin); uint32_t first = queued_id;
|
||||
operation_begin(&other, assign_body); broker_expect("503 Service Unavailable", false);
|
||||
operation_begin(&other, NULL); broker_expect("200 OK", false); assert(strstr(output, "idle"));
|
||||
execute(); assert(s_operation.state == OK && assignments == 1); zero(&s_operation.principal, sizeof(s_operation.principal));
|
||||
execute(); assert(assignments == 1);
|
||||
esp_err_t failures[] = {ESP_ERR_INVALID_STATE, ESP_ERR_NOT_FOUND, ESP_FAIL};
|
||||
for (unsigned i = 0; i < 3; ++i) { broker_error = failures[i]; submit(&admin); execute(); assert(s_operation.state == (i < 2 ? CONFLICT : FAILED)); }
|
||||
broker_error = ESP_OK;
|
||||
puts("PASS Broker typed dispatcher-only execution, replay suppression, login-isolated results, stale/absent target conflicts");
|
||||
submit(&admin); unsigned before = assignments;
|
||||
web_broker_settings_execute(0); web_broker_settings_execute(first); assert(assignments == before && s_operation.state == PENDING);
|
||||
now += 30000000; execute(); assert(s_operation.state == CANCELLED && assignments == before);
|
||||
submit(&admin); web_session_store_invalidate(admin.view.id); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); db_fail = true; execute(); db_fail = false; assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); stale_user = alice.user_id; execute(); stale_user = 0; assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); now = admin.view.expires_at_us - 1; submit(&admin); now = admin.view.expires_at_us; execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); operation_begin(&admin, NULL); broker_expect("200 OK", false); assert(strstr(output, "idle"));
|
||||
assert(assignments == before);
|
||||
puts("PASS Broker deadline/expiry/revocation/currentness failure and auth stop/restart fencing");
|
||||
send_fail = true; submit(&admin); send_fail = false; execute(); assert(s_operation.state == OK);
|
||||
operation_begin(&admin, NULL); broker_expect("200 OK", false); assert(strstr(output, "ok"));
|
||||
s_next_id = UINT32_MAX; operation_begin(&admin, assign_body); broker_expect("503 Service Unavailable", false);
|
||||
puts("PASS Broker lost acknowledgement retained result and nonwrapping operation IDs");
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/* Actual typed handlers, auth/parser/store; controlled HTTPD/dispatcher/owner boundaries. */
|
||||
#include "../../src/web_lifecycle_settings.c"
|
||||
static bool on_dispatcher, on_callback, on_handler, work_fail, queue_fail, invalidate_during_owner;
|
||||
static uint32_t queued_id;
|
||||
static unsigned mutations, work_calls, submit_calls, snapshots;
|
||||
static void (*work)(void *);
|
||||
static void *work_arg;
|
||||
static esp_err_t owner_error;
|
||||
static web_server_management_snapshot_t owner_snapshot = {7, true, false, true};
|
||||
esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *out) {
|
||||
assert(!host_lock_depth && on_handler); ++snapshots; *out = owner_snapshot; return owner_error;
|
||||
}
|
||||
static esp_err_t owner_action(unsigned action, uint32_t generation) {
|
||||
assert(on_dispatcher && !on_handler && !on_callback && !host_lock_depth);
|
||||
assert(generation == 7 && s_operation.action == action && s_operation.state == EXECUTING);
|
||||
++mutations;
|
||||
if (invalidate_during_owner) {
|
||||
web_session_store_invalidate(s_operation.session);
|
||||
web_lifecycle_settings_stopped(&server);
|
||||
web_lifecycle_settings_execute(s_operation.id); /* duplicate during admission */
|
||||
assert(s_operation.state == EXECUTING);
|
||||
}
|
||||
return owner_error;
|
||||
}
|
||||
esp_err_t web_server_stop_current(uint32_t generation) { return owner_action(0, generation); }
|
||||
esp_err_t web_server_restart_current(uint32_t generation) { return owner_action(1, generation); }
|
||||
esp_err_t web_server_reboot_current(uint32_t generation) { return owner_action(2, generation); }
|
||||
esp_err_t web_server_replace_identity(uint32_t generation, uint32_t identity, bool reset, bool *committed) {
|
||||
assert(identity == 11 && !reset); *committed = owner_error == ESP_OK; return owner_action(3, generation);
|
||||
}
|
||||
esp_err_t web_security_get_identity_snapshot(web_security_identity_snapshot_t *out) {
|
||||
assert(on_handler && !host_lock_depth); *out = (web_security_identity_snapshot_t){.generation=11};
|
||||
memset(out->fingerprint, 0xab, 32); return ESP_OK;
|
||||
}
|
||||
esp_err_t httpd_queue_work(httpd_handle_t handle, void (*callback)(void *), void *argument) {
|
||||
assert(on_handler && !on_dispatcher && !on_callback && !host_lock_depth && handle == &server);
|
||||
assert(!strcmp(response_status, "202 Accepted") && !send_fail && !aux.remaining_len && sends);
|
||||
assert(strstr(output, "pending") && s_ack_id == (uint32_t)(uintptr_t)argument);
|
||||
++work_calls;
|
||||
if (work_fail) return ESP_FAIL;
|
||||
assert(!work); work = callback; work_arg = argument; return ESP_OK;
|
||||
}
|
||||
esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id) {
|
||||
assert(id && on_callback && !on_dispatcher && !on_handler && !host_lock_depth); ++submit_calls;
|
||||
if (queue_fail) return ESP_FAIL;
|
||||
queued_id = id; return ESP_OK;
|
||||
}
|
||||
static void lifecycle_begin(const issued_t *identity, const char *body, bool snapshot_read) {
|
||||
begin(snapshot_read ? "/api/settings/lifecycle" : "/api/settings/lifecycle-operation", body ? HTTP_POST : HTTP_GET, body);
|
||||
same_origin(); if (body) add("Content-Type", "application/json");
|
||||
if (identity) {
|
||||
char cookie[100]; snprintf(cookie, sizeof(cookie), "__Host-sak-session=%s", identity->token);
|
||||
add("Cookie", cookie); if (body) add("X-CSRF-Token", identity->view.csrf);
|
||||
}
|
||||
}
|
||||
static void expect_lifecycle(const char *status, bool snapshot_read) {
|
||||
unsigned before = mutations, submitted = submit_calls;
|
||||
on_handler = true;
|
||||
esp_err_t error = snapshot_read ? web_lifecycle_settings_handler(&req) : web_lifecycle_operation_handler(&req);
|
||||
on_handler = false;
|
||||
assert(error == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
|
||||
assert(!strcmp(response_status, status) && mutations == before && submit_calls == submitted);
|
||||
assert(strlen(output) < (snapshot_read ? 320 : 96)); zero(scratch, sizeof(scratch));
|
||||
assert(!strstr(output, "principal") && !strstr(output, "csrf") && !strstr(output, "password"));
|
||||
}
|
||||
static const char *stop_body = "{\"action\":\"stop\",\"generation\":7}";
|
||||
static void lifecycle_submit(const issued_t *identity, const char *body) {
|
||||
lifecycle_begin(identity, body, false); expect_lifecycle("202 Accepted", false);
|
||||
}
|
||||
static void owner_callback(void) {
|
||||
assert(work); void (*callback)(void *) = work; void *argument = work_arg; work = NULL;
|
||||
on_callback = true; callback(argument); on_callback = false;
|
||||
}
|
||||
static void dispatch(void) { on_dispatcher = true; web_lifecycle_settings_execute(queued_id); on_dispatcher = false; }
|
||||
static void late_validation(void) { now += 30000000; }
|
||||
static void slow_send(void) {
|
||||
assert(on_handler && !work && !s_operation.queued && s_ack_id == s_operation.id);
|
||||
unsigned before = mutations;
|
||||
web_lifecycle_settings_execute(s_operation.id);
|
||||
assert(mutations == before && s_operation.state == PENDING);
|
||||
now += 2000000; /* Synchronous send returns only at the ACK deadline. */
|
||||
}
|
||||
static void lifecycle_tests(void) {
|
||||
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
|
||||
receive_fragment = 64;
|
||||
for (unsigned snapshot_read = 0; snapshot_read < 2; ++snapshot_read) {
|
||||
lifecycle_begin(NULL, NULL, snapshot_read); expect_lifecycle("401 Unauthorized", snapshot_read);
|
||||
lifecycle_begin(&user, NULL, snapshot_read); expect_lifecycle("403 Forbidden", snapshot_read);
|
||||
}
|
||||
lifecycle_begin(&user, stop_body, false); expect_lifecycle("403 Forbidden", false);
|
||||
for (unsigned mode = 0; mode < 10; ++mode) {
|
||||
lifecycle_begin(&admin, stop_body, false);
|
||||
if (mode == 0) req.content_len = aux.remaining_len = 257;
|
||||
if (mode == 1) req.uri = "/api/settings/lifecycle-operation?x=1";
|
||||
if (mode == 2) req.method = HTTP_GET;
|
||||
if (mode == 3) add("X-CSRF-Token", "duplicate");
|
||||
if (mode == 4) add("Origin", "https://evil.example");
|
||||
if (mode == 5) add("Transfer-Encoding", "chunked");
|
||||
if (mode == 6) add("Content-Type", "text/plain");
|
||||
if (mode == 7) add("Sec-Fetch-Site", "cross-site");
|
||||
if (mode == 8) { begin("/api/settings/lifecycle-operation", HTTP_POST, stop_body); add("Host", "device.example"); }
|
||||
if (mode == 9) { stale_user = alice.user_id; }
|
||||
on_handler = true; (void)web_lifecycle_operation_handler(&req); on_handler = false; stale_user = 0;
|
||||
assert(response_status[0] == '4' && !s_next_id && !mutations && !work_calls);
|
||||
}
|
||||
/* Stale validation may have invalidated the initial login. */
|
||||
auth_reset(); admin = mint(&alice); other = mint(&alice);
|
||||
puts("PASS lifecycle backend admin/original-cookie/Origin/CSRF and body/query/framing policy");
|
||||
const char *invalid[] = {"{}", "[]", "{\"action\":\"stop\"}",
|
||||
"{\"action\":\"stop\",\"generation\":0}", "{\"action\":\"stop\",\"generation\":4294967295}",
|
||||
"{\"action\":\"stop\",\"generation\":4294967296}", "{\"action\":\"stop\",\"generation\":07}",
|
||||
"{\"action\":\"stop\",\"generation\":7.0}", "{\"action\":\"stop\",\"generation\":7e0}",
|
||||
"{\"action\":\"stop\",\"generation\":-7}", "{\"action\":\"stop\",\"generation\":\"7\"}",
|
||||
"{\"action\":\"stop\",\"action\":\"stop\"}", "{\"action\":\"stop\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"reset\",\"generation\":7}", "{\"action\":\"certificate-rotate\",\"generation\":7}"};
|
||||
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
|
||||
lifecycle_begin(&admin, invalid[i], false); expect_lifecycle("400 Bad Request", false);
|
||||
}
|
||||
const char *invalid_rotation[] = {
|
||||
"{\"action\":\"rotate\",\"generation\":7}",
|
||||
"{\"action\":\"rotate\",\"identity_generation\":11}",
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":0}",
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":4294967295}",
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":4294967296}",
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":\"11\"}",
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"identity_generation\":11,\"reset\":true}",
|
||||
"{\"action\":\"stop\",\"generation\":7,\"identity_generation\":11}"
|
||||
};
|
||||
for (unsigned i = 0; i < sizeof(invalid_rotation)/sizeof(*invalid_rotation); ++i) {
|
||||
lifecycle_begin(&admin, invalid_rotation[i], false); expect_lifecycle("400 Bad Request", false);
|
||||
}
|
||||
lifecycle_operation_t parsed = {0};
|
||||
const char *rotation = "{\"identity_generation\":11,\"generation\":7,\"action\":\"rotate\"}";
|
||||
assert(parse(rotation, strlen(rotation), &parsed) && parsed.identity_generation == 11 && parsed.action == 3);
|
||||
for (size_t n = 0; n < strlen(rotation); ++n) assert(!parse(rotation, n, &parsed));
|
||||
for (size_t n = 0; n < strlen(stop_body); ++n) assert(!parse(stop_body, n, &parsed));
|
||||
assert(parse(stop_body, strlen(stop_body), &parsed)); assert(!parse(stop_body, strlen(stop_body) + 1, &parsed));
|
||||
const char *reordered = " { \"generation\":4294967294, \"action\":\"restart\" } ";
|
||||
assert(parse(reordered, strlen(reordered), &parsed));
|
||||
receive_fragment = 1; lifecycle_begin(&admin, stop_body, false); expect_lifecycle("400 Bad Request", false);
|
||||
assert(body_offset == 4); receive_fragment = 64;
|
||||
char full[257]; memset(full, ' ', 256); memcpy(full, stop_body, strlen(stop_body)); full[256] = 0;
|
||||
lifecycle_submit(&admin, full); assert(body_offset == 256 && !s_operation.queued);
|
||||
lifecycle_begin(&other, stop_body, false); expect_lifecycle("503 Service Unavailable", false);
|
||||
lifecycle_begin(&other, NULL, false); expect_lifecycle("200 OK", false); assert(strstr(output, "idle"));
|
||||
uint32_t first = s_operation.id;
|
||||
web_lifecycle_settings_execute(first); assert(!mutations); owner_callback(); dispatch();
|
||||
assert(mutations == 1 && s_operation.state == OK); zero(&s_operation.principal, sizeof(s_operation.principal));
|
||||
dispatch(); on_callback = true; ack_handoff((void *)(uintptr_t)first); on_callback = false;
|
||||
assert(mutations == 1 && submit_calls == 1);
|
||||
puts("PASS lifecycle strict parser/256-byte/four-receive bounds; ACK before ID dispatch and duplicate fencing");
|
||||
for (unsigned mode = 0; mode < 2; ++mode) {
|
||||
owner_error = mode ? ESP_FAIL : ESP_OK;
|
||||
lifecycle_begin(&admin, NULL, true); expect_lifecycle(mode ? "503 Service Unavailable" : "200 OK", true);
|
||||
if (!mode) {
|
||||
assert(strstr(output, "\"identity_generation\":11") && strstr(output, "\"rotatable\":true"));
|
||||
assert(strstr(output, "abababababababababababababababababababababababababababababababab"));
|
||||
assert(!strstr(output, "private") && !strstr(output, "certificate_der"));
|
||||
}
|
||||
}
|
||||
owner_error = ESP_OK;
|
||||
puts("PASS lifecycle bounded scalar snapshot and optional owner failure isolation");
|
||||
unsigned before = mutations, submitted = submit_calls;
|
||||
work_fail = true; lifecycle_submit(&admin, stop_body); work_fail = false;
|
||||
assert(s_operation.state == CANCELLED && !s_ack_id && !work); zero(&s_operation.principal, sizeof(s_operation.principal));
|
||||
send_fail = true; unsigned calls = work_calls; lifecycle_submit(&admin, stop_body); send_fail = false;
|
||||
assert(work_calls == calls && !s_ack_id && s_operation.state == CANCELLED);
|
||||
queue_fail = true; lifecycle_submit(&admin, stop_body); owner_callback(); queue_fail = false; dispatch();
|
||||
assert(s_operation.state == CANCELLED && mutations == before && submit_calls == submitted + 1);
|
||||
puts("PASS lifecycle response/HTTPD queue/dispatcher queue failures cancel only before admission without retry");
|
||||
lifecycle_submit(&admin, stop_body); uint32_t late = s_ack_id; now += 2000000;
|
||||
lifecycle_begin(&admin, NULL, false); expect_lifecycle("200 OK", false); assert(strstr(output, "cancelled") && s_ack_id == late);
|
||||
for (unsigned i = 0; i < 5; ++i) { lifecycle_begin(&admin, stop_body, false); expect_lifecycle("503 Service Unavailable", false); }
|
||||
assert(s_ack_id == late); owner_callback(); assert(!s_ack_id && mutations == before);
|
||||
lifecycle_submit(&admin, stop_body); on_callback = true; ack_handoff((void *)(uintptr_t)late); on_callback = false;
|
||||
assert(s_ack_id == s_operation.id); owner_callback(); now += 30000000; dispatch(); assert(s_operation.state == CANCELLED);
|
||||
lifecycle_submit(&admin, stop_body); owner_callback(); db_hook = late_validation; dispatch(); assert(s_operation.state == CANCELLED && mutations == before);
|
||||
puts("PASS lifecycle ACK expiry/lost-callback single reservation, late callback ABA and dequeue/post-validation deadlines");
|
||||
for (unsigned mode = 0; mode < 5; ++mode) {
|
||||
auth_reset(); admin = mint(&alice); lifecycle_submit(&admin, stop_body); owner_callback();
|
||||
if (mode == 0) web_session_store_invalidate(admin.view.id);
|
||||
if (mode == 1) db_fail = true;
|
||||
if (mode == 2) stale_user = alice.user_id;
|
||||
if (mode == 3) now = admin.view.expires_at_us;
|
||||
if (mode == 4) { hook_id = admin.view.id; db_hook = invalidate_hook; }
|
||||
dispatch(); db_fail = false; stale_user = 0;
|
||||
assert(s_operation.state == CANCELLED && mutations == before);
|
||||
}
|
||||
auth_reset(); admin = mint(&alice); lifecycle_submit(&admin, stop_body); late = s_ack_id;
|
||||
web_cookie_auth_stop(); web_lifecycle_settings_stopped(&server); assert(web_cookie_auth_start() == ESP_OK);
|
||||
admin = mint(&alice); owner_callback(); assert(!s_ack_id && s_operation.state == CANCELLED && mutations == before);
|
||||
lifecycle_submit(&admin, stop_body); on_callback = true; ack_handoff((void *)(uintptr_t)late); on_callback = false;
|
||||
assert(s_ack_id == s_operation.id); owner_callback(); dispatch(); assert(s_operation.state == OK);
|
||||
puts("PASS lifecycle original-login expiry/revocation/validation races and shutdown/restart same-owner ABA");
|
||||
for (unsigned action = 0; action < 4; ++action) {
|
||||
auth_reset(); admin = mint(&alice); char body[80];
|
||||
snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":7%s}", s_actions[action],
|
||||
action == 3 ? ",\"identity_generation\":11" : "");
|
||||
invalidate_during_owner = true; lifecycle_submit(&admin, body); owner_callback(); before = mutations; dispatch();
|
||||
invalidate_during_owner = false;
|
||||
assert(s_operation.state == OK && mutations == before + 1);
|
||||
}
|
||||
auth_reset(); admin = mint(&alice);
|
||||
owner_error = ESP_ERR_INVALID_STATE; lifecycle_submit(&admin, stop_body); owner_callback(); dispatch();
|
||||
assert(s_operation.state == FAILED); owner_error = ESP_OK;
|
||||
puts("PASS lifecycle all actions dispatcher-only; admitted revocation is not cancellation; failure uncertainty");
|
||||
before = mutations;
|
||||
send_hook = slow_send; lifecycle_submit(&admin, stop_body);
|
||||
assert(work && !s_operation.queued); owner_callback(); dispatch();
|
||||
assert(s_operation.state == CANCELLED && mutations == before);
|
||||
/* Neither callback nor dispatcher may dereference the old request, its
|
||||
* connection or its body after the handler returns. Reuse all three. */
|
||||
lifecycle_submit(&admin, stop_body);
|
||||
lifecycle_begin(&admin, NULL, true); expect_lifecycle("200 OK", true);
|
||||
memset(&req, 0xa5, sizeof(req)); memset(&aux, 0xa5, sizeof(aux));
|
||||
memset(scratch, 0xa5, sizeof(scratch)); request_body = NULL;
|
||||
owner_callback(); dispatch();
|
||||
assert(s_operation.state == OK && mutations == before + 1);
|
||||
s_next_id = UINT32_MAX; lifecycle_begin(&admin, stop_body, false); expect_lifecycle("503 Service Unavailable", false);
|
||||
puts("PASS lifecycle send-return expiry, request/connection storage reuse after disconnect, and nonwrapping operation ID exhaustion");
|
||||
}
|
||||
@@ -59,6 +59,9 @@ admin = "--admin" in sys.argv
|
||||
settings = "--settings" in sys.argv
|
||||
serial_settings = "--serial-settings" in sys.argv
|
||||
accounts = "--accounts" in sys.argv
|
||||
broker = "--broker" in sys.argv
|
||||
ssh_settings = "--ssh" in sys.argv
|
||||
lifecycle = "--lifecycle" in sys.argv
|
||||
display = "--display" in sys.argv
|
||||
if display:
|
||||
HEADERS["nvs_flash.h"] = '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n'
|
||||
@@ -247,6 +250,16 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
*(["-DHOST_ACCOUNTS"] if accounts else []),
|
||||
*(["-DHOST_NETWORK"] if network else []),
|
||||
*(["-DHOST_DISPLAY"] if display else []),
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
|
||||
*(["-DHOST_BROKER"] if broker else []),
|
||||
*(["-DHOST_SSH_SETTINGS"] if ssh_settings else []),
|
||||
*(["-DHOST_LIFECYCLE"] if lifecycle else []),
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto", *(["-lmbedcrypto"] if ssh_settings else []),
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||
if lifecycle:
|
||||
guard = subprocess.run(["cc", "-E", "-DCONFIG_HTTPD_QUEUE_WORK_BLOCKING=1",
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"),
|
||||
str(ROOT / "src/web_lifecycle_settings.c")], stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE, timeout=30)
|
||||
assert guard.returncode and b"Lifecycle ACK handoff requires nonblocking" in guard.stderr
|
||||
print("PASS lifecycle compile-time rejection of blocking HTTPD work submission")
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/* Production HTTP policy/store/settings; deterministic dispatcher/SSH owner doubles. */
|
||||
#include "../../src/web_ssh_settings.c"
|
||||
static bool on_dispatcher, queue_fail;
|
||||
static uint32_t queued_id;
|
||||
static unsigned mutations, snapshots;
|
||||
static esp_err_t owner_error;
|
||||
static ssh_transport_management_snapshot_t owner_snapshot;
|
||||
esp_err_t ssh_security_get_identity_snapshot(ssh_security_identity_snapshot_t *out) {
|
||||
assert(!host_lock_depth && !on_dispatcher);
|
||||
memset(out, 0, sizeof(*out)); out->metadata.generation = 3;
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t ssh_transport_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
|
||||
assert(on_dispatcher && !host_lock_depth && service == 7 && identity == 3 && !reset);
|
||||
++mutations; *committed = owner_error == ESP_OK; return owner_error;
|
||||
}
|
||||
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *out) {
|
||||
assert(!host_lock_depth && !on_dispatcher); ++snapshots;
|
||||
*out = owner_snapshot; return owner_error;
|
||||
}
|
||||
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action, uint32_t target, uint32_t generation) {
|
||||
assert(on_dispatcher && !host_lock_depth && generation == 7);
|
||||
assert(action <= SSH_TRANSPORT_MANAGE_DISCONNECT && target == (action == SSH_TRANSPORT_MANAGE_DISCONNECT ? 9U : 0U));
|
||||
++mutations; return owner_error;
|
||||
}
|
||||
esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id) {
|
||||
assert(id && !on_dispatcher && !host_lock_depth);
|
||||
if (queue_fail) return ESP_FAIL;
|
||||
queued_id = id; return ESP_OK;
|
||||
}
|
||||
static void operation_begin(const issued_t *identity, const char *body) {
|
||||
begin("/api/settings/ssh-operation", body ? HTTP_POST : HTTP_GET, body);
|
||||
same_origin(); if (body) add("Content-Type", "application/json");
|
||||
if (identity) {
|
||||
char cookie[100]; snprintf(cookie, sizeof(cookie), "__Host-sak-session=%s", identity->token);
|
||||
add("Cookie", cookie); if (body) add("X-CSRF-Token", identity->view.csrf);
|
||||
}
|
||||
}
|
||||
static void expect_ssh(const char *status, bool snapshot) {
|
||||
unsigned before = mutations;
|
||||
esp_err_t e = snapshot ? web_ssh_settings_handler(&req) : web_ssh_operation_handler(&req);
|
||||
assert(e == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
|
||||
if (strcmp(response_status, status)) fprintf(stderr, "expected %s got %s: %s\n", status, response_status, output);
|
||||
assert(!strcmp(response_status, status) && mutations == before);
|
||||
assert(strlen(output) < (snapshot ? 768 : 96)); zero(scratch, sizeof(scratch));
|
||||
}
|
||||
static void execute(void) { on_dispatcher = true; web_ssh_settings_execute(queued_id); on_dispatcher = false; }
|
||||
static const char *disconnect_body = "{\"action\":\"disconnect\",\"generation\":7,\"target\":9}";
|
||||
static void submit(const issued_t *who) {
|
||||
operation_begin(who, disconnect_body); expect_ssh("202 Accepted", false); assert(s_operation.state == PENDING);
|
||||
}
|
||||
static void ssh_settings_tests(void) {
|
||||
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
|
||||
receive_fragment = 64;
|
||||
operation_begin(NULL, disconnect_body); expect_ssh("401 Unauthorized", false);
|
||||
operation_begin(&user, disconnect_body); expect_ssh("403 Forbidden", false);
|
||||
operation_begin(&user, NULL); expect_ssh("403 Forbidden", false);
|
||||
for (unsigned mode = 0; mode < 8; ++mode) {
|
||||
operation_begin(&admin, disconnect_body);
|
||||
if (mode == 0) req.content_len = aux.remaining_len = 257;
|
||||
if (mode == 1) req.uri = "/api/settings/ssh-operation?x=1";
|
||||
if (mode == 2) req.method = HTTP_GET;
|
||||
if (mode == 3) add("X-CSRF-Token", "duplicate");
|
||||
if (mode == 4) add("Origin", "https://evil.example");
|
||||
if (mode == 5) add("Transfer-Encoding", "chunked");
|
||||
if (mode == 6) add("Content-Type", "text/plain");
|
||||
if (mode == 7) add("Sec-Fetch-Site", "cross-site");
|
||||
(void)web_ssh_operation_handler(&req);
|
||||
assert(response_status[0] == '4' && !s_next_id && !mutations);
|
||||
}
|
||||
puts("PASS SSH admin/cookie/Origin/CSRF and query/body/framing bounds");
|
||||
const char *invalid[] = {"{}", "[]", "{\"action\":\"disconnect\"}",
|
||||
"{\"action\":\"disconnect\",\"generation\":0,\"target\":9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":4294967295,\"target\":9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"start\",\"generation\":7,\"target\":9}",
|
||||
"{\"action\":\"stop\",\"generation\":7,\"target\":9}",
|
||||
"{\"action\":\"reboot\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":4294967296}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":09}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":9.0}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":9e0}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":-9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"generation\":9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":9,\"service\":\"web\"}"};
|
||||
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
|
||||
operation_begin(&admin, invalid[i]); expect_ssh("400 Bad Request", false);
|
||||
}
|
||||
ssh_operation_t parsed = {0};
|
||||
for (size_t n = 0; n < strlen(disconnect_body); ++n) assert(!parse(disconnect_body, n, &parsed));
|
||||
assert(parse(disconnect_body, strlen(disconnect_body), &parsed));
|
||||
assert(!parse(disconnect_body, strlen(disconnect_body)+1, &parsed));
|
||||
const char *reordered = " { \"target\":4294967295, \"generation\":4294967294, \"action\":\"disconnect\" } ";
|
||||
assert(parse(reordered, strlen(reordered), &parsed));
|
||||
receive_fragment = 1; operation_begin(&admin, disconnect_body); expect_ssh("400 Bad Request", false); assert(body_offset == 4); receive_fragment = 64;
|
||||
char full[257]; memset(full, ' ', 256); memcpy(full, disconnect_body, strlen(disconnect_body)); full[256] = 0;
|
||||
queue_fail = true; operation_begin(&admin, full); expect_ssh("503 Service Unavailable", false); queue_fail = false;
|
||||
assert(body_offset == 256 && s_operation.state == IDLE);
|
||||
puts("PASS SSH strict typed actions/parser, integer/order/truncation and exact request/receive limits");
|
||||
owner_snapshot.generation = 7; owner_snapshot.running = true;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
ssh_transport_session_snapshot_t *s = &owner_snapshot.sessions[i];
|
||||
s->active = s->principal_valid = true; s->session_id = i + 8; s->state = SSH_TRANSPORT_SESSION_ACTIVE;
|
||||
s->route = i ? SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE : SSH_TRANSPORT_ROUTE_BROKER;
|
||||
memset(s->username, '"', USER_DATABASE_USERNAME_CAPACITY);
|
||||
}
|
||||
for (unsigned mode = 0; mode < 4; ++mode) {
|
||||
unsigned before = snapshots;
|
||||
operation_begin(mode == 0 ? NULL : mode == 1 ? &user : &admin, NULL); req.uri = "/api/settings/ssh";
|
||||
owner_error = mode == 3 ? ESP_FAIL : ESP_OK;
|
||||
expect_ssh(mode == 0 ? "401 Unauthorized" : mode == 1 ? "403 Forbidden" : mode == 3 ? "503 Service Unavailable" : "200 OK", true);
|
||||
if (mode < 2) assert(snapshots == before);
|
||||
if (mode == 2) assert(strstr(output, "22222222222222222222222222222222") && !strstr(output, "socket") && !strstr(output, "principal"));
|
||||
}
|
||||
owner_error = ESP_OK;
|
||||
puts("PASS SSH bounded two-row safe snapshot and unavailable owner isolation");
|
||||
submit(&admin); uint32_t first = queued_id;
|
||||
operation_begin(&other, disconnect_body); expect_ssh("503 Service Unavailable", false);
|
||||
operation_begin(&other, NULL); expect_ssh("200 OK", false); assert(strstr(output, "idle"));
|
||||
execute(); assert(s_operation.state == OK && mutations == 1); zero(&s_operation.principal, sizeof(s_operation.principal));
|
||||
execute(); assert(mutations == 1);
|
||||
for (unsigned action = 0; action < 2; ++action) {
|
||||
char body[80]; snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":7,\"target\":0}", s_actions[action]);
|
||||
operation_begin(&admin, body); expect_ssh("202 Accepted", false); execute();
|
||||
operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, s_actions[action]));
|
||||
}
|
||||
esp_err_t failures[] = {ESP_ERR_INVALID_STATE, ESP_ERR_NOT_FOUND, ESP_FAIL};
|
||||
for (unsigned i = 0; i < 3; ++i) { owner_error = failures[i]; submit(&admin); execute(); assert(s_operation.state == (i < 2 ? CONFLICT : FAILED)); }
|
||||
owner_error = ESP_OK;
|
||||
puts("PASS SSH all three actions on dispatcher only, duplicate/stale IDs, conflicts and login-isolated results");
|
||||
submit(&admin); unsigned before = mutations;
|
||||
web_ssh_settings_execute(0); web_ssh_settings_execute(first); assert(mutations == before && s_operation.state == PENDING);
|
||||
now += 30000000; execute(); assert(s_operation.state == CANCELLED && mutations == before);
|
||||
submit(&admin); web_session_store_invalidate(admin.view.id); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); db_fail = true; execute(); db_fail = false; assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); stale_user = alice.user_id; execute(); stale_user = 0; assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); now = admin.view.expires_at_us - 1; submit(&admin); now = admin.view.expires_at_us; execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, "idle"));
|
||||
assert(mutations == before);
|
||||
puts("PASS SSH queue deadline, expiry/revocation/currentness failure, HTTPS auth lifecycle fencing");
|
||||
send_fail = true; submit(&admin); send_fail = false; execute(); assert(s_operation.state == OK);
|
||||
operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, "ok"));
|
||||
const char *rotate = "{\"action\":\"rotate\",\"generation\":7,\"target\":0,\"identity_generation\":3}";
|
||||
const char *bad_rotations[] = {
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"target\":0,\"identity_generation\":0}",
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"target\":9,\"identity_generation\":3}",
|
||||
"{\"action\":\"stop\",\"generation\":7,\"target\":0,\"identity_generation\":3}",
|
||||
"{\"action\":\"rotate\",\"generation\":7,\"target\":0,\"identity_generation\":4294967295}"};
|
||||
for (unsigned i=0;i<sizeof(bad_rotations)/sizeof(*bad_rotations);++i) {
|
||||
operation_begin(&admin,bad_rotations[i]); expect_ssh("400 Bad Request",false);
|
||||
}
|
||||
for (unsigned i=0;i<2;++i) {
|
||||
owner_error=i ? ESP_FAIL : ESP_OK;
|
||||
operation_begin(&admin,rotate); expect_ssh("202 Accepted",false);
|
||||
before=mutations; execute(); assert(mutations==before+1 && s_operation.state==(i ? FAILED : OK));
|
||||
execute(); assert(mutations==before+1);
|
||||
operation_begin(&other,NULL); expect_ssh("401 Unauthorized",false);
|
||||
}
|
||||
owner_error=ESP_OK;
|
||||
operation_begin(&admin,rotate);expect_ssh("202 Accepted",false);
|
||||
before=mutations;web_session_store_invalidate(admin.view.id);execute();assert(s_operation.state==CANCELLED&&mutations==before);
|
||||
admin=mint(&alice);
|
||||
puts("PASS SSH rotation requires both generations, rejects extras/targets/exhaustion, executes once, preserves login isolation and cancels revoked queued work");
|
||||
s_next_id = UINT32_MAX; operation_begin(&admin, disconnect_body); expect_ssh("503 Service Unavailable", false);
|
||||
puts("PASS SSH lost acknowledgement retained result and nonwrapping operation IDs");
|
||||
}
|
||||
@@ -19,7 +19,7 @@ static struct httpd_data server = {.config.max_resp_headers = 8};
|
||||
static struct sock_db socket_state;
|
||||
static struct resp_hdr response_headers[8];
|
||||
static char scratch[1024], cookie_values[2][200];
|
||||
#ifdef HOST_NETWORK
|
||||
#if defined(HOST_NETWORK) || defined(HOST_BROKER)
|
||||
static char output[2048];
|
||||
#else
|
||||
static char output[1024];
|
||||
@@ -31,6 +31,7 @@ static unsigned fail_header, setter_calls;
|
||||
static bool send_fail, recv_fail;
|
||||
static size_t receive_fragment = 7;
|
||||
static void (*password_hook)(void);
|
||||
static void (*send_hook)(void);
|
||||
static char response_status[48];
|
||||
static struct httpd_req_aux aux;
|
||||
static httpd_req_t req;
|
||||
@@ -54,6 +55,7 @@ esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body) {
|
||||
snprintf(cookie_values[cookie_count++], 200, "%s", response_headers[i].value);
|
||||
}
|
||||
}
|
||||
if (send_hook) { void (*hook)(void) = send_hook; send_hook = NULL; hook(); }
|
||||
return send_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
int httpd_req_recv(httpd_req_t *r, char *out, size_t size) {
|
||||
@@ -146,6 +148,15 @@ static void auth_reset(void) {
|
||||
#ifdef HOST_DISPLAY
|
||||
#include "display_settings_test.c"
|
||||
#endif
|
||||
#ifdef HOST_BROKER
|
||||
#include "broker_settings_test.c"
|
||||
#endif
|
||||
#ifdef HOST_SSH_SETTINGS
|
||||
#include "ssh_settings_test.c"
|
||||
#endif
|
||||
#ifdef HOST_LIFECYCLE
|
||||
#include "lifecycle_test.c"
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
@@ -308,6 +319,15 @@ int main(void) {
|
||||
#endif
|
||||
#ifdef HOST_DISPLAY
|
||||
display_settings_tests();
|
||||
#endif
|
||||
#ifdef HOST_BROKER
|
||||
broker_settings_tests();
|
||||
#endif
|
||||
#ifdef HOST_SSH_SETTINGS
|
||||
ssh_settings_tests();
|
||||
#endif
|
||||
#ifdef HOST_LIFECYCLE
|
||||
lifecycle_tests();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ particular, modeled failed commits retain predecessor storage; real flash fault
|
||||
and power-loss behavior needs target validation. No claim that NVS logical
|
||||
replacement securely erases historical flash pages.
|
||||
|
||||
The suite reports 15 production security groups plus one API/console static
|
||||
The suite reports 17 production security groups plus one API/console static
|
||||
absence check. Coverage includes fresh and stored-v2 paths, exact v1 migration,
|
||||
metadata/pair-copy bounds, NVS failures and retries, 21 legacy corruptions,
|
||||
14 v2 corruptions, unknown sizes, real bad signatures with recomputed hashes,
|
||||
@@ -44,7 +44,7 @@ intentionally retained.
|
||||
|
||||
## Integration/API contract
|
||||
|
||||
Five public functions remain:
|
||||
The five existing public functions remain:
|
||||
|
||||
- `web_security_init(web_security_load_result_t *)`
|
||||
- `web_security_copy_tls_material(...)` (unchanged pair-copy API)
|
||||
@@ -52,23 +52,40 @@ Five public functions remain:
|
||||
- `web_security_rotate_certificate(void)`
|
||||
- `web_security_reset_all(void)` (**TLS only**, changed signature)
|
||||
|
||||
8D.21 adds `web_security_get_identity_snapshot()` (zero-wait public fingerprint/
|
||||
generation only) and the owner-only reservation contract
|
||||
`web_security_reserve_identity()` / `web_security_replace_reserved()` /
|
||||
`web_security_release_identity()`. The API-symbol check includes all nine functions.
|
||||
New tests cover zero-wait contention, stale generations, one-use/nonreused tokens,
|
||||
reservation exhaustion and competing canonical rotate/reset/init during real crypto.
|
||||
Crypto/NVS runs outside the normal mutex during replacement; the identity reservation
|
||||
survives until its owner releases it after service stop/start.
|
||||
|
||||
`python3 tests/web_admin_transport/server_lifecycle.py` additionally links real
|
||||
production security and mbedTLS to the production HTTPS owner, with NVS/HTTPD doubles,
|
||||
for unchanged identity/storage before commit and no rollback after stop/start failure.
|
||||
See [8D.21 contracts and evidence limits](../../docs/phase8d21_implementation.md).
|
||||
|
||||
Removed: two credential functions (`show_credentials`, `rotate_credentials`),
|
||||
one credential struct type, three username/password capacity/length constants,
|
||||
and two console operations (`web credentials show`, `web credentials rotate`).
|
||||
There is no credential generation/display/synchronization path. Authentication
|
||||
continues to belong to the user database; read-only status does not mutate it.
|
||||
The integration owner must remove legacy startup callers in `main.c` and adapt
|
||||
other console policy/completion/UI/test callers outside this ownership scope.
|
||||
Legacy startup callers and console policy/completion integrations were removed in
|
||||
the accepted legacy-credential cleanup; this test does not reintroduce them.
|
||||
|
||||
Load results retain `STORED=0`, `GENERATED_MISSING=1`, and add `MIGRATED_V1=2`.
|
||||
Repeated successful init returns the remembered result without reloading.
|
||||
Repeated successful init returns the remembered result without reloading; an active
|
||||
identity reservation rejects init until its owner finishes.
|
||||
Migration must validate and commit before publication; no fallback generation
|
||||
or overwrite follows migration failure. Reset explicitly overwrites missing,
|
||||
valid, or incompatible material, increments a live generation or uses one when
|
||||
no live identity exists, and fails on live generation exhaustion. Rotation
|
||||
requires live material and also fails at `UINT32_MAX`.
|
||||
|
||||
`web reset --force` retains the old lifecycle: commit first; when running,
|
||||
CLI and browser-shell identity mutations now share `web_server_replace_identity()`
|
||||
service/security reservation composition. `web reset --force` retains the old
|
||||
lifecycle: commit first; when running,
|
||||
stop then start, with no start after failed stop; otherwise attempt start.
|
||||
Lifecycle failure does not roll back committed identity. Database accounts are
|
||||
never synchronized, reset or otherwise mutated by these operations.
|
||||
|
||||
@@ -17,6 +17,7 @@ HEADERS = {
|
||||
#define ESP_ERR_INVALID_VERSION 4
|
||||
#define ESP_ERR_INVALID_RESPONSE 5
|
||||
#define ESP_ERR_NO_MEM 6
|
||||
#define ESP_ERR_TIMEOUT 7
|
||||
""",
|
||||
"esp_mac.h": """#pragma once
|
||||
#include <stdint.h>
|
||||
@@ -26,6 +27,7 @@ HEADERS = {
|
||||
""",
|
||||
"freertos/FreeRTOS.h": """#pragma once
|
||||
#define portMAX_DELAY 0xffffffffU
|
||||
#define pdTRUE 1
|
||||
""",
|
||||
"freertos/semphr.h": """#pragma once
|
||||
typedef void *SemaphoreHandle_t;
|
||||
@@ -69,7 +71,8 @@ with tempfile.TemporaryDirectory(prefix="web-security-") as directory:
|
||||
assert set(re.findall(r" T (web_security_\w+)$", symbols, re.MULTILINE)) == {
|
||||
"web_security_init", "web_security_copy_tls_material",
|
||||
"web_security_get_certificate_metadata", "web_security_rotate_certificate",
|
||||
"web_security_reset_all",
|
||||
"web_security_reset_all", "web_security_get_identity_snapshot",
|
||||
"web_security_reserve_identity", "web_security_replace_reserved", "web_security_release_identity",
|
||||
}
|
||||
header = (ROOT / "src/web_security.h").read_text()
|
||||
assert "web_security_credentials_t" not in header
|
||||
@@ -78,8 +81,8 @@ with tempfile.TemporaryDirectory(prefix="web-security-") as directory:
|
||||
console = (ROOT / "src/web_console.c").read_text()
|
||||
for forbidden in ('"credentials"', "web credentials", "user_database_sync_legacy", "synchronize_migrated", "Password:"):
|
||||
assert forbidden not in console, forbidden
|
||||
assert "web_security_reset_all()" in console
|
||||
assert "web_server_replace_identity(0, 0, reset, &committed)" in console
|
||||
assert set(re.findall(r"\b(user_database_\w+)\s*\(", console)) == {
|
||||
"user_database_get_snapshot",
|
||||
}
|
||||
print("PASS exact five-function API and legacy credential/console DB-mutation absence")
|
||||
print("PASS exact public/owner API and legacy credential/console DB-mutation absence")
|
||||
|
||||
@@ -11,13 +11,14 @@
|
||||
static uint8_t stored[1600], pending[1600];
|
||||
static size_t stored_size, pending_size;
|
||||
static int fault, writes, commits, rng_calls, legacy_wipes, groups;
|
||||
static bool locked, fail_mutex, fail_rng, fail_mac, alternate_mac, watch_publication;
|
||||
static bool locked, fail_mutex, fail_rng, fail_mac, alternate_mac, watch_publication, mutex_busy;
|
||||
static void (*crypto_hook)(void);
|
||||
static web_security_blob_t expected_live;
|
||||
|
||||
enum { OPEN_RO = 20, OPEN_RW, QUERY, READ, SET, COMMIT, TYPE, SHORT_READ };
|
||||
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return fail_mutex ? NULL : (void *)1; }
|
||||
int xSemaphoreTake(SemaphoreHandle_t m, unsigned delay)
|
||||
{ (void)delay; assert(m && !locked); locked = true; return 1; }
|
||||
{ assert(m && !locked); if (mutex_busy) { assert(delay == 0); return 0; } locked = true; return 1; }
|
||||
int xSemaphoreGive(SemaphoreHandle_t m)
|
||||
{ assert(m && locked); locked = false; return 1; }
|
||||
esp_err_t esp_read_mac(uint8_t *mac, int type)
|
||||
@@ -32,6 +33,7 @@ esp_err_t secure_random_init(void) { return fail_rng ? ESP_FAIL : ESP_OK; }
|
||||
esp_err_t secure_random_fill(void *out, size_t length)
|
||||
{
|
||||
++rng_calls;
|
||||
if (crypto_hook) { void (*hook)(void) = crypto_hook; crypto_hook = NULL; hook(); }
|
||||
if (fail_rng) return ESP_FAIL;
|
||||
return getrandom(out, length, 0) == (ssize_t)length ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
@@ -68,7 +70,7 @@ esp_err_t nvs_get_blob(nvs_handle_t handle, const char *key, void *data, size_t
|
||||
esp_err_t nvs_set_blob(nvs_handle_t handle, const char *key, const void *data, size_t size)
|
||||
{
|
||||
assert(handle == NVS_READWRITE && !strcmp(key, "material"));
|
||||
assert(size == 1340 && locked);
|
||||
assert(size == 1340 && (s_identity_token ? !locked : locked));
|
||||
++writes;
|
||||
if (watch_publication) assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
|
||||
if (fault == SET) return ESP_FAIL;
|
||||
@@ -90,6 +92,8 @@ static void boot(void)
|
||||
{
|
||||
memset(&s_material, 0, sizeof(s_material));
|
||||
s_material_ready = false; s_security_mutex = NULL;
|
||||
s_identity_token = s_next_identity_token = 0; s_identity_used = false;
|
||||
crypto_hook = NULL; mutex_busy = false;
|
||||
s_load_result = WEB_SECURITY_LOAD_STORED;
|
||||
fault = writes = commits = rng_calls = legacy_wipes = 0;
|
||||
fail_mutex = fail_rng = fail_mac = alternate_mac = locked = false;
|
||||
@@ -124,6 +128,19 @@ static void rejected(void)
|
||||
assert(writes == 0 || fault == SET || fault == COMMIT);
|
||||
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
|
||||
}
|
||||
static void competing_identity(void)
|
||||
{
|
||||
assert(!locked && s_identity_token);
|
||||
web_security_identity_snapshot_t snapshot;
|
||||
assert(web_security_get_identity_snapshot(&snapshot) == ESP_OK && snapshot.busy);
|
||||
assert(snapshot.generation == expected_live.generation);
|
||||
assert(!memcmp(snapshot.fingerprint, expected_live.certificate_fingerprint, 32));
|
||||
assert(web_security_rotate_certificate() == ESP_ERR_INVALID_STATE);
|
||||
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
|
||||
assert(web_security_init(NULL) == ESP_ERR_INVALID_STATE);
|
||||
assert(!memcmp(&expected_live, &s_material, sizeof(s_material)));
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
boot(); stored_size = 0;
|
||||
@@ -262,6 +279,34 @@ int main(void)
|
||||
}
|
||||
group("rotation/reset transactional failures, identity change and generation increment");
|
||||
|
||||
uint32_t token = 0, generation = s_material.generation;
|
||||
int prior_writes = writes, prior_rng = rng_calls;
|
||||
assert(web_security_reserve_identity(generation - 1, false, &token) == ESP_ERR_INVALID_STATE && !token);
|
||||
assert(writes == prior_writes && rng_calls == prior_rng);
|
||||
web_security_identity_snapshot_t projection;
|
||||
mutex_busy = true;
|
||||
assert(web_security_get_identity_snapshot(&projection) == ESP_ERR_TIMEOUT);
|
||||
assert(web_security_reserve_identity(generation, false, &token) == ESP_ERR_TIMEOUT);
|
||||
mutex_busy = false;
|
||||
assert(web_security_reserve_identity(generation, false, &token) == ESP_OK && token);
|
||||
crypto_hook = competing_identity;
|
||||
assert(web_security_replace_reserved(token) == ESP_OK && !crypto_hook);
|
||||
assert(s_identity_token == token && s_material.generation == generation + 1);
|
||||
assert(web_security_replace_reserved(token) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_security_reset_all() == ESP_ERR_INVALID_STATE);
|
||||
web_security_release_identity(token - 1); assert(s_identity_token == token);
|
||||
web_security_release_identity(token); assert(!s_identity_token);
|
||||
assert(web_security_replace_reserved(token) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_security_get_identity_snapshot(&projection) == ESP_OK && !projection.busy);
|
||||
assert(!memcmp(projection.fingerprint, s_material.certificate_fingerprint, 32));
|
||||
expected_live = s_material;
|
||||
group("zero-wait public projection and stale/token fencing; real crypto outside locks excludes canonical writers through release");
|
||||
s_next_identity_token = UINT32_MAX;
|
||||
assert(web_security_reserve_identity(0, true, &token) == ESP_ERR_INVALID_STATE);
|
||||
assert(web_security_get_identity_snapshot(&projection) == ESP_OK && projection.busy);
|
||||
assert(!memcmp(&s_material, &expected_live, sizeof(s_material)));
|
||||
group("reservation IDs saturate without ABA or recovery mutation bypass");
|
||||
|
||||
for (int kind = 0; kind < 3; ++kind) {
|
||||
boot(); legacy(&identity);
|
||||
if (kind == 0) stored_size = 0;
|
||||
|
||||
@@ -15,6 +15,10 @@ doubles. It reuses the HTTPD stub text from `tests/web_login_ui/run.py`, without
|
||||
importing/executing that runner. Node executes the actual C-rendered application
|
||||
and inline asset-failure script, not a separately maintained implementation.
|
||||
|
||||
Current 8D.17 run: **126 behavior groups**, plus production renderer/HTML/CSP checks. Seven new quick groups cover mouse/focus/touch activation, single DOM ownership, focused-editor hover stability, Escape/outside dismissal, late reads/expiry, terminal input/drain isolation, full-page draft/promotion preservation, explicit Apply/Save and pending non-replay, Network secret exclusion/strict schema/401. `Element` focus/containment/hover and events are doubles, not browser event-dispatch proof.
|
||||
|
||||
Optional geometry: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py`. The fixture covers 18 cases (four full views plus Serial/Wi-Fi quick panels at320/600/1200px, quick height360), including viewport bounds/scrolling and secret-control exclusion. The 8D.17 attempt was **blocked before measurement** by sandbox socket/Crash Reports restrictions; no geometry pass claimed. Full evidence/limits and target checklist: `docs/phase8d17_implementation.md`.
|
||||
|
||||
Coverage:
|
||||
|
||||
- Resource selection, NULL/invalid input, setter/send failure propagation,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
|
||||
const path = '/api/settings/broker', op = path + '-operation';
|
||||
const row = (id, type = 1) => ({id, type, name_hex:'3c696d673e', pending:12, high_water:4096, dropped:'18446744073709551615'});
|
||||
const fixture = (extra = {}) => ({generation:7, writer:8, clients:[row(8),row(9,3)], ...extra});
|
||||
const reply = (state='pending', id=42, status=200, action='assign') => new Response(JSON.stringify({id,action,state}), {status});
|
||||
const n = (b,id) => b.nodes['broker-'+id];
|
||||
const posts = b => b.calls.filter(c => c.url === op && c.method === 'POST');
|
||||
const gets = b => b.calls.filter(c => c.url === op && c.method === 'GET');
|
||||
function liveTimer(b) { const entry=[...b.timers].find(([,t])=>t.ms===5000&&!t.interval); assert.ok(entry, 'missing live/deadline timer'); b.timers.delete(entry[0]); entry[1].fn(); }
|
||||
const reads = b => b.calls.filter(c => c.url === path);
|
||||
async function open(value=fixture()) {
|
||||
const b=await adminBrowser(); b.click('select-settings'); await tick();
|
||||
b.queues[path].push(json(value)); b.click('settings-broker'); await tick(); return b;
|
||||
}
|
||||
function select(b,id=9) { n(b,'target').value=String(id); n(b,'target').change(); }
|
||||
async function submit(b) { select(b); b.window.confirm=()=>true; b.queues[op].push(reply('pending',42,202)); b.click('broker-assign'); await tick(); }
|
||||
async function quick(domain='broker', mode='serial') {
|
||||
const b=await adminBrowser(); b.click('select-'+mode);
|
||||
b.nodes['serial-settings'].appendChild(b.nodes['quick-close']);
|
||||
b.queues[path].push(json(fixture())); b.click('quick-'+domain); await tick(); return b;
|
||||
}
|
||||
await test('Contextual broker/writer native triggers support pointer keyboard touch, admin-only management and terminal input/focus isolation',async()=>{
|
||||
for(const domain of ['broker','writer']) {
|
||||
const u=browser();u.start();await tick();assert.ok(u.nodes['quick-'+domain].disabled);
|
||||
u.nodes['quick-'+domain].pointerenter({pointerType:'mouse'});u.nodes['quick-'+domain].focus();u.click('quick-'+domain);await tick();assert.equal(reads(u).length,0);
|
||||
for(const mode of ['serial','admin']) {
|
||||
const b=await quick(domain,mode), trigger=b.nodes['quick-'+domain], host=b.nodes['serial-settings'];
|
||||
assert.equal(host.role,'dialog');assert.equal(trigger['aria-expanded'],'true');assert.equal(b.document.activeElement,b.nodes['quick-close']);
|
||||
assert.equal(posts(b).length,0);select(b);assert.equal(posts(b).length,0);
|
||||
for(let i=0;i<2;++i){b.sockets[i].emit('message',{data:Uint8Array.of(255,0,i).buffer});assert.deepEqual(b.terminals[i].writes.at(-1),[255,0,i]);b.terminals[i].input('blocked');assert.equal(b.sockets[i].sent.length,0);}
|
||||
const focus=b.terminals.map(t=>t.focusCalls);
|
||||
b.emit('keydown',{key:'Escape',preventDefault(){},stopPropagation(){}});
|
||||
assert.equal(b.document.activeElement,trigger);assert.deepEqual(b.terminals.map(t=>t.focusCalls),focus);assert.equal(host['data-quick'],'false');
|
||||
trigger.pointerenter({pointerType:'touch'});await tick();assert.equal(host['data-quick'],'false');
|
||||
b.queues[path].push(json(fixture()));b.click('quick-'+domain);await tick();assert.equal(host['data-quick'],'true');
|
||||
b.emit('pointerdown',{target:b.nodes['connection-toggle']});b.nodes['connection-toggle'].focus();assert.equal(b.document.activeElement,b.nodes['connection-toggle']);assert.deepEqual(b.terminals.map(t=>t.focusCalls),focus);
|
||||
trigger.pointerleave();b.queues[path].push(json(fixture()));trigger.focus();await tick();assert.equal(trigger['aria-expanded'],'true');
|
||||
b.nodes['quick-close'].focus();trigger.pointerleave();b.fire(250);assert.equal(host['data-quick'],'true');
|
||||
}
|
||||
}
|
||||
});
|
||||
await test('Live refresh is single-flight, preserves explicit selection across reorder/counters and never rebases lease/identity tokens',async()=>{
|
||||
const b=await quick();select(b);const d=deferred();b.queues[path].push(d.promise);liveTimer(b);await tick();
|
||||
assert.equal(reads(b).length,2);assert.ok(n(b,'assign').disabled);assert.equal(n(b,'target').disabled,false);
|
||||
b.click('broker-refresh');b.click('quick-broker');await tick();assert.equal(reads(b).length,2);
|
||||
d.resolve(json(fixture({clients:[{...row(9,3),pending:0},row(8)]})));await tick();
|
||||
assert.equal(n(b,'target').value,'9');assert.equal(n(b,'assign').disabled,false);
|
||||
b.queues[path].push(json(fixture({generation:8})));liveTimer(b);await tick();assert.equal(n(b,'target').value,'9');assert.ok(n(b,'assign').disabled);assert.match(n(b,'detail').textContent,/Selection stale/);
|
||||
b.queues[path].push(json(fixture()));liveTimer(b);await tick();assert.ok(n(b,'assign').disabled);b.click('broker-assign');await tick();assert.equal(posts(b).length,0);assert.match(n(b,'operation-detail').textContent,/Selection stale/);
|
||||
select(b);assert.equal(n(b,'assign').disabled,false);
|
||||
b.queues[path].push(json(fixture({clients:[row(8),{...row(9,3),name_hex:'61'}]})));liveTimer(b);await tick();assert.ok(n(b,'assign').disabled);
|
||||
b.queues[path].push(json(fixture()));liveTimer(b);await tick();assert.ok(n(b,'assign').disabled);
|
||||
select(b);b.queues[path].push(json(fixture({clients:[row(8)]})));liveTimer(b);await tick();assert.equal(n(b,'target').value,'');
|
||||
b.queues[path].push(json(fixture()));liveTimer(b);await tick();assert.equal(n(b,'target').value,'');assert.ok(n(b,'assign').disabled);assert.equal(posts(b).length,0);
|
||||
});
|
||||
await test('Live refresh preserves focused action/select controls with aria-disabled gating and visible stale rejection',async()=>{
|
||||
for(const id of ['assign','refresh','result','target']) {
|
||||
const b=await quick();select(b);b.nodes['serial-settings'].appendChild(n(b,id));n(b,id).focus();
|
||||
const focus=b.terminals.map(t=>t.focusCalls), d=deferred();b.queues[path].push(d.promise);liveTimer(b);await tick();
|
||||
assert.equal(b.document.activeElement,n(b,id));assert.equal(n(b,id).disabled,false);
|
||||
if(id!=='target'){assert.equal(n(b,id)['aria-disabled'],'true');b.click('broker-'+id);await tick();assert.equal(reads(b).length,2);assert.equal(posts(b).length,0);assert.equal(gets(b).length,0);}
|
||||
d.resolve(json(fixture({generation:8})));await tick();assert.equal(b.document.activeElement,n(b,id));assert.equal(n(b,id).disabled,false);assert.deepEqual(b.terminals.map(t=>t.focusCalls),focus);
|
||||
if(id==='assign'){assert.equal(n(b,id)['aria-disabled'],'true');b.click('broker-assign');await tick();assert.match(n(b,'operation-detail').textContent,/Selection stale/);assert.equal(posts(b).length,0);}
|
||||
}
|
||||
});
|
||||
await test('Live timeout/error stops automatic refresh and stale late responses cannot restore selection or close a newer view',async()=>{
|
||||
for(const response of [failure(503),json({invalid:true})]) {
|
||||
const b=await quick();select(b);b.queues[path].push(response);liveTimer(b);await tick();assert.ok(n(b,'assign').disabled);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)));
|
||||
}
|
||||
for(const stage of ['session','snapshot']) {
|
||||
const b=await quick();select(b);const d=deferred();b.queues[stage==='session'?'/api/session':path].push(d.promise);liveTimer(b);await tick();liveTimer(b);await tick();
|
||||
assert.match(n(b,'detail').textContent,/timed out/);assert.ok(n(b,'assign').disabled);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)));
|
||||
b.queues[path].push(json(fixture()));b.click('broker-refresh');await tick();assert.ok(n(b,'assign').disabled);
|
||||
d.resolve(stage==='session'?session({role:'admin'}):json(fixture({generation:99})));await tick();assert.ok(n(b,'assign').disabled);select(b);
|
||||
b.queues[op].push(reply('pending',42,202));b.click('broker-assign');await tick();assert.equal(JSON.parse(posts(b)[0].body).generation,7);
|
||||
}
|
||||
});
|
||||
await test('Context triggers preserve every full-page draft and coordinate sole DOM ownership with Serial/WiFi quick views',async()=>{
|
||||
const b=await adminBrowser();b.click('select-settings');await tick();b.nodes['edit-baud'].value='12345';
|
||||
for(const domain of ['broker','writer']) {b.nodes['quick-'+domain].pointerenter({pointerType:'mouse'});b.nodes['quick-'+domain].focus();b.click('quick-'+domain);await tick();assert.equal(b.nodes['edit-baud'].value,'12345');assert.equal(reads(b).length,0);}
|
||||
b.click('settings-accounts');await tick();b.nodes['account-password'].value='unsubmitted';b.click('quick-writer');await tick();assert.equal(b.nodes['account-password'].value,'unsubmitted');
|
||||
b.click('settings-network');await tick();b.nodes['network-password'].value='unsubmitted';b.click('quick-broker');await tick();assert.equal(b.nodes['network-password'].value,'unsubmitted');
|
||||
b.click('select-serial');b.click('quick-serial');await tick();b.nodes['quick-serial'].focusout();b.queues[path].push(json(fixture()));b.nodes['quick-writer'].focus();await tick();assert.equal(b.document.activeElement,b.nodes['quick-writer']);assert.equal(b.nodes['quick-serial']['aria-expanded'],'false');
|
||||
select(b);const count=reads(b).length;b.nodes['quick-full'].click({preventDefault(){}});await tick();assert.equal(n(b,'target').value,'9');assert.equal(reads(b).length,count);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)));
|
||||
});
|
||||
await test('Context stale confirmation retains captured lease through delayed validation; conflict visible and no replay on dismiss/reopen',async()=>{
|
||||
const b=await quick('writer');select(b);const d=deferred();b.queues['/api/session'].push(d.promise);b.queues[op].push(reply('pending',42,202));b.click('broker-assign');await tick();assert.equal(posts(b).length,0);
|
||||
b.click('broker-refresh');await tick();d.resolve(session({role:'admin'}));await tick();assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'assign',generation:7,target:9});
|
||||
b.queues[op].push(reply('conflict'));b.queues[path].push(json(fixture({generation:10})));b.fire(1000);await tick();assert.match(n(b,'operation-detail').textContent,/No lease change/);assert.ok(n(b,'assign').disabled);
|
||||
b.click('quick-close');b.queues[path].push(json(fixture()));b.click('quick-writer');await tick();assert.equal(posts(b).length,1);assert.equal(n(b,'target').value,'');
|
||||
select(b);b.queues[op].push(()=>{throw Error('lost');});b.click('broker-assign');await tick();b.click('quick-close');b.queues[path].push(json(fixture()));b.click('quick-writer');await tick();assert.equal(posts(b).length,2);assert.match(n(b,'operation-detail').textContent,/unknown/);assert.ok(n(b,'assign').disabled);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)||t.ms===1000));
|
||||
});
|
||||
await test('Context read/operation/session identity fencing covers expiry pagehide late401 and newer quick ownership',async()=>{
|
||||
for(const stage of ['read','post','result']) {
|
||||
const b=await quick();const d=deferred();
|
||||
if(stage==='read'){b.queues[path].push(d.promise);liveTimer(b);}else if(stage==='post'){select(b);b.queues[op].push(d.promise);b.click('broker-assign');}else{await submit(b);b.queues[op].push(d.promise);b.fire(1000);}
|
||||
await tick();b.click('quick-serial');await tick();d.resolve(failure(401));await tick();assert.deepEqual(b.redirects,[]);assert.equal(b.nodes['quick-serial']['aria-expanded'],'true');assert.ok(b.sockets.every(s=>!s.closed));
|
||||
}
|
||||
for(const action of ['expiry','pagehide','identity','401']) {
|
||||
const b=await quick();select(b);
|
||||
if(action==='expiry')b.window.sakSessionExpired();else if(action==='pagehide')b.emit('pagehide');else{if(action==='identity')b.queues['/api/session'].push(session({role:'admin',username:'other'}));else b.queues[path].push(failure(401));liveTimer(b);}
|
||||
await tick();assert.ok(b.sockets.every(s=>s.closed));assert.equal(n(b,'target').value,'');assert.equal(posts(b).length,0);assert.ok(![...b.timers.values()].some(t=>(t.ms===5000&&!t.interval)));
|
||||
}
|
||||
});
|
||||
await test('Broker admin-only full page, label/value style, bounded safe rows; view/selection do not mutate or close either terminal', async()=>{
|
||||
for(const id of ['settings-broker','broker-values','broker-target','broker-assign','broker-refresh','broker-result']) assert.ok(html.includes('id="'+id+'"'));
|
||||
const u=browser(); u.start(); await tick(); u.click('settings-broker'); await tick(); assert.equal(reads(u).length,0);
|
||||
const b=await open(); assert.equal(b.nodes['broker-settings'].hidden,false); assert.equal(b.nodes['display-settings'].hidden,true);
|
||||
assert.match(n(b,'values').textContent,/<img>/); assert.match(n(b,'values').textContent,/18446744073709551615/);
|
||||
select(b); assert.equal(posts(b).length,0); assert.equal(n(b,'assign').disabled,false);
|
||||
const count=b.calls.length; b.click('settings-broker'); await tick(); assert.equal(b.calls.length,count);
|
||||
for(let i=0;i<2;++i){ b.sockets[i].emit('message',{data:Uint8Array.of(0,255,i).buffer}); assert.deepEqual(b.terminals[i].writes.at(-1),[0,255,i]); b.terminals[i].input('blocked'); assert.equal(b.sockets[i].sent.length,0); }
|
||||
assert.ok(b.sockets.every(s=>!s.closed));
|
||||
});
|
||||
await test('Broker snapshot rejects malformed/missing/duplicate/oversized rows and exhausted generation disables assignments',async()=>{
|
||||
for(const v of [null,{},fixture({generation:0}),fixture({extra:1}),fixture({writer:123}),fixture({clients:[row(8),row(8)]}),fixture({clients:[{...row(8),name_hex:'zz'}]}),fixture({clients:[{...row(8),dropped:'18446744073709551616'}]}),fixture({clients:[{...row(8),pending:4097}]}),fixture({clients:Array.from({length:9},(_,i)=>row(i+8))})]){
|
||||
const b=await open(v); select(b); assert.ok(n(b,'assign').disabled); assert.match(n(b,'detail').textContent,/unavailable|invalid/);
|
||||
}
|
||||
const b=await open(fixture({generation:4294967295})); select(b); assert.ok(n(b,'assign').disabled);
|
||||
const empty=await open(fixture({writer:0,clients:[]})); assert.match(n(empty,'detail').textContent,/0 connected/);
|
||||
const c=await open(); c.queues[path].push(new Response('x'.repeat(2049))); c.click('broker-refresh'); await tick(); select(c); assert.ok(n(c,'assign').disabled);
|
||||
});
|
||||
await test('Broker explicit confirmation captures target and lease generation, cancel/current-writer/selection never POST; refresh retains stale selection',async()=>{
|
||||
const b=await open(); select(b,8); b.click('broker-assign'); await tick(); assert.equal(posts(b).length,0);
|
||||
select(b); let prompt=''; b.window.confirm=text=>{prompt=text;return false;}; b.click('broker-assign'); await tick(); assert.equal(posts(b).length,0); assert.match(prompt,/9 \/ SSH/); assert.match(prompt,/Current writer: 8/);
|
||||
await submit(b); assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'assign',generation:7,target:9}); assert.equal(posts(b)[0].headers['X-CSRF-Token'],token);
|
||||
b.click('broker-assign'); await tick(); assert.equal(posts(b).length,1);
|
||||
b.queues[op].push(reply('ok')); b.queues[path].push(json(fixture({generation:8,writer:9}))); b.fire(1000); await tick();
|
||||
assert.equal(n(b,'target').value,'9'); assert.ok(n(b,'assign').disabled); assert.match(n(b,'detail').textContent,/Selection stale/); assert.match(n(b,'operation-detail').textContent,/completed/); assert.equal(posts(b).length,1);
|
||||
});
|
||||
await test('Broker stale/failed/cancelled completion refreshes without retry; stale target removal clears explicit selection',async()=>{
|
||||
for(const state of ['conflict','failed','cancelled']) {
|
||||
const b=await open(); await submit(b); b.queues[op].push(reply(state)); b.queues[path].push(json(fixture({clients:[row(8)]}))); b.fire(1000); await tick();
|
||||
assert.equal(posts(b).length,1); assert.equal(reads(b).length,2); assert.equal(n(b,'target').value,''); assert.ok(n(b,'assign').disabled);
|
||||
if(state==='conflict') assert.match(n(b,'operation-detail').textContent,/No lease change/);
|
||||
}
|
||||
});
|
||||
await test('Broker polling bounded to ten requests/fifteen seconds, including stalled session checks',async()=>{
|
||||
const b=await open(); await submit(b);
|
||||
for(let i=0;i<10;++i){b.queues[op].push(reply());b.fire(1000);await tick();}
|
||||
assert.equal(gets(b).length,10);assert.equal(posts(b).length,1);assert.match(n(b,'operation-detail').textContent,/Automatic checking stopped/);
|
||||
const c=await open();await submit(c);const d=deferred();c.queues['/api/session'].push(d.promise);c.fire(1000);await tick();c.elapse(15000);c.fire(15000);await tick();d.resolve(session({role:'admin'}));await tick();assert.equal(gets(c).length,0);
|
||||
});
|
||||
await test('Broker lost acknowledgement/replaced result/invalid state preserve uncertainty and never replay',async()=>{
|
||||
const b=await open();select(b);b.window.confirm=()=>true;b.queues[op].push(()=>{throw Error('lost');});b.click('broker-assign');await tick();
|
||||
assert.match(n(b,'operation-detail').textContent,/unknown/);assert.ok(n(b,'assign').disabled);
|
||||
b.queues[op].push(reply('ok'));b.queues[path].push(json(fixture()));b.click('broker-result');await tick();assert.match(n(b,'operation-detail').textContent,/acknowledgement was lost/);
|
||||
for(const result of [reply('pending',43),reply('loaded_defaults'),reply('ok',42,202),reply('ok',42,200,'reset'),reply('idle',42)]){
|
||||
const c=await open();await submit(c);c.queues[op].push(result);c.fire(1000);await tick();assert.equal(posts(c).length,1);assert.match(n(c,'operation-detail').textContent,/unknown/);assert.ok(![...c.timers.values()].some(t=>t.ms===1000||t.ms===15000));
|
||||
}
|
||||
});
|
||||
await test('Broker navigation aborts stale read/POST/results, no automatic resubmit on return; endpoint401 closes both terminals',async()=>{
|
||||
for(const stage of ['read','post','result']){
|
||||
const b=await open();const d=deferred();
|
||||
if(stage==='read'){b.queues[path].push(d.promise);b.click('broker-refresh');}
|
||||
else if(stage==='post'){select(b);b.window.confirm=()=>true;b.queues[op].push(d.promise);b.click('broker-assign');}
|
||||
else{await submit(b);b.queues[op].push(d.promise);b.fire(1000);}
|
||||
await tick();b.click('settings-display');await tick();d.resolve(failure(401));await tick();assert.deepEqual(b.redirects,[]);assert.ok(b.sockets.every(s=>!s.closed));
|
||||
const count=posts(b).length;b.queues[path].push(json(fixture()));b.click('settings-broker');await tick();assert.equal(posts(b).length,count);assert.equal(n(b,'target').value,'');
|
||||
}
|
||||
const b=await open();b.queues[path].push(failure(401));b.click('broker-refresh');await tick();assert.deepEqual(b.redirects,['/login']);assert.ok(b.sockets.every(s=>s.closed));
|
||||
});
|
||||
await test('Broker pagehide/expiry/logout clear selected identities and stop checks without cancellation claims',async()=>{
|
||||
for(const action of ['pagehide','expiry','logout']){
|
||||
const b=await open();await submit(b);
|
||||
if(action==='pagehide')b.emit('pagehide');else if(action==='expiry')b.window.sakSessionExpired();else{b.queues['/api/logout'].push(new Response(null,{status:204}));b.click('sign-out');}
|
||||
await tick();assert.equal(n(b,'target').value,'');assert.equal(n(b,'values').textContent,'');assert.equal(posts(b).length,1);assert.ok(![...b.timers.values()].some(t=>t.ms===1000||t.ms===15000));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
|
||||
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
|
||||
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
|
||||
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': []};
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': [], '/api/settings/lifecycle': [], '/api/settings/lifecycle-operation': []};
|
||||
const fits = [];
|
||||
let serial = 0, now = Date.now();
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
@@ -30,7 +30,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
|
||||
}
|
||||
class Terminal {
|
||||
constructor(options) { this.options = options; this.writes = []; terminals.push(this); }
|
||||
loadAddon() {} open() {} focus() {} resize(cols, rows) { this.cols = cols; this.rows = rows; } onData(fn) { this.input = fn; }
|
||||
loadAddon() {} open(host) { this.host = host; this.focusCalls = 0; } focus() { ++this.focusCalls; this.host.focus(); } resize(cols, rows) { this.cols = cols; this.rows = rows; } onData(fn) { this.input = fn; }
|
||||
write(bytes, callback) { this.writes.push([...bytes]); if (this.holdWrites) (this.pending ||= []).push(callback); else callback?.(); }
|
||||
}
|
||||
const window = {confirm: () => true, addEventListener: on, removeEventListener(k, fn) { events[k] = (events[k] || []).filter(f => f !== fn); },
|
||||
@@ -48,7 +48,10 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
|
||||
appendChild(child) { this.children.push(child); child.parentNode = this; return child; }
|
||||
setAttribute(k, v) { this[k] = v; }
|
||||
getBoundingClientRect() { return {width: 100, height: 100}; }
|
||||
addEventListener(k, fn) { this[k] = fn; }
|
||||
addEventListener(k, fn) { this[k === 'focus' ? 'onfocus' : k] = fn; }
|
||||
focus() { context.document.activeElement = this; this.onfocus?.(); }
|
||||
contains(node) { return node === this || this.children.some(child => child.contains(node)); }
|
||||
matches() { return !!this.hovered; }
|
||||
}
|
||||
const context = vm.createContext({window, document: {
|
||||
createElement: tag => new Element(tag),
|
||||
@@ -80,13 +83,128 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
|
||||
const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`);
|
||||
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
|
||||
};
|
||||
return {nodes, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
|
||||
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
|
||||
click: id => nodes[id].click(), elapse: ms => { now += ms; }, window};
|
||||
}
|
||||
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
|
||||
let passed = 0;
|
||||
async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', name); }
|
||||
(async () => {
|
||||
await test('Quick Serial hover/focus/tap reuses one form, opens read-only and preserves socket/lease and binary drains', async () => {
|
||||
const b = browser({role: 'admin'}); b.start(); await tick();
|
||||
const trigger = b.nodes['quick-serial'], host = b.nodes['serial-settings'];
|
||||
host.appendChild(b.nodes['quick-close']);
|
||||
trigger.pointerenter({pointerType: 'touch'}); await tick();
|
||||
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, 0);
|
||||
trigger.pointerenter({pointerType: 'mouse'}); await tick();
|
||||
assert.equal(host['data-quick'], 'true'); assert.equal(trigger['aria-expanded'], 'true');
|
||||
assert.equal(host.role, 'dialog'); assert.equal(b.nodes.terminal.hidden, false);
|
||||
const reads = b.calls.filter(c => c.url === '/api/settings/serial').length;
|
||||
trigger.focus(); b.click('quick-serial'); await tick();
|
||||
assert.equal(b.document.activeElement, b.nodes['quick-close']);
|
||||
assert.equal(b.calls.filter(c => c.url === '/api/settings/serial').length, reads);
|
||||
trigger.pointerleave(); b.fire(250); assert.equal(host['data-quick'], 'true');
|
||||
assert.equal(b.calls.filter(c => c.url.includes('settings') && c.method === 'POST').length, 0);
|
||||
const ws = b.sockets[0]; ws.emit('open'); ws.emit('message', {data: JSON.stringify({type:'hello',clientId:8,writerId:8,role:'writer'})});
|
||||
b.terminals[0].input('not serial input'); assert.equal(ws.sent.length, 0);
|
||||
ws.emit('message', {data: Uint8Array.of(0,255).buffer}); assert.deepEqual(b.terminals[0].writes.at(-1), [0,255]);
|
||||
b.nodes['edit-baud'].value = '999';
|
||||
b.emit('keydown', {key:'Escape',preventDefault(){},stopPropagation(){}});
|
||||
assert.equal(host['data-quick'], 'false'); assert.equal(b.document.activeElement, trigger);
|
||||
assert.equal(b.nodes['edit-baud'].value, ''); assert.equal(trigger['aria-expanded'], 'false');
|
||||
trigger.pointerenter({pointerType:'mouse'}); await tick(); assert.equal(host['data-quick'],'false');
|
||||
b.terminals[0].input('x'); assert.equal(ws.sent.length, 1); assert.ok(!ws.closed);
|
||||
});
|
||||
await test('Quick adjacent trigger keyboard focus survives switching without focusing either terminal', async () => {
|
||||
const b = browser({role:'admin'}); b.start(); await tick();
|
||||
b.nodes['quick-network'].focus(); await tick();
|
||||
const term = b.terminals[0], before = term.focusCalls;
|
||||
b.nodes['quick-network'].focusout(); b.nodes['quick-serial'].focus(); await tick();
|
||||
assert.equal(b.document.activeElement,b.nodes['quick-serial']);
|
||||
assert.equal(b.nodes['quick-serial']['aria-expanded'],'true');
|
||||
assert.equal(b.nodes['quick-network']['aria-expanded'],'false');
|
||||
assert.equal(term.focusCalls,before);
|
||||
b.nodes['quick-serial'].focusout(); b.nodes['quick-network'].focus(); await tick();
|
||||
assert.equal(b.document.activeElement,b.nodes['quick-network']);
|
||||
assert.equal(term.focusCalls,before);
|
||||
});
|
||||
await test('Quick automatic dismissal restores Serial/Admin state without stealing outside focus; Close/Escape focus only trigger', async () => {
|
||||
for (const mode of ['serial','admin']) {
|
||||
const b = browser({role:'admin'}); b.start(); await tick();
|
||||
if (mode === 'admin') b.click('select-admin');
|
||||
const terminal = b.terminals[mode === 'admin' ? 1 : 0];
|
||||
b.nodes['quick-serial'].focus(); await tick();
|
||||
const before = b.terminals.map(t => t.focusCalls);
|
||||
b.nodes['quick-serial'].focusout(); b.nodes['connection-toggle'].focus();
|
||||
b.fire(250); await tick();
|
||||
assert.equal(b.document.activeElement,b.nodes['connection-toggle']);
|
||||
assert.deepEqual(b.terminals.map(t => t.focusCalls),before);
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'],'false');
|
||||
assert.equal(b.nodes['select-' + mode]['aria-pressed'],'true');
|
||||
assert.equal(terminal.host.hidden,false);
|
||||
b.click('quick-serial'); await tick(); b.nodes['connection-toggle'].focus();
|
||||
b.emit('pointerdown',{target:b.nodes['connection-toggle']});
|
||||
assert.equal(b.document.activeElement,b.nodes['connection-toggle']);
|
||||
assert.deepEqual(b.terminals.map(t => t.focusCalls),before);
|
||||
for (const dismiss of ['close','escape']) {
|
||||
b.click('quick-serial'); await tick();
|
||||
if (dismiss === 'close') b.click('quick-close');
|
||||
else b.emit('keydown',{key:'Escape',preventDefault(){},stopPropagation(){}});
|
||||
assert.equal(b.document.activeElement,b.nodes['quick-serial']);
|
||||
assert.deepEqual(b.terminals.map(t => t.focusCalls),before);
|
||||
}
|
||||
assert.ok(b.sockets.every(s => !s.closed));
|
||||
}
|
||||
});
|
||||
await test('Quick dismissal fences late snapshots, hover bridge/outside click and expiry without replay', async () => {
|
||||
const b = browser({role:'admin'}); b.start(); await tick();
|
||||
const late = deferred(); b.queues['/api/settings/serial'].push(late.promise);
|
||||
b.nodes['quick-serial'].pointerenter({pointerType:'mouse'}); await tick();
|
||||
const request = b.calls.find(c => c.url === '/api/settings/serial');
|
||||
b.nodes['quick-serial'].pointerleave(); b.nodes['serial-settings'].hovered = true;
|
||||
b.fire(250); assert.equal(b.nodes['serial-settings']['data-quick'], 'true');
|
||||
b.emit('pointerdown', {target:b.nodes['connection-toggle']});
|
||||
assert.ok(request.signal.aborted); late.resolve(json(serialSettings())); await tick();
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'false'); assert.equal(b.nodes['edit-baud'].value, '');
|
||||
b.click('quick-serial'); await tick(); b.emit('pagehide');
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'false'); assert.ok(b.sockets.every(s => s.closed));
|
||||
const count = b.calls.length; b.click('quick-serial'); await tick(); assert.equal(b.calls.length,count);
|
||||
});
|
||||
await test('Quick full-page promotion preserves the sole draft/controller and full-page hover cannot discard drafts', async () => {
|
||||
const b = browser({role:'admin'}); b.start(); await tick(); b.click('quick-serial'); await tick();
|
||||
b.nodes['edit-baud'].value = '115200'; const reads = b.calls.length;
|
||||
b.nodes['quick-full'].click({preventDefault(){}});
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'false'); assert.equal(b.nodes['edit-baud'].value, '115200');
|
||||
b.nodes['quick-network'].pointerenter({pointerType:'mouse'}); await tick();
|
||||
assert.equal(b.calls.length,reads); assert.equal(b.nodes['edit-baud'].value, '115200');
|
||||
const u = await connected(); u.click('quick-serial'); u.nodes['quick-network'].focus(); await tick();
|
||||
assert.ok(!u.calls.some(c => c.url.startsWith('/api/settings/')));
|
||||
});
|
||||
await test('Quick Serial shares validation, explicit apply/save and pending result recovery without replay', async () => {
|
||||
const b = browser({role:'admin'}); b.start(); await tick(); b.click('quick-serial'); await tick();
|
||||
b.nodes['edit-baud'].value = 'bad'; b.click('serial-apply'); await tick();
|
||||
assert.ok(!b.calls.some(c => c.url.endsWith('serial-operation')));
|
||||
b.nodes['edit-baud'].value = '115200';
|
||||
b.queues['/api/settings/serial-operation'].push(new Response(JSON.stringify({id:4,action:'apply',state:'pending'}),{status:202}));
|
||||
b.click('serial-apply'); await tick(); b.click('quick-close'); await tick(); b.click('quick-serial'); await tick();
|
||||
assert.equal(b.calls.filter(c => c.url.endsWith('serial-operation') && c.method === 'POST').length,1);
|
||||
assert.ok(b.nodes['serial-apply'].disabled); assert.match(b.nodes['serial-operation-detail'].textContent,/uncertain|unknown|pending/);
|
||||
assert.ok(![...b.timers.values()].some(t => t.ms === 1000));
|
||||
});
|
||||
await test('Quick Save sends only the explicit persistence action, and active editor focus resists unrelated hover', async () => {
|
||||
const b = browser({role:'admin'}); b.start(); await tick(); b.click('quick-serial'); await tick();
|
||||
const host = b.nodes['serial-settings']; host.appendChild(b.nodes['edit-baud']);
|
||||
b.nodes['edit-baud'].value = '9600'; b.nodes['edit-baud'].focus();
|
||||
b.nodes['quick-network'].pointerenter({pointerType:'mouse'}); await tick();
|
||||
assert.equal(b.nodes['quick-serial']['aria-expanded'],'true'); assert.equal(b.nodes['edit-baud'].value,'9600');
|
||||
b.queues['/api/settings/serial-operation'].push(new Response(JSON.stringify({id:5,action:'save',state:'pending'}),{status:202}));
|
||||
b.click('serial-save'); await tick();
|
||||
const posts = b.calls.filter(c => c.url.endsWith('serial-operation') && c.method === 'POST');
|
||||
assert.equal(posts.length,1); assert.deepEqual(JSON.parse(posts[0].body),{action:'save'});
|
||||
b.nodes['quick-full'].click({preventDefault(){}}); await tick();
|
||||
assert.ok([...b.timers.values()].some(t => t.ms === 1000));
|
||||
assert.equal(b.calls.filter(c => c.url.endsWith('serial-operation') && c.method === 'POST').length,1);
|
||||
});
|
||||
await test('bootstrap, CSRF, bounded expiry safe text, serial protocol and disconnect pause', async () => {
|
||||
const b = await connected();
|
||||
assert.equal(b.calls[0].url, '/api/session');
|
||||
@@ -1252,5 +1370,8 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
});
|
||||
await require('./network.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./display.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./ssh.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./lifecycle.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
@@ -45,10 +45,21 @@ def check_layout(html):
|
||||
if cls in classes(node):
|
||||
return node
|
||||
raise AssertionError(cls)
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values'):
|
||||
for domain in ('serial', 'network', 'broker', 'writer'):
|
||||
trigger = ids['quick-' + domain]
|
||||
assert trigger['tag'] == 'button' and trigger['attrs']['type'] == 'button'
|
||||
assert trigger['attrs']['aria-controls'] == 'serial-settings'
|
||||
assert trigger['attrs']['aria-expanded'] == 'false'
|
||||
assert trigger['attrs']['aria-haspopup'] == 'dialog'
|
||||
assert trigger['attrs']['aria-label'] and 'disabled' in trigger['attrs']
|
||||
assert ids['quick-full']['tag'] == 'a' and ids['quick-full']['attrs']['href'] == '#serial-settings'
|
||||
assert 'hidden' in ids['quick-header']['attrs']
|
||||
assert ids['network-password']['parent'] is ids['network-password-label']
|
||||
assert ids['network-password-mode']['parent'] is ids['network-password-mode-label']
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values', 'ssh-values'):
|
||||
assert ids[ident]['tag'] == 'dl'
|
||||
assert 'settings-values' in classes(ids[ident])
|
||||
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings'):
|
||||
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings', 'ssh-settings', 'lifecycle-settings'):
|
||||
nodes = list(descendants(ids[ident]))
|
||||
assert not any(n['tag'] == 'pre' for n in nodes)
|
||||
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
|
||||
@@ -59,9 +70,9 @@ def check_layout(html):
|
||||
ancestor(n, 'settings-edit')
|
||||
except AssertionError:
|
||||
ancestor(n, 'serial-edit')
|
||||
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh'):
|
||||
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh', 'ssh-refresh', 'lifecycle-refresh'):
|
||||
assert ids[ident]['text'] == 'Refresh'
|
||||
for ident in ('serial-result', 'account-result', 'network-result', 'display-result'):
|
||||
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result', 'ssh-result', 'lifecycle-result'):
|
||||
assert ids[ident]['text'] == 'Check Operation Result'
|
||||
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
|
||||
assert 'settings-check' in classes(ids[ident]['parent'])
|
||||
@@ -90,7 +101,7 @@ def check_layout(html):
|
||||
):
|
||||
assert rule in css, rule
|
||||
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
|
||||
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all four settings views')
|
||||
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all seven settings views')
|
||||
|
||||
|
||||
def check_browser_layout(html, tmp, executable):
|
||||
@@ -103,8 +114,10 @@ def check_browser_layout(html, tmp, executable):
|
||||
fixture = re.sub(r'<link\b[^>]*>|<img\b[^>]*>', '', fixture)
|
||||
probe = r'''
|
||||
const cases = [];
|
||||
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) {
|
||||
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = '900px';
|
||||
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings']) for (const quick of [false, true]) {
|
||||
if (quick && !['serial-settings-content', 'network-settings'].includes(view)) continue;
|
||||
const height = quick ? 360 : 900;
|
||||
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = height + 'px';
|
||||
cases.push(new Promise(resolve => {
|
||||
frame.onload = () => {
|
||||
const d = frame.contentDocument, win = frame.contentWindow;
|
||||
@@ -122,6 +135,17 @@ def check_browser_layout(html, tmp, executable):
|
||||
});
|
||||
section.querySelectorAll('input:not([type=checkbox]),textarea').forEach(n => n.value = 'x'.repeat(96));
|
||||
const errors = [];
|
||||
if (quick) {
|
||||
const host = d.getElementById('serial-settings'); host.dataset.quick = 'true';
|
||||
d.getElementById('quick-header').hidden = false;
|
||||
const rect = host.getBoundingClientRect();
|
||||
if (rect.left < 0 || rect.top < 0 || rect.right > width || rect.bottom > height) errors.push('popover viewport overflow');
|
||||
if (host.scrollWidth > host.clientWidth + 1) errors.push('popover horizontal overflow');
|
||||
if (win.getComputedStyle(host).overflowY !== 'auto') errors.push('popover not scrollable');
|
||||
for (const id of ['settings-navigation', 'network-password-mode-label', 'network-password-label', 'network-password-status']) {
|
||||
if (d.getElementById(id).getBoundingClientRect().width) errors.push('quick hidden content:' + id);
|
||||
}
|
||||
}
|
||||
for (const node of section.querySelectorAll('dl,input,select,textarea,.settings-edit,.serial-edit,.serial-actions')) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (!rect.width) continue;
|
||||
@@ -132,7 +156,7 @@ def check_browser_layout(html, tmp, executable):
|
||||
}
|
||||
// Measure glyph ranges, not the full-width grid cell or textContent.
|
||||
// The normal-whitespace control proves the pair detects collapsing.
|
||||
for (const dd of section.querySelectorAll('dl dd:first-of-type')) {
|
||||
for (const dd of quick ? [] : section.querySelectorAll('dl dd:first-of-type')) {
|
||||
const original = dd.textContent;
|
||||
const measureSSID = spaces => {
|
||||
dd.textContent = 'SSID: ' + JSON.stringify('office' + ' '.repeat(spaces) + 'wifi');
|
||||
@@ -147,7 +171,7 @@ def check_browser_layout(html, tmp, executable):
|
||||
if (Math.abs(measureSSID(2) - measureSSID(1)) > 0.1) errors.push('invalid whitespace control');
|
||||
dd.style.removeProperty('white-space'); dd.textContent = original;
|
||||
}
|
||||
resolve({width, view, errors});
|
||||
resolve({width, height, view, quick, errors});
|
||||
};
|
||||
}));
|
||||
frame.srcdoc = FIXTURE; document.body.append(frame);
|
||||
@@ -166,6 +190,6 @@ def check_browser_layout(html, tmp, executable):
|
||||
assert result.returncode == 0, result.stderr
|
||||
parsed = Document(result.stdout)
|
||||
results = json.loads(parsed.ids['layout-results']['text'])
|
||||
assert len(results) == 12
|
||||
assert len(results) == 18
|
||||
assert all(not case['errors'] for case in results), results
|
||||
print('PASS Chromium layout: all four settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')
|
||||
print('PASS Chromium layout: four full views plus Serial/Wi-Fi popovers at 320/600/1200px (quick height360); viewport/overflow/secret exclusion and full-page whitespace checks (fixtures, not live app)')
|
||||
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
|
||||
const path = '/api/settings/lifecycle', op = path + '-operation';
|
||||
const fixture = (extra = {}) => ({generation:7,running:true,transitioning:false,controllable:true,identity_generation:11,fingerprint:'ab'.repeat(32),rotatable:true,...extra});
|
||||
const reply = (state='pending', id=42, status=200, action='stop') => new Response(JSON.stringify({id,action,state}), {status});
|
||||
const n = (b,id) => b.nodes['lifecycle-'+id], posts = b => b.calls.filter(c=>c.url===op && c.method==='POST');
|
||||
async function open(v=fixture()) { const b=await adminBrowser(); b.click('select-settings'); await tick(); b.queues[path].push(json(v)); b.click('settings-lifecycle'); await tick(); return b; }
|
||||
async function refresh(b,v=fixture()) { b.queues[path].push(json(v)); b.click('lifecycle-refresh'); await tick(); }
|
||||
async function submit(b,action='stop') { b.queues[op].push(reply('pending',42,202,action)); b.click('lifecycle-'+action); await tick(); }
|
||||
await test('Lifecycle admin-only view and existing Network link are read-only and preserve both terminal drains', async()=>{
|
||||
const u=browser(); u.start(); await tick(); u.click('settings-lifecycle'); await tick(); assert.ok(!u.calls.some(c=>c.url===path));
|
||||
const b=await open(); assert.equal(b.nodes['lifecycle-settings'].hidden,false); assert.equal(posts(b).length,0);
|
||||
for(let i=0;i<2;++i) { b.sockets[i].emit('message',{data:Uint8Array.of(0,255,i).buffer}); assert.deepEqual(b.terminals[i].writes.at(-1),[0,255,i]); b.terminals[i].input('blocked'); assert.equal(b.sockets[i].sent.length,0); }
|
||||
b.click('lifecycle-network'); await tick(); assert.equal(b.nodes['lifecycle-settings'].hidden,true); assert.equal(b.nodes['network-settings'].hidden,false);
|
||||
assert.equal(posts(b).length,0); assert.ok(!b.calls.some(c=>c.url==='/api/settings/network-operation'&&c.method==='POST'));
|
||||
assert.match(html,/USB remains UART1 serial access, not a web administration console/); assert.match(html,/Native USB preserves network-independent UART1 serial access, not Wi-Fi administration/);
|
||||
});
|
||||
await test('Lifecycle confirmations name all-client loss/reboot USB interruption and submit exact generation once',async()=>{
|
||||
for(const action of ['stop','restart','reboot']) {
|
||||
const b=await open(); let confirmation=''; b.window.confirm=s=>{confirmation=s;return false;}; b.click('lifecycle-'+action); await tick(); assert.equal(posts(b).length,0);
|
||||
assert.match(confirmation,/ALL/); assert.match(confirmation,/unsaved|Unsaved/); assert.match(confirmation,/UART0/); assert.match(confirmation,/no automatic retry/);
|
||||
assert.match(confirmation,action==='reboot'?/USB and UART operation are interrupted/:/BOTH browser terminal routes/);
|
||||
b.window.confirm=()=>true; await submit(b,action); assert.equal(posts(b).length,1); assert.deepEqual(JSON.parse(posts(b)[0].body),{action,generation:7});
|
||||
assert.equal(posts(b)[0].headers['X-CSRF-Token'],'a'.repeat(64)); b.click('lifecycle-'+action); await tick(); assert.equal(posts(b).length,1);
|
||||
assert.match(n(b,'operation-detail').textContent,/do not resubmit/); assert.ok(![...b.timers.values()].some(t=>t.ms===1000 && !t.interval));
|
||||
b.queues[op].push(reply('ok',42,200,action)); b.click('lifecycle-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/not proof of peer receipt/);
|
||||
assert.ok(n(b,'stop').disabled); await refresh(b); assert.equal(n(b,'stop').disabled,false); assert.equal(posts(b).length,1);
|
||||
}
|
||||
});
|
||||
await test('HTTPS identity rotation confirms public fingerprint and both generations, shares pending gate, and requires renewed trust/login',async()=>{
|
||||
const b=await open(); let confirmation=''; b.window.confirm=s=>{confirmation=s;return false;};
|
||||
b.click('lifecycle-rotate'); await tick(); assert.equal(posts(b).length,0);
|
||||
for(const text of ['ab'.repeat(32),'generation 11','generation 7','UART0','trust','ALL','SSH','USB','sign in freshly']) assert.ok(confirmation.includes(text),text);
|
||||
assert.match(n(b,'identity').textContent,/Stored HTTPS identity generation 11/);
|
||||
b.window.confirm=()=>true; await submit(b,'rotate');
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'rotate',generation:7,identity_generation:11});
|
||||
b.click('lifecycle-stop'); b.click('lifecycle-rotate'); await tick(); assert.equal(posts(b).length,1);
|
||||
b.queues[op].push(reply('failed',42,200,'rotate')); b.click('lifecycle-result'); await tick();
|
||||
assert.match(n(b,'operation-detail').textContent,/identity may already be persisted/);
|
||||
assert.match(n(b,'operation-detail').textContent,/No rollback/);
|
||||
assert.ok(n(b,'rotate').disabled); await refresh(b); assert.equal(n(b,'rotate').disabled,false);
|
||||
assert.equal(posts(b).length,1);
|
||||
});
|
||||
await test('HTTPS identity metadata rejects missing generations, secrets and malformed fingerprints; unavailable identity does not disable ordinary service controls',async()=>{
|
||||
for(const v of [fixture({identity_generation:undefined}),fixture({identity_generation:0}),fixture({identity_generation:4294967295}),fixture({fingerprint:'<script>'}),fixture({fingerprint:'a'.repeat(65)}),fixture({private_key:'secret'})]) {
|
||||
const b=await open(v); b.click('lifecycle-rotate'); await tick(); assert.ok(n(b,'rotate').disabled); assert.equal(posts(b).length,0);
|
||||
assert.doesNotMatch(n(b,'identity').textContent,/script|secret/);
|
||||
}
|
||||
const b=await open(fixture({identity_generation:0,fingerprint:'',rotatable:false}));
|
||||
assert.ok(n(b,'rotate').disabled); assert.equal(n(b,'stop').disabled,false);
|
||||
assert.doesNotMatch(html,/id="lifecycle-reset"/);
|
||||
});
|
||||
await test('HTTPS rotation lost ACK, role loss and post-admission login expiry never replay or restore',async()=>{
|
||||
const b=await open(); b.queues[op].push(()=>{throw Error('lost');}); b.click('lifecycle-rotate'); await tick();
|
||||
b.queues[op].push(reply('ok',42,200,'rotate')); b.click('lifecycle-result'); await tick();
|
||||
await refresh(b); assert.ok(n(b,'rotate').disabled && n(b,'stop').disabled); assert.equal(posts(b).length,1);
|
||||
b.click('select-serial'); b.queues[path].push(json(fixture())); b.click('select-settings'); await tick(); assert.equal(posts(b).length,1);
|
||||
const denied=await open(); denied.queues['/api/session'].push(session({role:'user'})); denied.click('lifecycle-rotate'); await tick(); assert.equal(posts(denied).length,0);
|
||||
const expired=await open(); await submit(expired,'rotate'); expired.queues[op].push(failure(401)); expired.click('lifecycle-result'); await tick();
|
||||
assert.deepEqual(expired.redirects,['/login']); assert.ok(expired.sockets.every(s=>s.closed));
|
||||
const fresh=await open(fixture({identity_generation:12,generation:10})); assert.equal(posts(fresh).length,0); assert.ok(!fresh.calls.some(c=>c.url===op));
|
||||
});
|
||||
await test('Lifecycle bounded snapshot schema rejects unavailable malformed transitioning saturated and contradictory state',async()=>{
|
||||
for(const v of [{},fixture({generation:0}),fixture({generation:4294967296}),fixture({running:1}),fixture({controllable:1}),fixture({extra:true}),fixture({transitioning:true}),fixture({running:false}),fixture({generation:4294967295})]) {
|
||||
const b=await open(v); assert.ok(n(b,'stop').disabled && n(b,'restart').disabled && n(b,'reboot').disabled); assert.equal(posts(b).length,0);
|
||||
}
|
||||
for(const v of [fixture({transitioning:true,controllable:false,rotatable:false}),fixture({generation:4294967295,controllable:false,rotatable:false})]) {const b=await open(v);assert.ok(n(b,'stop').disabled);}
|
||||
const b=await open(); b.queues[path].push(failure(503)); b.click('lifecycle-refresh'); await tick(); assert.ok(n(b,'stop').disabled); assert.equal(b.sockets.length,2);
|
||||
});
|
||||
await test('Lifecycle captures confirmation before delayed original-session validation and gates double click',async()=>{
|
||||
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); b.queues[op].push(reply('pending',42,202)); b.click('lifecycle-stop'); await tick();
|
||||
b.click('lifecycle-reboot'); b.click('lifecycle-refresh'); await tick(); assert.equal(posts(b).length,0);
|
||||
d.resolve(session({role:'admin'})); await tick(); assert.equal(posts(b).length,1); assert.equal(JSON.parse(posts(b)[0].body).generation,7);
|
||||
for(const state of ['failed','cancelled']) {b.queues[op].push(reply(state));b.click('lifecycle-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='failed'?/may already be persisted/:/before lifecycle admission/);}
|
||||
assert.equal(posts(b).length,1);
|
||||
});
|
||||
await test('Lifecycle lost ACK/replaced result never clears pending or adopts old action results',async()=>{
|
||||
for(const lost of [true,false]) {
|
||||
const b=await open(); if(lost) { b.queues[op].push(()=>{throw Error('lost');}); b.click('lifecycle-stop'); await tick(); } else await submit(b);
|
||||
b.queues[op].push(reply('ok',lost?42:43)); b.click('lifecycle-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/cannot be matched/);
|
||||
await refresh(b); assert.ok(n(b,'stop').disabled); b.click('lifecycle-reboot'); await tick(); assert.equal(posts(b).length,1);
|
||||
b.click('select-serial'); b.queues[path].push(json(fixture())); b.click('select-settings'); await tick(); assert.ok(n(b,'stop').disabled); assert.equal(posts(b).length,1);
|
||||
}
|
||||
const b=await open(); await submit(b); b.queues[op].push(reply('ok',42,200,'reboot')); b.click('lifecycle-result'); await tick(); assert.match(n(b,'operation-detail').textContent,/cannot be matched/); assert.ok(n(b,'stop').disabled);
|
||||
});
|
||||
await test('Lifecycle whole-request deadlines fence late session/read/ACK/body completion without mutation retry',async()=>{
|
||||
for(const stage of ['session','snapshot','ack','body']) {
|
||||
const b=await open(), d=deferred();
|
||||
if(stage==='session') b.queues['/api/session'].push(d.promise);
|
||||
if(stage==='snapshot') b.queues[path].push(d.promise);
|
||||
if(stage==='ack') b.queues[op].push(d.promise);
|
||||
if(stage==='body') b.queues[op].push({status:202,ok:true,headers:new Headers(),body:{getReader:()=>({read:()=>d.promise,cancel:async()=>{}})}});
|
||||
b.click(stage==='snapshot'?'lifecycle-refresh':'lifecycle-stop'); await tick(); b.fire(15000); await tick(); assert.match(n(b,stage==='snapshot'?'detail':'operation-detail').textContent,/timed out/);
|
||||
d.resolve(stage==='session'?session({role:'admin'}):stage==='snapshot'?json(fixture({generation:99})):stage==='body'?{done:true}:reply('pending',42,202)); await tick();
|
||||
assert.equal(posts(b).length,stage==='session'||stage==='snapshot'?0:1); assert.ok(n(b,'stop').disabled); assert.match(n(b,stage==='snapshot'?'detail':'operation-detail').textContent,/timed out/);
|
||||
}
|
||||
});
|
||||
await test('Lifecycle navigation/pagehide fences late reads/results/401 and never restores a mutation',async()=>{
|
||||
for(const pagehide of [false,true]) {
|
||||
const b=await open(), d=deferred(); b.queues[op].push(d.promise); b.click('lifecycle-stop'); await tick();
|
||||
if(pagehide) b.emit('pagehide'); else b.click('settings-serial');
|
||||
d.resolve(failure(401)); await tick(); assert.equal(b.redirects.length,0); assert.equal(posts(b).length,1);
|
||||
if(pagehide) {b.emit('pageshow');await tick();} else {b.queues[path].push(json(fixture()));b.click('settings-lifecycle');await tick();}
|
||||
assert.equal(posts(b).length,1); assert.ok(n(b,'stop').disabled);
|
||||
}
|
||||
});
|
||||
await test('Lifecycle original-login switch/revocation denies mutation; fresh login document has no old result restore',async()=>{
|
||||
for(const response of [failure(401),session({role:'admin',username:'another'}),session({role:'admin',csrf:'b'.repeat(64)})]) {
|
||||
const b=await open(); b.queues['/api/session'].push(response); b.click('lifecycle-reboot'); await tick(); assert.equal(posts(b).length,0); assert.ok(b.sockets.every(s=>s.closed));
|
||||
}
|
||||
const b=await open(); b.queues[op].push(failure(401)); b.click('lifecycle-stop'); await tick(); assert.equal(posts(b).length,1); assert.deepEqual(b.redirects,['/login']);
|
||||
const fresh=await open(); assert.equal(posts(fresh).length,0); assert.equal(n(fresh,'stop').disabled,false);
|
||||
});
|
||||
await test('Lifecycle accepted restart followed by expired login closes both routes without replay or result restore',async()=>{
|
||||
const b=await open(); await submit(b,'restart');
|
||||
b.queues[op].push(failure(401)); b.click('lifecycle-result'); await tick();
|
||||
assert.equal(posts(b).length,1); assert.deepEqual(b.redirects,['/login']);
|
||||
assert.ok(b.sockets.every(s=>s.closed));
|
||||
const fresh=await open(fixture({generation:9}));
|
||||
assert.equal(posts(fresh).length,0);
|
||||
assert.ok(!fresh.calls.some(c=>c.url===op));
|
||||
assert.equal(n(fresh,'restart').disabled,false);
|
||||
});
|
||||
await test('Lifecycle malformed/oversized result and busy response retain uncertainty with safe error text',async()=>{
|
||||
for(const response of [failure(503),new Response('x'.repeat(97),{status:202}),reply('ok',42,202),reply('pending',0,202),new Response(JSON.stringify({id:42,action:'stop',state:'pending',secret:'bad'}),{status:202})]) {
|
||||
const b=await open(); b.queues[op].push(response); b.click('lifecycle-stop'); await tick(); assert.equal(posts(b).length,1); assert.ok(n(b,'stop').disabled); assert.doesNotMatch(n(b,'operation-detail').textContent,/SECRET ERROR BODY|secret|bad/); assert.match(n(b,'operation-detail').textContent,/Outcome may be unknown/);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -28,6 +28,34 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
|
||||
b.queues[operation].push(reply(42, state, action, 200, error)); b.queues[path].push(json(value));
|
||||
b.fire(1000); await tick();
|
||||
}
|
||||
await test('Quick Wi-Fi uses shared strict snapshots/nonsecret edits and explicit save, never cached passwords', async () => {
|
||||
const b = await adminBrowser(); b.queues[path].push(json(fixture()));
|
||||
b.click('quick-network'); await tick();
|
||||
assert.equal(b.nodes['serial-settings']['data-quick'], 'true');
|
||||
assert.equal(reads(b).length,1); assert.equal(posts(b).length,0); clean(b);
|
||||
assert.ok(n(b,'password-mode').disabled);
|
||||
target(b,'0'); assert.equal(posts(b).length,0); clean(b);
|
||||
input(b,'ssid','new network');
|
||||
b.queues[operation].push(ack('profile-patch')); b.click('network-apply'); await tick();
|
||||
assert.equal(posts(b).length,1);
|
||||
const body = JSON.parse(posts(b)[0].body); assert.equal(body.action,'profile-patch');
|
||||
assert.ok(!Object.hasOwn(body,'password')); clean(b);
|
||||
b.click('quick-close'); await tick(); assert.equal(n(b,'ssid').value,''); clean(b);
|
||||
b.queues[path].push(json(fixture())); b.click('quick-network'); await tick();
|
||||
assert.equal(posts(b).length,1); assert.ok(n(b,'apply').disabled);
|
||||
assert.match(n(b,'operation-detail').textContent,/pending|unknown|uncertain/);
|
||||
});
|
||||
await test('Quick Network rejects extra secret fields, fences401 and keeps full-page secret draft out of hover', async () => {
|
||||
const b = await open(); secret(b); const count = b.calls.length;
|
||||
b.nodes['quick-serial'].pointerenter({pointerType:'mouse'}); await tick();
|
||||
assert.equal(b.calls.length,count); assert.equal(n(b,'password').value,'a safe PSK');
|
||||
b.click('select-serial'); clean(b);
|
||||
b.queues[path].push(json({...fixture(), password:'SECRET'})); b.click('quick-network'); await tick();
|
||||
assert.equal(n(b,'edit').hidden,true); safe(b); clean(b);
|
||||
b.click('quick-close'); b.queues[path].push(failure(401)); b.click('quick-network'); await tick();
|
||||
assert.deepEqual(b.redirects,['/login']); assert.equal(b.nodes['serial-settings']['data-quick'],'false'); clean(b);
|
||||
assert.ok(b.sockets.every(s => s.closed));
|
||||
});
|
||||
await test('Network authored HTML has actual AP/four profiles/mDNS controls and truthful persistence/recovery policy', async () => {
|
||||
for (const id of ['settings-network','network-target','network-ssid','network-ssid-mode','network-apply','network-result','network-wifi-save','network-wifi-load','network-start','network-stop','network-reconnect','network-next-profile','network-mdns-set','network-mdns-save','network-mdns-load','network-mdns-defaults']) assert.ok(html.includes('id="' + id + '"'), id);
|
||||
for (let i = 0; i < 4; i++) assert.ok(html.includes('value="' + i + '">STA profile ' + i));
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
|
||||
const path='/api/settings/ssh', op=path+'-operation';
|
||||
const row=(id=9, extra={})=>({id,state:2,route:1,name_hex:'3c696d673e',closing:false,...extra});
|
||||
const fixture=(extra={})=>({generation:7,running:true,transitioning:false,identity_generation:3,algorithm:'ecdsa-sha2-nistp256',fingerprint:'SHA256:'+'A'.repeat(43),rotatable:!extra.transitioning&&extra.generation!==4294967295,sessions:[row(),row(10,{route:2})],...extra});
|
||||
const reply=(state='pending',id=42,status=200,action='disconnect')=>new Response(JSON.stringify({id,action,state}),{status});
|
||||
const n=(b,id)=>b.nodes['ssh-'+id], posts=b=>b.calls.filter(c=>c.url===op&&c.method==='POST');
|
||||
async function open(v=fixture()) {const b=await adminBrowser();b.click('select-settings');await tick();b.queues[path].push(json(v));b.click('settings-ssh');await tick();return b;}
|
||||
function select(b,id=9){n(b,'target').value=String(id);n(b,'target').change();}
|
||||
async function refresh(b,v=fixture()){b.queues[path].push(json(v));b.click('ssh-refresh');await tick();}
|
||||
async function submit(b,action='disconnect'){if(action==='disconnect')select(b);b.window.confirm=()=>true;b.queues[op].push(reply('pending',42,202,action));b.click('ssh-'+action);await tick();}
|
||||
await test('SSH admin-only view, safe rows, no navigation/selection mutation and both terminal isolation',async()=>{
|
||||
const u=browser();u.start();await tick();u.click('settings-ssh');await tick();assert.equal(u.calls.filter(c=>c.url===path).length,0);
|
||||
const b=await open();assert.equal(b.nodes['ssh-settings'].hidden,false);
|
||||
assert.equal(n(b,'values').children[8].textContent,'9 / Serial / <img>');select(b);assert.equal(posts(b).length,0);
|
||||
for(let i=0;i<2;++i){b.sockets[i].emit('message',{data:Uint8Array.of(0,255,i).buffer});assert.deepEqual(b.terminals[i].writes.at(-1),[0,255,i]);b.terminals[i].input('blocked');assert.equal(b.sockets[i].sent.length,0);}
|
||||
assert.match(html,/Stop closes all SSH sessions/);assert.match(html,/HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped/);
|
||||
b.click('settings-broker');await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(posts(b).length,0);
|
||||
});
|
||||
await test('SSH exact confirmed start stop targeted disconnect requests and manual bounded result flow',async()=>{
|
||||
for(const action of ['start','stop','disconnect']){
|
||||
const b=await open(fixture({running:action!=='start'}));if(action==='disconnect')select(b);
|
||||
let confirmation='';b.window.confirm=text=>{confirmation=text;return false;};b.click('ssh-'+action);await tick();assert.equal(posts(b).length,0);
|
||||
assert.match(confirmation,/Settings and host identity are unchanged/);assert.match(confirmation,action==='stop'?/ALL SSH sessions/:action==='start'?/port 22/:/only SSH session 9/);
|
||||
await submit(b,action);assert.deepEqual(JSON.parse(posts(b)[0].body),{action,generation:7,target:action==='disconnect'?9:0});assert.equal(posts(b)[0].headers['X-CSRF-Token'],'a'.repeat(64));
|
||||
b.click('ssh-'+action);await tick();assert.equal(posts(b).length,1);assert.match(n(b,'operation-detail').textContent,/Check Operation Result/);
|
||||
b.queues[op].push(reply('ok',42,200,action));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/execution time/);assert.ok(n(b,'start').disabled&&n(b,'stop').disabled&&n(b,'disconnect').disabled);
|
||||
await refresh(b);assert.equal(posts(b).length,1);assert.equal(n(b,'target').value,'');
|
||||
}
|
||||
});
|
||||
await test('SSH host rotation confirms fingerprint and both generations, preserves HTTPS and never replays',async()=>{
|
||||
const b=await open(); let text=''; b.window.confirm=t=>{text=t;return false;}; b.click('ssh-rotate');await tick();assert.equal(posts(b).length,0);
|
||||
for(const pattern of [/SHA256:/,/identity generation 3/,/service generation 7/,/ALL SSH sessions/,/known_hosts/,/trusted UART0/,/ssh host-key info/,/HTTPS stays accessible/,/persistence fails/])assert.match(text,pattern);
|
||||
await submit(b,'rotate');assert.deepEqual(JSON.parse(posts(b)[0].body),{action:'rotate',generation:7,target:0,identity_generation:3});
|
||||
b.click('ssh-rotate');await tick();assert.equal(posts(b).length,1);assert.equal(b.sockets.length,2);
|
||||
b.queues[op].push(reply('failed',42,200,'rotate'));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/persisted even if restart failed/);
|
||||
await refresh(b,fixture({identity_generation:4}));assert.equal(posts(b).length,1);
|
||||
});
|
||||
await test('SSH host metadata malformed or unavailable fails closed for rotation only',async()=>{
|
||||
for(const extra of [{identity_generation:0,fingerprint:''},{identity_generation:4294967295},{fingerprint:'private-key'},{algorithm:'ssh-rsa'}]){
|
||||
const b=await open(fixture(extra));assert.ok(n(b,'rotate').disabled);b.click('ssh-rotate');await tick();assert.equal(posts(b).length,0);
|
||||
}
|
||||
const b=await open(fixture({identity_generation:0,fingerprint:'',rotatable:false}));assert.ok(n(b,'rotate').disabled);assert.equal(n(b,'stop').disabled,false);
|
||||
});
|
||||
await test('SSH refresh never rebases explicit identity or resurrects stale and absent selections',async()=>{
|
||||
for(const v of [fixture({generation:8}),fixture({sessions:[row(13)]}),fixture({sessions:[row(9,{closing:true})]}),fixture({sessions:[row(9,{name_hex:'61'})]}),fixture({transitioning:true})]){
|
||||
const b=await open();select(b);await refresh(b,v);assert.ok(n(b,'disconnect').disabled);await refresh(b);assert.ok(n(b,'disconnect').disabled);assert.equal(n(b,'target').value,'');
|
||||
b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);select(b);assert.equal(n(b,'disconnect').disabled,false);
|
||||
}
|
||||
const b=await open();select(b);await refresh(b,fixture({sessions:[row(10,{route:2}),row()]}));assert.equal(n(b,'target').value,'9');assert.equal(n(b,'disconnect').disabled,false);
|
||||
});
|
||||
await test('SSH invalid/unavailable/transition/exhausted snapshots fail closed without affecting terminals',async()=>{
|
||||
const invalid=[{},fixture({generation:0}),fixture({generation:4294967296}),fixture({running:1}),fixture({sessions:[row(),row()]}),fixture({sessions:[row(9,{name_hex:'zz'})]}),fixture({sessions:[row(9,{route:3})]}),fixture({sessions:[row(9,{id:0})]})];
|
||||
for(const v of invalid){const b=await open(v);assert.ok(n(b,'disconnect').disabled&&n(b,'stop').disabled&&n(b,'start').disabled);assert.match(n(b,'detail').textContent,/unavailable/);assert.equal(posts(b).length,0);}
|
||||
for(const extra of [{transitioning:true},{generation:4294967295}]){const b=await open(fixture(extra));assert.ok(n(b,'stop').disabled&&n(b,'start').disabled);}
|
||||
const b=await open();select(b);b.queues[path].push(failure(503));b.click('ssh-refresh');await tick();assert.ok(n(b,'disconnect').disabled);assert.equal(b.sockets.length,2);
|
||||
});
|
||||
await test('SSH single-flight whole-read deadline and navigation fence late snapshots without replay',async()=>{
|
||||
for(const stage of ['session','snapshot']){
|
||||
const b=await open(), d=deferred();select(b);b.queues[stage==='session'?'/api/session':path].push(d.promise);b.click('ssh-refresh');await tick();b.click('ssh-stop');b.click('ssh-refresh');await tick();assert.equal(posts(b).length,0);
|
||||
b.fire(15000);await tick();assert.match(n(b,'detail').textContent,/timed out/);await refresh(b);d.resolve(stage==='session'?session({role:'admin'}):json(fixture({generation:99})));await tick();select(b);await submit(b);assert.equal(JSON.parse(posts(b)[0].body).generation,7);
|
||||
}
|
||||
const b=await open(),d=deferred();b.queues[path].push(d.promise);b.click('ssh-refresh');await tick();b.click('settings-serial');await tick();d.resolve(json(fixture()));await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(n(b,'values').children.length,0);assert.equal(posts(b).length,0);
|
||||
});
|
||||
await test('SSH captures confirmation before delayed auth and handles conflict failed cancelled without replay',async()=>{
|
||||
for(const state of ['conflict','failed','cancelled']){
|
||||
const b=await open(),d=deferred();select(b);b.window.confirm=()=>true;b.queues['/api/session'].push(d.promise);b.queues[op].push(reply('pending',42,202));b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);d.resolve(session({role:'admin'}));await tick();assert.equal(JSON.parse(posts(b)[0].body).generation,7);
|
||||
b.queues[op].push(reply(state));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='conflict'?/No action admitted/:state==='failed'?/may still finish/:/Rejected before execution/);assert.equal(posts(b).length,1);
|
||||
}
|
||||
});
|
||||
await test('SSH lost acknowledgement/result replacement and timeout retain uncertainty across navigation',async()=>{
|
||||
const b=await open();select(b);b.window.confirm=()=>true;b.queues[op].push(()=>{throw Error('lost');});b.click('ssh-disconnect');await tick();assert.ok(n(b,'disconnect').disabled);b.click('select-serial');b.queues[path].push(json(fixture()));b.click('select-settings');await tick();assert.equal(posts(b).length,1);
|
||||
b.queues[op].push(reply('ok',41));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/Acknowledgement was lost/);
|
||||
b.queues[op].push(reply('ok',43));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/Previous result replaced/);
|
||||
await refresh(b);const d=deferred();select(b);b.queues[op].push(d.promise);b.click('ssh-disconnect');await tick();b.fire(15000);await tick();assert.match(n(b,'operation-detail').textContent,/timed out/);assert.ok(n(b,'stop').disabled);d.resolve(reply('pending',44,202));await tick();assert.match(n(b,'operation-detail').textContent,/timed out/);assert.equal(posts(b).length,2);
|
||||
});
|
||||
await test('SSH revoked session and pagehide cancel UI work without affecting newer context via late401',async()=>{
|
||||
const b=await open();select(b);b.queues['/api/session'].push(failure(401));b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);
|
||||
const c=await open(),d=deferred();c.queues[path].push(d.promise);c.click('ssh-refresh');await tick();c.emit('pagehide');d.resolve(failure(401));await tick();assert.equal(posts(c).length,0);assert.equal(n(c,'values').children.length,0);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user