diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 825f17c..ff8829c 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -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`. +## 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 `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. diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 1529372..cd7e064 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -34,6 +34,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the ## Session broker +- **8D.16 management:** `session_broker_get_management_snapshot()` copies compact eight-client rows plus writer/lease generation atomically with zero wait; `session_broker_assign_writer_current()` compares generation and target under the force-writer lock. 29-bit client generations retire slots instead of wrapping; saturating lease generation fences ABA, survives counter clear, and leaves ordinary recovery available. Actual canonical regressions extend `tests/session_broker_diagnostics/run.py`. Full contracts/wrap analysis: `docs/phase8d16_implementation.md`. + **Responsibility:** mediate all transport access to the serial service; provide one writer lease and multiple isolated observers. - Files: `src/session_broker.{h,c}`, `src/session_console.{h,c}` @@ -59,6 +61,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the ## 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`. - **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. diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index cafef91..a3cc8ee 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do ## 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.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. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index ad3c39e..f641ec6 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -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}` +## 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 **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. diff --git a/docs/phase8d16_implementation.md b/docs/phase8d16_implementation.md new file mode 100644 index 0000000..e0ab57a --- /dev/null +++ b/docs/phase8d16_implementation.md @@ -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. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index 225044e..3ef4f56 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -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.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.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. | diff --git a/docs/user_administration_tests.md b/docs/user_administration_tests.md index 3942d9d..a1200b4 100644 --- a/docs/user_administration_tests.md +++ b/docs/user_administration_tests.md @@ -2,6 +2,16 @@ This document retains phase-specific regression procedures. The Phase 8A and 8B sections describe the behavior of those historical implementation baselines; they are not the current end-to-end acceptance behavior. In current Phase 8C firmware, HTTPS and SSH authenticate through the user database, role-`user` SSH sessions receive the broker-backed serial stream, and role-`admin` SSH sessions receive the administration shell. Use the Phase 8C section for current routing and shared-console validation. Never include generated or entered passwords in test logs. +## Current Broker management regression procedure — 8D.16 + +Implemented and host/build verified; **independent parent review, target validation and user sign-off pending**. The [8D.16 contract and target checklist](phase8d16_implementation.md) defines exact API, generation/wrap safety, resource bounds and evidence limits. This procedure is not an execution claim. + +- Verify admin-only detailed client rows against non-consuming UART0 snapshots/counters; direct normal-user management GET/POST denied. Observe zero/one/full client mix without reading UART payloads as a probe. +- Opening, selecting, cancelling, refreshing and navigating must leave writer and both terminals unchanged. Refresh clears selection. Native confirmation must identify exact target and snapshot writer; no8D.17/18 popover behavior is included. +- Assign among USB/SSH/two browser serial clients with both admins connected. Disconnect/reuse target while confirmation is open; race writer release/reacquire, competing requests, shell force and local release. Stale confirmation must fail without changing the intervening lease; Refresh/reselect/confirm explicitly. +- Test separate-login result isolation, same-login tab replacement, pending capacity, lost ACK/result, bounded polling/manual recovery, logout/expiry/revocation and HTTPS stop/restart. Never infer cancellation from connection loss or replay automatically. +- At signed-off160MHz/230400 baud, check full-mix binary traffic/drop isolation, UART0/USB recovery and optional-route failure isolation. Capture exact revision/mix and internal/DMA/PSRAM plus HTTPD/dispatcher margins; host tests/build do not approve reserves or target throughput for this phase. + ## Current Network settings regression procedure — 8D.12/8D.13 Both phases were authorized and implemented together. The [implementation record](phase8d12_13_implementation.md) defines the complete API, limits, result states, exclusions and pending target checklist. Backend/cookie Network PASS, UI agent97+renderer/CSP/review PASS and lifecycle agent21 PASS are reported evidence; final parent build/tests and target sign-off remain pending. Do not treat this procedure as executed or infer M3 completion/8D.14 authorization. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b326ed3..0edeaf1 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -35,6 +35,7 @@ idf_component_register( "web_account_settings.c" "web_network_settings.c" "web_display_settings.c" + "web_broker_settings.c" "web_admin_tickets.c" "web_admin_transport.c" "web_assets_data.c" diff --git a/src/admin_ssh_console.c b/src/admin_ssh_console.c index 3ed875e..96c3d64 100644 --- a/src/admin_ssh_console.c +++ b/src/admin_ssh_console.c @@ -20,6 +20,7 @@ #include "web_account_settings.h" #include "web_network_settings.h" #include "web_display_settings.h" +#include "web_broker_settings.h" #define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U #define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U @@ -89,6 +90,7 @@ typedef enum { ADMIN_REQUEST_ACCOUNT_SETTINGS, ADMIN_REQUEST_NETWORK_SETTINGS, ADMIN_REQUEST_DISPLAY_SETTINGS, + ADMIN_REQUEST_BROKER_SETTINGS, } admin_request_origin_t; typedef struct { @@ -103,6 +105,7 @@ typedef struct { uint32_t account_settings_id; uint32_t network_settings_id; uint32_t display_settings_id; + uint32_t broker_settings_id; }; } 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; } +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) { (void)context; @@ -707,11 +720,13 @@ static void worker_task(void *context) continue; } if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS || - request.origin == ADMIN_REQUEST_NETWORK_SETTINGS || request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) { + request.origin == ADMIN_REQUEST_NETWORK_SETTINGS || request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS || + request.origin == ADMIN_REQUEST_BROKER_SETTINGS) { if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id); else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id); else if (request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) web_network_settings_execute(request.network_settings_id); - else web_display_settings_execute(request.display_settings_id); + else if (request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) web_display_settings_execute(request.display_settings_id); + else web_broker_settings_execute(request.broker_settings_id); secure_wipe(&request, sizeof(request)); continue; } diff --git a/src/admin_ssh_console.h b/src/admin_ssh_console.h index 3a3959a..b8b0ff4 100644 --- a/src/admin_ssh_console.h +++ b/src/admin_ssh_console.h @@ -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_network_settings(uint32_t id); esp_err_t admin_ssh_console_submit_display_settings(uint32_t id); +esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id); /* Fits the longest supported ECDSA P-256 OpenSSH key import command. */ #define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U diff --git a/src/session_broker.c b/src/session_broker.c index 70376ed..823ae0d 100644 --- a/src/session_broker.c +++ b/src/session_broker.c @@ -47,6 +47,8 @@ static session_broker_slot_t s_slots[SESSION_BROKER_MAX_CLIENTS]; static session_broker_client_id_t s_writer_id; static uint32_t s_connected_clients; static uint64_t s_event_sequence; +/* Saturation disables management confirmations, never ordinary recovery. */ +static uint32_t s_writer_generation = 1U; static session_broker_global_counters_t s_counters; static bool s_initialized; @@ -102,6 +104,10 @@ static void broadcast_event_locked(session_broker_event_type_t type, session_broker_client_id_t client_id, session_broker_client_id_t writer_id) { + if ((type == SESSION_BROKER_EVENT_WRITER_GRANTED || + type == SESSION_BROKER_EVENT_WRITER_RELEASED || + type == SESSION_BROKER_EVENT_WRITER_REVOKED) && s_writer_generation != UINT32_MAX) + ++s_writer_generation; session_broker_event_t event = { .sequence = ++s_event_sequence, .type = type, @@ -299,7 +305,8 @@ esp_err_t session_broker_connect(session_broker_client_type_t type, session_broker_slot_t *slot = NULL; size_t slot_index = 0U; for (; slot_index < SESSION_BROKER_MAX_CLIENTS; ++slot_index) { - if (!s_slots[slot_index].connected) { + if (!s_slots[slot_index].connected && + s_slots[slot_index].generation < SESSION_BROKER_MAX_GENERATION) { slot = &s_slots[slot_index]; break; } @@ -319,9 +326,7 @@ esp_err_t session_broker_connect(session_broker_client_type_t type, } uint32_t generation = slot->generation + 1U; - if (generation == 0U || generation > SESSION_BROKER_MAX_GENERATION) { - generation = 1U; - } + /* Exhausted slots are retired until reboot: no 29-bit ID reuse. */ xStreamBufferReset(slot->output); xQueueReset(slot->events); @@ -470,13 +475,19 @@ esp_err_t session_broker_release_writer(session_broker_client_id_t client_id) return ESP_OK; } -esp_err_t session_broker_force_writer(session_broker_client_id_t client_id) +static esp_err_t broker_force_writer(session_broker_client_id_t client_id, + uint32_t expected_generation) { if (!s_initialized) { return ESP_ERR_INVALID_STATE; } xSemaphoreTake(s_mutex, portMAX_DELAY); + if (expected_generation && (expected_generation == UINT32_MAX || + expected_generation != s_writer_generation)) { + xSemaphoreGive(s_mutex); + return ESP_ERR_INVALID_STATE; + } session_broker_slot_t *new_writer = NULL; if (client_id != SESSION_BROKER_NO_CLIENT) { new_writer = find_slot_locked(client_id); @@ -522,6 +533,41 @@ esp_err_t session_broker_force_writer(session_broker_client_id_t client_id) return ESP_OK; } +esp_err_t session_broker_force_writer(session_broker_client_id_t client_id) +{ + return broker_force_writer(client_id, 0); +} + +esp_err_t session_broker_assign_writer_current(session_broker_client_id_t client_id, + uint32_t generation) +{ + if (!client_id || !generation) return ESP_ERR_INVALID_ARG; + return broker_force_writer(client_id, generation); +} + +esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *snapshot) +{ + if (!snapshot) return ESP_ERR_INVALID_ARG; + if (!s_initialized) return ESP_ERR_INVALID_STATE; + if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT; + memset(snapshot, 0, sizeof(*snapshot)); + snapshot->generation = s_writer_generation; + snapshot->writer_id = s_writer_id; + for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) { + const session_broker_slot_t *slot = &s_slots[i]; + if (!slot->connected) continue; + session_broker_management_client_t *client = &snapshot->clients[snapshot->count++]; + client->id = slot->id; + client->type = slot->type; + memcpy(client->name, slot->name, sizeof(client->name)); + client->pending = xStreamBufferBytesAvailable(slot->output); + client->high_water = slot->counters.output_high_water_bytes; + client->dropped = slot->counters.output_dropped_bytes; + } + xSemaphoreGive(s_mutex); + return ESP_OK; +} + esp_err_t session_broker_force_release_writer( session_broker_client_id_t expected_writer_id) { diff --git a/src/session_broker.h b/src/session_broker.h index 476673b..1f98799 100644 --- a/src/session_broker.h +++ b/src/session_broker.h @@ -120,6 +120,31 @@ typedef struct { session_broker_global_counters_t counters; } session_broker_global_snapshot_t; +/* Compact, atomic, non-consuming management projection. No transport pointers. */ +typedef struct { + session_broker_client_id_t id; + session_broker_client_type_t type; + char name[SESSION_BROKER_CLIENT_NAME_MAX + 1U]; + size_t pending, high_water; + uint64_t dropped; +} session_broker_management_client_t; +typedef struct { + uint32_t generation; + session_broker_client_id_t writer_id; + size_t count; + session_broker_management_client_t clients[SESSION_BROKER_MAX_CLIENTS]; +} session_broker_management_snapshot_t; + +/* Zero-wait atomic snapshot. Generation survives counter clears; UINT32_MAX + * means confirmations exhausted until reboot. Every lease transition advances + * it, including release/reacquire ABA. Client IDs never wrap within a boot. */ +esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *snapshot); +/* Nonzero target and generation required; compare + target validation + transfer + * share the broker lock. Stale/exhausted generation or absent target has no effects. + * Existing unconditional force remains available to recovery/console callers. */ +esp_err_t session_broker_assign_writer_current(session_broker_client_id_t client_id, + uint32_t generation); + /* * Allocates all eight output streams and event queues, then starts the * permanent broker task. The serial service must already be initialized diff --git a/src/web_broker_settings.c b/src/web_broker_settings.c new file mode 100644 index 0000000..6774f5c --- /dev/null +++ b/src/web_broker_settings.c @@ -0,0 +1,234 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "web_broker_settings.h" + +#include +#include +#include +#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; +} diff --git a/src/web_broker_settings.h b/src/web_broker_settings.h new file mode 100644 index 0000000..3a7e856 --- /dev/null +++ b/src/web_broker_settings.h @@ -0,0 +1,9 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#pragma once +#include +#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); diff --git a/src/web_server.c b/src/web_server.c index 909d8b8..d69d8d3 100644 --- a/src/web_server.c +++ b/src/web_server.c @@ -27,6 +27,7 @@ #include "web_account_settings.h" #include "web_network_settings.h" #include "web_display_settings.h" +#include "web_broker_settings.h" #include "web_admin_transport.h" #include "web_session_store.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 = { .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 = { .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_uri_handlers = sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) + - sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 16U; + sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 19U; /* Exhaustion rejects new sockets, never evicts an existing serial writer. */ config.httpd.lru_purge_enable = false; 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(server, &s_display_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_display_operation_get_uri.uri, HTTP_GET); + if (web_httpd_register_optional_get(server, &s_broker_uri) == ESP_OK && + web_httpd_register_optional_get(server, &s_broker_operation_get_uri) == ESP_OK && + web_httpd_register_optional(server, &s_broker_operation_post_uri) != ESP_OK) + (void)httpd_unregister_uri_handler(server, s_broker_operation_get_uri.uri, HTTP_GET); } if (error != ESP_OK) { web_cookie_auth_stop(); diff --git a/src/web_ui.c b/src/web_ui.c index f1b76ca..2dc380e 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -186,7 +186,8 @@ static const char s_index_html[] = "
" "" "" - "
" + "" + "\n" "