Add broker management and writer transfer UI
This commit is contained in:
@@ -237,6 +237,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`.
|
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 and clears selection on Refresh. Native confirmation captures exact target/generation before session revalidation. Viewing/selecting never mutates; completion checks are bounded to10 one-second GETs/15seconds, no mutation replay. There are no contextual popovers or diagnostic/service controls. Complete contracts, wrap analysis, evidence and pending parent/target review: `docs/phase8d16_implementation.md`.
|
||||||
|
|
||||||
## Local UI and hardware boundaries
|
## 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.
|
`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.
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
|||||||
|
|
||||||
## Session broker
|
## 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.
|
**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}`
|
- Files: `src/session_broker.{h,c}`, `src/session_console.{h,c}`
|
||||||
@@ -59,6 +61,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
|||||||
|
|
||||||
## Web and WebSocket serial
|
## Web and WebSocket serial
|
||||||
|
|
||||||
|
- **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`.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do
|
|||||||
|
|
||||||
## Development state
|
## Development state
|
||||||
|
|
||||||
|
- **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.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.
|
- **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.
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ 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}`
|
**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.
|
||||||
|
|
||||||
|
**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
|
## 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.
|
**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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -188,7 +188,7 @@ Typed operations must preserve subsystem owner/lock/persistence contracts and co
|
|||||||
| **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.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.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.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** | 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.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.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.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. |
|
||||||
|
|||||||
@@ -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.
|
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
|
## 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.
|
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,7 @@ idf_component_register(
|
|||||||
"web_account_settings.c"
|
"web_account_settings.c"
|
||||||
"web_network_settings.c"
|
"web_network_settings.c"
|
||||||
"web_display_settings.c"
|
"web_display_settings.c"
|
||||||
|
"web_broker_settings.c"
|
||||||
"web_admin_tickets.c"
|
"web_admin_tickets.c"
|
||||||
"web_admin_transport.c"
|
"web_admin_transport.c"
|
||||||
"web_assets_data.c"
|
"web_assets_data.c"
|
||||||
|
|||||||
+17
-2
@@ -20,6 +20,7 @@
|
|||||||
#include "web_account_settings.h"
|
#include "web_account_settings.h"
|
||||||
#include "web_network_settings.h"
|
#include "web_network_settings.h"
|
||||||
#include "web_display_settings.h"
|
#include "web_display_settings.h"
|
||||||
|
#include "web_broker_settings.h"
|
||||||
|
|
||||||
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
|
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
|
||||||
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
|
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
|
||||||
@@ -89,6 +90,7 @@ typedef enum {
|
|||||||
ADMIN_REQUEST_ACCOUNT_SETTINGS,
|
ADMIN_REQUEST_ACCOUNT_SETTINGS,
|
||||||
ADMIN_REQUEST_NETWORK_SETTINGS,
|
ADMIN_REQUEST_NETWORK_SETTINGS,
|
||||||
ADMIN_REQUEST_DISPLAY_SETTINGS,
|
ADMIN_REQUEST_DISPLAY_SETTINGS,
|
||||||
|
ADMIN_REQUEST_BROKER_SETTINGS,
|
||||||
} admin_request_origin_t;
|
} admin_request_origin_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -103,6 +105,7 @@ typedef struct {
|
|||||||
uint32_t account_settings_id;
|
uint32_t account_settings_id;
|
||||||
uint32_t network_settings_id;
|
uint32_t network_settings_id;
|
||||||
uint32_t display_settings_id;
|
uint32_t display_settings_id;
|
||||||
|
uint32_t broker_settings_id;
|
||||||
};
|
};
|
||||||
} admin_request_t;
|
} admin_request_t;
|
||||||
|
|
||||||
@@ -698,6 +701,16 @@ 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;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
static void worker_task(void *context)
|
static void worker_task(void *context)
|
||||||
{
|
{
|
||||||
(void)context;
|
(void)context;
|
||||||
@@ -707,11 +720,13 @@ static void worker_task(void *context)
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS ||
|
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) {
|
||||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id);
|
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_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 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 web_broker_settings_execute(request.broker_settings_id);
|
||||||
secure_wipe(&request, sizeof(request));
|
secure_wipe(&request, sizeof(request));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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_account_settings(uint32_t id);
|
||||||
esp_err_t admin_ssh_console_submit_network_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_display_settings(uint32_t id);
|
||||||
|
esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id);
|
||||||
|
|
||||||
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
|
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
|
||||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||||
|
|||||||
+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 session_broker_client_id_t s_writer_id;
|
||||||
static uint32_t s_connected_clients;
|
static uint32_t s_connected_clients;
|
||||||
static uint64_t s_event_sequence;
|
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 session_broker_global_counters_t s_counters;
|
||||||
static bool s_initialized;
|
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 client_id,
|
||||||
session_broker_client_id_t writer_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 = {
|
session_broker_event_t event = {
|
||||||
.sequence = ++s_event_sequence,
|
.sequence = ++s_event_sequence,
|
||||||
.type = type,
|
.type = type,
|
||||||
@@ -299,7 +305,8 @@ esp_err_t session_broker_connect(session_broker_client_type_t type,
|
|||||||
session_broker_slot_t *slot = NULL;
|
session_broker_slot_t *slot = NULL;
|
||||||
size_t slot_index = 0U;
|
size_t slot_index = 0U;
|
||||||
for (; slot_index < SESSION_BROKER_MAX_CLIENTS; ++slot_index) {
|
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];
|
slot = &s_slots[slot_index];
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -319,9 +326,7 @@ esp_err_t session_broker_connect(session_broker_client_type_t type,
|
|||||||
}
|
}
|
||||||
|
|
||||||
uint32_t generation = slot->generation + 1U;
|
uint32_t generation = slot->generation + 1U;
|
||||||
if (generation == 0U || generation > SESSION_BROKER_MAX_GENERATION) {
|
/* Exhausted slots are retired until reboot: no 29-bit ID reuse. */
|
||||||
generation = 1U;
|
|
||||||
}
|
|
||||||
|
|
||||||
xStreamBufferReset(slot->output);
|
xStreamBufferReset(slot->output);
|
||||||
xQueueReset(slot->events);
|
xQueueReset(slot->events);
|
||||||
@@ -470,13 +475,19 @@ esp_err_t session_broker_release_writer(session_broker_client_id_t client_id)
|
|||||||
return ESP_OK;
|
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) {
|
if (!s_initialized) {
|
||||||
return ESP_ERR_INVALID_STATE;
|
return ESP_ERR_INVALID_STATE;
|
||||||
}
|
}
|
||||||
|
|
||||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
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;
|
session_broker_slot_t *new_writer = NULL;
|
||||||
if (client_id != SESSION_BROKER_NO_CLIENT) {
|
if (client_id != SESSION_BROKER_NO_CLIENT) {
|
||||||
new_writer = find_slot_locked(client_id);
|
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;
|
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(
|
esp_err_t session_broker_force_release_writer(
|
||||||
session_broker_client_id_t expected_writer_id)
|
session_broker_client_id_t expected_writer_id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -120,6 +120,31 @@ typedef struct {
|
|||||||
session_broker_global_counters_t counters;
|
session_broker_global_counters_t counters;
|
||||||
} session_broker_global_snapshot_t;
|
} 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
|
* Allocates all eight output streams and event queues, then starts the
|
||||||
* permanent broker task. The serial service must already be initialized
|
* permanent broker task. The serial service must already be initialized
|
||||||
|
|||||||
@@ -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);
|
||||||
+15
-1
@@ -27,6 +27,7 @@
|
|||||||
#include "web_account_settings.h"
|
#include "web_account_settings.h"
|
||||||
#include "web_network_settings.h"
|
#include "web_network_settings.h"
|
||||||
#include "web_display_settings.h"
|
#include "web_display_settings.h"
|
||||||
|
#include "web_broker_settings.h"
|
||||||
#include "web_admin_transport.h"
|
#include "web_admin_transport.h"
|
||||||
#include "web_session_store.h"
|
#include "web_session_store.h"
|
||||||
#include "web_cookie_auth.h"
|
#include "web_cookie_auth.h"
|
||||||
@@ -414,6 +415,15 @@ static const httpd_uri_t s_account_generate_password_uri = {
|
|||||||
static const httpd_uri_t s_network_uri = {
|
static const httpd_uri_t s_network_uri = {
|
||||||
.uri = "/api/settings/network", .method = HTTP_GET, .handler = web_network_snapshot_handler,
|
.uri = "/api/settings/network", .method = HTTP_GET, .handler = web_network_snapshot_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 = {
|
static const httpd_uri_t s_display_uri = {
|
||||||
.uri = "/api/settings/display", .method = HTTP_GET, .handler = web_display_settings_handler,
|
.uri = "/api/settings/display", .method = HTTP_GET, .handler = web_display_settings_handler,
|
||||||
};
|
};
|
||||||
@@ -645,7 +655,7 @@ esp_err_t web_server_start(void)
|
|||||||
config.httpd.max_open_sockets = 6;
|
config.httpd.max_open_sockets = 6;
|
||||||
config.httpd.max_uri_handlers =
|
config.httpd.max_uri_handlers =
|
||||||
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
|
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
|
||||||
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 16U;
|
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 19U;
|
||||||
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
|
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
|
||||||
config.httpd.lru_purge_enable = false;
|
config.httpd.lru_purge_enable = false;
|
||||||
config.httpd.recv_wait_timeout = 1;
|
config.httpd.recv_wait_timeout = 1;
|
||||||
@@ -713,6 +723,10 @@ esp_err_t web_server_start(void)
|
|||||||
web_httpd_register_optional_get(server, &s_display_operation_get_uri) == ESP_OK &&
|
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)
|
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);
|
(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 (error != ESP_OK) {
|
if (error != ESP_OK) {
|
||||||
web_cookie_auth_stop();
|
web_cookie_auth_stop();
|
||||||
|
|||||||
+161
-2
@@ -186,7 +186,8 @@ static const char s_index_html[] =
|
|||||||
"<div class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
|
"<div 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-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-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></div>"
|
||||||
|
"<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 clears selection. 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"
|
"<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\">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"
|
"<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"
|
||||||
@@ -393,7 +394,7 @@ static const char s_app_js[] =
|
|||||||
" element('serial-result').disabled = busy;\n"
|
" element('serial-result').disabled = busy;\n"
|
||||||
"}\n"
|
"}\n"
|
||||||
"function clearSettings() {\n"
|
"function clearSettings() {\n"
|
||||||
" clearAccounts(); clearNetwork(); clearDisplay();\n"
|
" clearAccounts(); clearNetwork(); clearDisplay(); clearBroker();\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"
|
" 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"
|
" stopSerialAuto(true);\n"
|
||||||
" if (settingsAbort) settingsAbort.abort();\n"
|
" if (settingsAbort) settingsAbort.abort();\n"
|
||||||
@@ -408,6 +409,7 @@ static const char s_app_js[] =
|
|||||||
" if (settingsDomain === 'accounts') return refreshAccounts();\n"
|
" if (settingsDomain === 'accounts') return refreshAccounts();\n"
|
||||||
" if (settingsDomain === 'network') return refreshNetwork();\n"
|
" if (settingsDomain === 'network') return refreshNetwork();\n"
|
||||||
" if (settingsDomain === 'display') return refreshDisplay();\n"
|
" if (settingsDomain === 'display') return refreshDisplay();\n"
|
||||||
|
" if (settingsDomain === 'broker') return refreshBroker();\n"
|
||||||
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
|
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
|
||||||
" settingsHost.hidden = false;\n"
|
" settingsHost.hidden = false;\n"
|
||||||
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
|
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
|
||||||
@@ -638,6 +640,162 @@ static const char s_app_js[] =
|
|||||||
"element('display-refresh').addEventListener('click', refreshDisplay);\n"
|
"element('display-refresh').addEventListener('click', refreshDisplay);\n"
|
||||||
"element('display-result').addEventListener('click', () => displayOperation(null));\n"
|
"element('display-result').addEventListener('click', () => displayOperation(null));\n"
|
||||||
"for (const action of displayActions) element('display-' + action).addEventListener('click', () => displayOperation(action));\n"
|
"for (const action of displayActions) element('display-' + action).addEventListener('click', () => displayOperation(action));\n"
|
||||||
|
"const brokerDetail = element('broker-detail');\n"
|
||||||
|
"let brokerClients = [], brokerWriter = 0;\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 brokerButtons() {\n"
|
||||||
|
" const busy = !!brokerAbort || !!brokerAuto;\n"
|
||||||
|
" const target = Number(element('broker-target').value);\n"
|
||||||
|
" element('broker-assign').disabled = busy || brokerOperationPending || !brokerGeneration || brokerGeneration === 4294967295 || !brokerClients.some(c => c.id === target) || target === brokerWriter;\n"
|
||||||
|
" element('broker-target').disabled = busy || brokerOperationPending || !brokerGeneration;\n"
|
||||||
|
" element('broker-refresh').disabled = busy;\n"
|
||||||
|
" element('broker-result').disabled = 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"
|
||||||
|
" 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"
|
||||||
|
" const controller = new AbortController(), generation = workGeneration; brokerAbort = controller; brokerButtons();\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"
|
||||||
|
" element('broker-target').value = '';\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"
|
||||||
|
" brokerDetail.textContent = (brokerOperationPending ? 'Outcome pending or unknown. ' : '') + 'Writer: ' + (brokerWriter || 'None') + '. ' + brokerClients.length + ' connected clients. Refresh clears selection; review and explicitly confirm assignment.' + (brokerGeneration === 4294967295 ? ' Confirmation generation exhausted; use the admin shell.' : '');\n"
|
||||||
|
" } catch (error) {\n"
|
||||||
|
" if (live(generation) && current()) { brokerGeneration = 0; brokerDetail.textContent = (error.status ? error.message : 'Broker snapshot unavailable or invalid.') + ' Select Refresh to retry.'; }\n"
|
||||||
|
" } finally { if (current()) { brokerAbort = null; brokerButtons(); } }\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 (action !== 'assign' || !client || !brokerGeneration || brokerGeneration === 4294967295 || target === brokerWriter) return;\n"
|
||||||
|
" const value = {action, generation: brokerGeneration, 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"
|
||||||
|
" 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"
|
||||||
|
" element('broker-refresh').disabled = true; 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', brokerButtons);\n"
|
||||||
"let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n"
|
"let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n"
|
||||||
"let keysAbort = null, accountKeys = [], keysIdentity = '';\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"
|
"function keyIdentity() { const t = accounts[Number(element('account-target').value)]; return t ? JSON.stringify([t.username,t.user_id,t.auth_generation]) : ''; }\n"
|
||||||
@@ -717,6 +875,7 @@ static const char s_app_js[] =
|
|||||||
" clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\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('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('display-settings').hidden = domain !== 'display'; element('settings-display').setAttribute('aria-pressed', String(domain === 'display'));\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-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"
|
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
|
||||||
" refreshSettings();\n"
|
" refreshSettings();\n"
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ typedef int *SemaphoreHandle_t;
|
|||||||
#define pdMS_TO_TICKS(x) (x)
|
#define pdMS_TO_TICKS(x) (x)
|
||||||
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
|
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
|
||||||
static unsigned lock_depth, ticks, runs, actions;
|
static unsigned lock_depth, ticks, runs, actions;
|
||||||
|
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 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_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; }
|
static void web_network_settings_execute(uint32_t id) { assert(!lock_depth); network_settings_executed = id; }
|
||||||
|
|||||||
@@ -379,5 +379,20 @@ int main(void)
|
|||||||
assert(serial_settings_executed == 41 && network_settings_executed == 42 && account_settings_executed == 43 && display_settings_executed == 44);
|
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);
|
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");
|
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");
|
||||||
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
|
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,61 @@ int main(void) {
|
|||||||
comparison(1); comparison(2);
|
comparison(1); comparison(2);
|
||||||
assert(allocations == initial_allocations);
|
assert(allocations == initial_allocations);
|
||||||
puts("PASS no post-init broker allocations; 7 diagnostic groups passed");
|
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();
|
cleanup_allocations();
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ def define(path, name):
|
|||||||
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
|
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
|
||||||
source, re.M | re.S)
|
source, re.M | re.S)
|
||||||
# Non-array declarations have no brackets; explicit shape avoids silent omission.
|
# Non-array declarations have no brackets; explicit shape avoids silent omission.
|
||||||
if len(uri_tables) != 27:
|
if len(uri_tables) != 30:
|
||||||
raise RuntimeError('Review URI extraction: expected 25 descriptors and two tables')
|
raise RuntimeError('Review URI extraction: expected 28 descriptors and two tables')
|
||||||
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
|
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
|
||||||
source.index('static esp_err_t ensure_mutex(void)')]
|
source.index('static esp_err_t ensure_mutex(void)')]
|
||||||
header = (ROOT / 'src/web_server.h').read_text()
|
header = (ROOT / 'src/web_server.h').read_text()
|
||||||
@@ -97,7 +97,7 @@ static bool unregister_fail;
|
|||||||
static bool settings_fail;
|
static bool settings_fail;
|
||||||
static unsigned settings_calls;
|
static unsigned settings_calls;
|
||||||
static unsigned operation_calls, operation_fail_at;
|
static unsigned operation_calls, operation_fail_at;
|
||||||
static const httpd_uri_t *registered[32];
|
static const httpd_uri_t *registered[36];
|
||||||
static char events[128]; static size_t event_length;
|
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 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 SemaphoreHandle_t xSemaphoreCreateMutex(void) { assert(!locked); return mutex_fail ? NULL : &mutex_storage; }
|
||||||
@@ -154,6 +154,26 @@ static esp_err_t display_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
|||||||
registered[registered_count++] = uri;
|
registered[registered_count++] = uri;
|
||||||
return ESP_OK;
|
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 unsigned network_calls, network_allocations, network_fail_at;
|
||||||
static esp_err_t network_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
static esp_err_t network_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||||
assert(s == SERVER && auth_live && ssl_live && !locked);
|
assert(s == SERVER && auth_live && ssl_live && !locked);
|
||||||
@@ -189,7 +209,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) {
|
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(!locked && auth_live && !ssl_live); ++ssl_starts;
|
||||||
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
|
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 == 33 && config->port_secure == 443);
|
||||||
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
|
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
|
||||||
assert(config->tls_handshake_timeout_ms == 5000);
|
assert(config->tls_handshake_timeout_ms == 5000);
|
||||||
assert(config->user_cb == tls_session_callback);
|
assert(config->user_cb == tls_session_callback);
|
||||||
@@ -217,7 +237,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);
|
assert(serial_init_error != ESP_OK || serial_live);
|
||||||
} else assert(registration_calls < 14);
|
} else assert(registration_calls < 14);
|
||||||
esp_err_t error = register_one(s);
|
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 < 36); registered[registered_count++] = uri; }
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||||
@@ -227,6 +247,7 @@ 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) {
|
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||||
assert(uri->method == HTTP_GET);
|
assert(uri->method == HTTP_GET);
|
||||||
|
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_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)
|
if (uri->handler == web_network_snapshot_handler || uri->handler == web_network_operation_handler)
|
||||||
return network_register(s, uri);
|
return network_register(s, uri);
|
||||||
@@ -235,6 +256,7 @@ static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_u
|
|||||||
return httpd_register_uri_handler(s, uri);
|
return httpd_register_uri_handler(s, uri);
|
||||||
}
|
}
|
||||||
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
|
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *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_display_operation_handler) return display_register(s, uri);
|
||||||
if (uri->handler == web_network_operation_handler) return network_register(s, uri);
|
if (uri->handler == web_network_operation_handler) return network_register(s, uri);
|
||||||
if (uri->handler == web_account_keys_handler) {
|
if (uri->handler == web_account_keys_handler) {
|
||||||
@@ -262,7 +284,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) {
|
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(!locked && s == SERVER && ssl_live && auth_live && serial_live);
|
||||||
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
|
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")) && method == HTTP_GET));
|
||||||
++unregister_calls;
|
++unregister_calls;
|
||||||
for (unsigned i = 0; i < registered_count; ++i) {
|
for (unsigned i = 0; i < registered_count; ++i) {
|
||||||
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
|
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
|
||||||
@@ -332,6 +354,7 @@ static void reset(void) {
|
|||||||
operation_calls = operation_fail_at = 0;
|
operation_calls = operation_fail_at = 0;
|
||||||
network_calls = network_allocations = network_fail_at = 0;
|
network_calls = network_allocations = network_fail_at = 0;
|
||||||
display_calls = display_allocations = display_fail_at = 0;
|
display_calls = display_allocations = display_fail_at = 0;
|
||||||
|
broker_calls = broker_allocations = broker_fail_at = 0;
|
||||||
account_calls = account_fail_at = generation_calls = keys_calls = 0;
|
account_calls = account_fail_at = generation_calls = keys_calls = 0;
|
||||||
generation_fail = keys_fail = false;
|
generation_fail = keys_fail = false;
|
||||||
}
|
}
|
||||||
@@ -339,6 +362,7 @@ static void fresh_registration(void) {
|
|||||||
registration_calls = registered_count = 0;
|
registration_calls = registered_count = 0;
|
||||||
network_calls = network_allocations = 0;
|
network_calls = network_allocations = 0;
|
||||||
display_calls = display_allocations = 0;
|
display_calls = display_allocations = 0;
|
||||||
|
broker_calls = broker_allocations = 0;
|
||||||
}
|
}
|
||||||
static void start(void) {
|
static void start(void) {
|
||||||
assert(web_server_start() == ESP_OK);
|
assert(web_server_start() == ESP_OK);
|
||||||
@@ -368,6 +392,14 @@ static void display_complete(void) {
|
|||||||
assert(r && r->handler == web_display_operation_handler);
|
assert(r && r->handler == web_display_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) {
|
static void network_complete(void) {
|
||||||
assert(network_calls == 3 && network_allocations == 6);
|
assert(network_calls == 3 && network_allocations == 6);
|
||||||
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
||||||
@@ -410,7 +442,7 @@ int main(void) {
|
|||||||
}
|
}
|
||||||
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
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 == 33 && 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(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(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||||
@@ -464,7 +496,7 @@ int main(void) {
|
|||||||
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
|
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
|
||||||
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
|
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(!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 == 31 && unregister_calls == failure - 17);
|
||||||
for (unsigned i = 0; i < registered_count; ++i)
|
for (unsigned i = 0; i < registered_count; ++i)
|
||||||
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
|
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
|
||||||
assert(route("/ws/serial")->handler == traced_websocket_handler);
|
assert(route("/ws/serial")->handler == traced_websocket_handler);
|
||||||
@@ -473,13 +505,13 @@ int main(void) {
|
|||||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||||
assert(!admin_detaches && !admin_stoppeds);
|
assert(!admin_detaches && !admin_stoppeds);
|
||||||
registration_fail_at = 0; fresh_registration(); start();
|
registration_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 30 && admin_attaches == 1 && s_counters.starts == 2);
|
assert(registered_count == 33 && admin_attaches == 1 && s_counters.starts == 2);
|
||||||
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
|
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");
|
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;
|
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 == 32);
|
||||||
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
|
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
|
||||||
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
|
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
|
||||||
ticket = route("/api/admin/ws-ticket");
|
ticket = route("/api/admin/ws-ticket");
|
||||||
@@ -491,7 +523,7 @@ int main(void) {
|
|||||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||||
assert(!admin_detaches && !admin_stoppeds);
|
assert(!admin_detaches && !admin_stoppeds);
|
||||||
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
|
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 == 33 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||||
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
|
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;
|
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
|
||||||
@@ -513,7 +545,7 @@ int main(void) {
|
|||||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
|
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
|
||||||
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
|
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
|
||||||
reset(); settings_fail = true; start();
|
reset(); settings_fail = true; start();
|
||||||
assert(settings_calls == 1 && registered_count == 29);
|
assert(settings_calls == 1 && registered_count == 32);
|
||||||
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
|
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
|
||||||
settings_fail = false; fresh_registration(); start();
|
settings_fail = false; fresh_registration(); start();
|
||||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||||
@@ -521,7 +553,7 @@ int main(void) {
|
|||||||
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
|
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
|
||||||
for (unsigned failure = 1; failure <= 2; ++failure) {
|
for (unsigned failure = 1; failure <= 2; ++failure) {
|
||||||
reset(); operation_fail_at = failure; start();
|
reset(); operation_fail_at = failure; start();
|
||||||
assert(registered_count == 28 && operation_calls == failure && unregister_calls == failure - 1);
|
assert(registered_count == 31 && operation_calls == failure && unregister_calls == failure - 1);
|
||||||
assert(auth_live && serial_live && admin_owned);
|
assert(auth_live && serial_live && admin_owned);
|
||||||
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
|
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
@@ -529,7 +561,7 @@ int main(void) {
|
|||||||
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
|
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
|
||||||
for (unsigned failure = 1; failure <= 3; ++failure) {
|
for (unsigned failure = 1; failure <= 3; ++failure) {
|
||||||
reset(); account_calls = 0; account_fail_at = failure; start();
|
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 ? 30 : 31));
|
||||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
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(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||||
assert(auth_live && serial_live && admin_owned);
|
assert(auth_live && serial_live && admin_owned);
|
||||||
@@ -537,17 +569,17 @@ int main(void) {
|
|||||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
|
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
|
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 30 && account_calls == 3);
|
assert(registered_count == 33 && account_calls == 3);
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
|
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 == 32 && auth_live && serial_live && admin_owned);
|
||||||
for (unsigned i = 0; i < registered_count; ++i)
|
for (unsigned i = 0; i < registered_count; ++i)
|
||||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
|
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
|
||||||
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
|
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)");
|
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();
|
reset(); generation_fail = true; start();
|
||||||
assert(generation_calls == 1 && registered_count == 29 && account_calls == 3);
|
assert(generation_calls == 1 && registered_count == 32 && account_calls == 3);
|
||||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
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(!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")->handler == web_account_settings_handler);
|
||||||
@@ -559,12 +591,12 @@ int main(void) {
|
|||||||
}
|
}
|
||||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||||
generation_fail = false; fresh_registration(); start();
|
generation_fail = false; fresh_registration(); start();
|
||||||
assert(generation_calls == 2 && registered_count == 30);
|
assert(generation_calls == 2 && registered_count == 33);
|
||||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
|
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
|
||||||
reset(); keys_fail = true; start();
|
reset(); keys_fail = true; start();
|
||||||
assert(keys_calls == 1 && registered_count == 29 && account_calls == 3 && generation_calls == 1);
|
assert(keys_calls == 1 && registered_count == 32 && account_calls == 3 && generation_calls == 1);
|
||||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
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")->handler == web_account_settings_handler);
|
||||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||||
@@ -579,7 +611,7 @@ int main(void) {
|
|||||||
}
|
}
|
||||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||||
keys_fail = false; fresh_registration(); start();
|
keys_fail = false; fresh_registration(); start();
|
||||||
assert(keys_calls == 2 && registered_count == 30);
|
assert(keys_calls == 2 && registered_count == 33);
|
||||||
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
|
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
|
||||||
@@ -604,7 +636,7 @@ int main(void) {
|
|||||||
reset(); network_fail_at = failure; start();
|
reset(); network_fail_at = failure; start();
|
||||||
unsigned failed_route = (failure + 1) / 2;
|
unsigned failed_route = (failure + 1) / 2;
|
||||||
assert(network_calls == failed_route && network_allocations == failure);
|
assert(network_calls == failed_route && network_allocations == failure);
|
||||||
assert(registered_count == (failed_route == 1 ? 27 : 28));
|
assert(registered_count == (failed_route == 1 ? 30 : 31));
|
||||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
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_GET));
|
||||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||||
@@ -612,13 +644,13 @@ int main(void) {
|
|||||||
other_domains_complete();
|
other_domains_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
network_fail_at = 0; fresh_registration(); start();
|
network_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 30); network_complete();
|
assert(registered_count == 33); network_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
|
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
|
||||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||||
reset(); network_fail_at = failure; unregister_fail = true; start();
|
reset(); network_fail_at = failure; unregister_fail = true; start();
|
||||||
assert(registered_count == 29 && unregister_calls == 1);
|
assert(registered_count == 32 && unregister_calls == 1);
|
||||||
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
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_GET)->handler == web_network_operation_handler);
|
||||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||||
@@ -628,7 +660,7 @@ int main(void) {
|
|||||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||||
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
|
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 30); network_complete();
|
assert(registered_count == 33); network_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
|
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||||
@@ -636,7 +668,7 @@ int main(void) {
|
|||||||
reset(); display_fail_at = failure; start();
|
reset(); display_fail_at = failure; start();
|
||||||
unsigned failed_route = (failure + 1) / 2;
|
unsigned failed_route = (failure + 1) / 2;
|
||||||
assert(display_calls == failed_route && display_allocations == failure);
|
assert(display_calls == failed_route && display_allocations == failure);
|
||||||
assert(registered_count == (failed_route == 1 ? 27 : 28));
|
assert(registered_count == (failed_route == 1 ? 30 : 31));
|
||||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
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_GET));
|
||||||
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
||||||
@@ -644,13 +676,13 @@ int main(void) {
|
|||||||
other_domains_complete(); network_complete();
|
other_domains_complete(); network_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
display_fail_at = 0; fresh_registration(); start();
|
display_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 30); display_complete();
|
assert(registered_count == 33); display_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS all six Display descriptor/name allocation positions isolate failures and recover after restart");
|
puts("PASS all six Display descriptor/name allocation positions isolate failures and recover after restart");
|
||||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||||
reset(); display_fail_at = failure; unregister_fail = true; start();
|
reset(); display_fail_at = failure; unregister_fail = true; start();
|
||||||
assert(registered_count == 29 && unregister_calls == 1);
|
assert(registered_count == 32 && unregister_calls == 1);
|
||||||
assert(route("/api/settings/display")->handler == web_display_settings_handler);
|
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_GET)->handler == web_display_operation_handler);
|
||||||
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
||||||
@@ -660,10 +692,42 @@ int main(void) {
|
|||||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||||
unregister_fail = false; display_fail_at = 0; fresh_registration(); start();
|
unregister_fail = false; display_fail_at = 0; fresh_registration(); start();
|
||||||
assert(registered_count == 30); display_complete();
|
assert(registered_count == 33); display_complete();
|
||||||
assert(web_server_stop() == ESP_OK);
|
assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS failed Display result unregister leaves reads only and preserves stop-failure ownership/restart");
|
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 ? 30 : 31));
|
||||||
|
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 == 33); 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 == 32 && 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 == 33); 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 = 0; failure < 8; ++failure) {
|
for (unsigned failure = 0; failure < 8; ++failure) {
|
||||||
reset();
|
reset();
|
||||||
if (failure == 0) settings_fail = true;
|
if (failure == 0) settings_fail = true;
|
||||||
@@ -671,10 +735,10 @@ int main(void) {
|
|||||||
else if (failure <= 5) account_fail_at = failure - 2;
|
else if (failure <= 5) account_fail_at = failure - 2;
|
||||||
else if (failure == 6) generation_fail = true;
|
else if (failure == 6) generation_fail = true;
|
||||||
else keys_fail = true;
|
else keys_fail = true;
|
||||||
start(); network_complete(); display_complete(); assert(web_server_stop() == ESP_OK);
|
start(); network_complete(); display_complete(); broker_complete(); assert(web_server_stop() == ESP_OK);
|
||||||
}
|
}
|
||||||
puts("PASS every other settings route failure leaves the complete Network domain available");
|
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)");
|
puts("25 lifecycle groups passed (16 required fatal positions, 19 optional routes, Network/Display/Broker allocation positions and failed unregister)");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
'''
|
'''
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ admin = "--admin" in sys.argv
|
|||||||
settings = "--settings" in sys.argv
|
settings = "--settings" in sys.argv
|
||||||
serial_settings = "--serial-settings" in sys.argv
|
serial_settings = "--serial-settings" in sys.argv
|
||||||
accounts = "--accounts" in sys.argv
|
accounts = "--accounts" in sys.argv
|
||||||
|
broker = "--broker" in sys.argv
|
||||||
display = "--display" in sys.argv
|
display = "--display" in sys.argv
|
||||||
if display:
|
if display:
|
||||||
HEADERS["nvs_flash.h"] = '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n'
|
HEADERS["nvs_flash.h"] = '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n'
|
||||||
@@ -247,6 +248,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
|||||||
*(["-DHOST_ACCOUNTS"] if accounts else []),
|
*(["-DHOST_ACCOUNTS"] if accounts else []),
|
||||||
*(["-DHOST_NETWORK"] if network else []),
|
*(["-DHOST_NETWORK"] if network else []),
|
||||||
*(["-DHOST_DISPLAY"] if display else []),
|
*(["-DHOST_DISPLAY"] if display else []),
|
||||||
|
*(["-DHOST_BROKER"] if broker else []),
|
||||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
|
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
|
||||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ static struct httpd_data server = {.config.max_resp_headers = 8};
|
|||||||
static struct sock_db socket_state;
|
static struct sock_db socket_state;
|
||||||
static struct resp_hdr response_headers[8];
|
static struct resp_hdr response_headers[8];
|
||||||
static char scratch[1024], cookie_values[2][200];
|
static char scratch[1024], cookie_values[2][200];
|
||||||
#ifdef HOST_NETWORK
|
#if defined(HOST_NETWORK) || defined(HOST_BROKER)
|
||||||
static char output[2048];
|
static char output[2048];
|
||||||
#else
|
#else
|
||||||
static char output[1024];
|
static char output[1024];
|
||||||
@@ -146,6 +146,9 @@ static void auth_reset(void) {
|
|||||||
#ifdef HOST_DISPLAY
|
#ifdef HOST_DISPLAY
|
||||||
#include "display_settings_test.c"
|
#include "display_settings_test.c"
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef HOST_BROKER
|
||||||
|
#include "broker_settings_test.c"
|
||||||
|
#endif
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
assert(store_tests() == 0); auth_reset();
|
assert(store_tests() == 0); auth_reset();
|
||||||
@@ -308,6 +311,9 @@ int main(void) {
|
|||||||
#endif
|
#endif
|
||||||
#ifdef HOST_DISPLAY
|
#ifdef HOST_DISPLAY
|
||||||
display_settings_tests();
|
display_settings_tests();
|
||||||
|
#endif
|
||||||
|
#ifdef HOST_BROKER
|
||||||
|
broker_settings_tests();
|
||||||
#endif
|
#endif
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'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');
|
||||||
|
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(); }
|
||||||
|
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 clears 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,''); assert.ok(n(b,'assign').disabled); 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',username:'alice'}));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)); };
|
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>'} = {}) {
|
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
|
||||||
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
|
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': []};
|
||||||
const fits = [];
|
const fits = [];
|
||||||
let serial = 0, now = Date.now();
|
let serial = 0, now = Date.now();
|
||||||
class Clock extends Date { static now() { return now; } }
|
class Clock extends Date { static now() { return now; } }
|
||||||
@@ -1252,5 +1252,6 @@ 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('./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('./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});
|
||||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ def check_layout(html):
|
|||||||
if cls in classes(node):
|
if cls in classes(node):
|
||||||
return node
|
return node
|
||||||
raise AssertionError(cls)
|
raise AssertionError(cls)
|
||||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values'):
|
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values'):
|
||||||
assert ids[ident]['tag'] == 'dl'
|
assert ids[ident]['tag'] == 'dl'
|
||||||
assert 'settings-values' in classes(ids[ident])
|
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'):
|
||||||
nodes = list(descendants(ids[ident]))
|
nodes = list(descendants(ids[ident]))
|
||||||
assert not any(n['tag'] == 'pre' for n in nodes)
|
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')
|
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
|
||||||
@@ -59,9 +59,9 @@ def check_layout(html):
|
|||||||
ancestor(n, 'settings-edit')
|
ancestor(n, 'settings-edit')
|
||||||
except AssertionError:
|
except AssertionError:
|
||||||
ancestor(n, 'serial-edit')
|
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'):
|
||||||
assert ids[ident]['text'] == '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'):
|
||||||
assert ids[ident]['text'] == 'Check Operation Result'
|
assert ids[ident]['text'] == 'Check Operation Result'
|
||||||
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
|
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
|
||||||
assert 'settings-check' in classes(ids[ident]['parent'])
|
assert 'settings-check' in classes(ids[ident]['parent'])
|
||||||
@@ -90,7 +90,7 @@ def check_layout(html):
|
|||||||
):
|
):
|
||||||
assert rule in css, rule
|
assert rule in css, rule
|
||||||
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
|
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 five settings views')
|
||||||
|
|
||||||
|
|
||||||
def check_browser_layout(html, tmp, executable):
|
def check_browser_layout(html, tmp, executable):
|
||||||
|
|||||||
Reference in New Issue
Block a user