diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index dde1b46..9934cad 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -110,6 +110,10 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li ### HTTPS, WebSocket, and web serial +**8D.20 integrated lifecycle (supersedes prerequisite-only status below):** `web_lifecycle_settings` admits strict current-admin cookie/Origin/CSRF stop/restart/reboot through three optional routes. One original-login operation slot and nonreused ID,256-byte/four-receive request,128-byte snapshot/96-byte result. Only after synchronous response send succeeds does HTTPD queue an ID callback; it hands that ID without waiting to the existing four-entry dispatcher. No request/fd/socket/operation pointer escapes the handler. Two-second ACK and30-second post-validation dequeue deadlines precede owner admission, not lifecycle completion. Lost HTTPD work retains one reservation until callback or successful destruction, avoiding late-work accumulation; failed destruction preserves it. Dispatcher reserves execution, revalidates original session/current admin and calls canonical generation-conditional owner APIs outside locks. Restart invalidates logins but retains its owner reservation through start; later revocation is not cancellation. Conditional reboot reserves the HTTPS generation then invokes the shared canonical `esp_restart()` primitive outside locks, with no console self-cleanup or HTTPD stop wait. UI explicit confirmation/manual results/15-second whole request fences and no replay/restore; existing Network reused.39 handlers/six sockets and unchanged tasks/stacks/timers/queue depth. Full API, uncertainty, SDK/reset audit and resource limits: `docs/phase8d20_implementation.md`. + +**Historical 8D.20 internal owner prerequisite (superseded above):** `web_server` exposes a zero-wait scalar management snapshot and off-HTTPD generation-conditional stop/restart APIs. A firmware-lifetime saturated generation advances under the existing lifecycle mutex on every admitted canonical start/stop, including failed attempts; counter clear and repeated init do not restore old tokens. Conditional admission rejects failed cleanup, transition, absent server or exhausted generation. Reserved restart keeps `s_transitioning` true across successful stop into private start, with no unreserved stopped gap; external lifecycle calls remain outside the mutex. Stop failure skips start, partial-start cleanup failure retains ownership, and canonical stop/start remain recovery after saturation/failure. Repeated init preserves retained lifecycle errors. These APIs have no production caller yet and perform no authorization or ACK handoff; HTTP routes/UI/typed reboot are unimplemented. A future dispatcher must validate the original login and bound ACK handling before admission, never call shutdown from HTTPD or describe admitted work as cancelled by later revocation. Contract and incomplete scope: `docs/phase8d20_implementation.md`. + `web_server` runs HTTPS only on port 443 using the device-specific self-signed P-256 certificate from `web_security`. Current routes provide the UI, static assets, status, ticket issuance, and serial WebSocket upgrade. HTTPS login uses `user_database` and opaque server-side cookie sessions; Basic authentication and its cache are removed in 8D.3. No legacy credential is imported, synchronized or consulted for authentication. Both roles retain the same shipped web status/serial UI. 8D.5 adds an admin-only backend without a normal UI entry. diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 714be45..350b623 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -61,6 +61,10 @@ This is a semantic map, not a complete file inventory. Start here, then read the ## Web and WebSocket serial +- **8D.20 end-to-end (supersedes prerequisite-only entry below):** `web_lifecycle_settings.{c,h}` owns three optional current-admin lifecycle snapshot/operation routes,256-byte typed stop/restart/reboot, one original-login slot and bounded send-return→HTTPD ID callback→existing dispatcher handoff. One lost ACK reservation survives until callback/successful HTTPD destruction; nonreused IDs and owner generation prevent late/same-pointer ABA. `web_server` conditional reboot uses canonical `esp_restart()` outside locks, stop/restart retains owner reservation. `web_ui.c` adds confirmed HTTPS/Reboot,15-second requests/manual results, pending uncertainty/no replay/relogin, link to existing Network with accurate UART0/SSH/USB recovery.39 handlers/six sockets; no tasks/timers/queue growth/assets/config/identity work. PASS lifecycle41, cookie lifecycle8+shared, UI153+HTML/CSP, dispatcher and broad regressions. Final pio100,508 RAM/1,821,505 flash (+104/+13,064 vs pre-phase100,404/1,808,441); CPU160 preserved. Host/build complete, independent parent review and target/runtime checks pending. Exact bounds/failure/reboot audit/resources/checklist: `docs/phase8d20_implementation.md`. + +- **Historical 8D.20 internal HTTPS owner prerequisite (superseded by integration above):** `web_server.{c,h}` owns zero-wait `web_server_get_management_snapshot()` plus off-HTTPD `web_server_stop_current()`/`web_server_restart_current()`. Canonical mutex compares saturated lifecycle generation; restart retains transition reservation across stop/start, failures preserve canonical cleanup/recovery. Repeated init preserves lifecycle failure. No HTTP/UI/ACK/reboot integration or new production caller yet. Existing lifecycle harness now34 groups including ABA, saturation, gap interleavings and failure recovery. Baseline100,404/1,808,441 →100,404/1,808,589 B (+0/+148); unused public entry points linker-discarded. Parent/target review pending. Contract/split/remaining authorized20 and target checklist: `docs/phase8d20_implementation.md`. Do not implement unrelated19 or duplicate existing Network controls. + - **8D.18 client/writer contexts (2026-09-13):** `web_ui.c` extends8D.17's sole quick host with admin-only Broker clients/Active writer entrances to the existing Broker controller/native confirmation. One5-second-after-success live timer and5-second whole-read deadline; explicit identity/lease token retained across refresh, sticky stale/absence latches prevent rebasing/resurrection. Full-page drafts untouched by new triggers; focused controls retain focus with guarded aria-disabled state. No backend/policy/assets/CPU/transport changes. `tests/web_ui_session/{broker.cjs,layout.py}`:135 UI groups+renderer/HTML/CSP; broad broker/auth/lifecycle/transport regressions PASS. Baseline100,300/1,788,661 → final100,300/1,793,301 RAM/flash (+0/+4,640 B), CPU160 verified. Independent parent review and target sign-off pending. Exact contracts/tests/limits/checklist: `docs/phase8d18_implementation.md`. - **8D.17 quick settings (2026-09-13):** `web_ui.c` owns Serial/Wi-Fi status-trigger nonmodal popovers using the single existing settings DOM/controller, no parallel drafts/endpoints. Secret-free Network quick mode excludes password controls; full-page promotion preserves controller/nonsecret draft, dismissal fences reads/polling without replay. Hover/focus/click/tap, Escape/outside dismiss; full-page drafts protected from hover. Tests `tests/web_ui_session/{browser,network}.cjs`, `layout.py`:126 UI groups + renderer/HTML/CSP PASS; optional Chromium geometry blocked by sandbox, target/independent parent review pending. Baseline100,300/1,782,613 → final100,300/1,788,629 RAM/flash (+0/+6,016 B). CPU160/combined WS send/Broker/Display unchanged. Contract, exact tests and checklist: `docs/phase8d17_implementation.md`. diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index 4e000da..aa5ac34 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,10 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **8D.20 end-to-end integration complete (2026-09-13), host/build verified, independently reviewed; target sign-off pending:** Continuation found/preserved uncommitted backend/API/UI/tests beyond the prerequisite-only memory. Inspected and validated that integration, added send-return-at-deadline/request-storage-reuse/accepted-restart-expired-login regressions and explicit post-restart login wording. `web_lifecycle_settings.{c,h}` provides three optional current-admin routes, strict256-byte/four-receive typed stop/restart/reboot,128-byte snapshot/96-byte result, one login-bound slot/nonreused ID and one2-second ACK reservation. Synchronous send return then HTTPD ID callback then existing four-entry dispatcher, original-login/current-admin/post-validation30-second deadline, canonical generation/reserved stop/restart and narrow `esp_restart()` admission. No request/fd capture, no lifecycle on HTTPD, no lock/self-console-cleanup wait at reset. Lost HTTPD work stays singly reserved until callback or successful destruction; late/ABA work inert; failed stop retains canonical recovery. UI HTTPS/Reboot explicit confirmations,15-second whole requests, manual results, pending/unknown duplicate gate and no replay/restore; existing Network link/controller, corrected USB-vs-admin recovery wording. PASS lifecycle41, cookie lifecycle8+shared/all prior variants, dispatcher and broad console/auth/transport/store/idle/performance/SSH/Network/broker/security/parser/login/diagnostics; UI153+seven-view HTML/CSP. Final focused suites rerun after final wording. `pio run` first21.86s100,508/1,821,441; final22.85s100,508/1,821,505 B. Final delta vs pre-phase100,404/1,808,441: **+104 RAM/+13,064 flash**; vs prerequisite100,404/1,808,589: +104/+12,916. Module mutable object108 B (slot88,lock8,IDs/handle12); linked placement differs.39 handlers (36+3), six sockets; no task/timer/queue-depth/item/stack/session/ticket/heap/PSRAM/schema/config/assets/SDK/dependency/identity changes. CPU160/nonblocking HTTPD queue verified; UI18/SSH19/combined WS preserved. No device/upload/erase/commit or runtime reserve/full-M3 claim. SDK sequencing/reset inspected, not real scheduler/reset tested. Independent reviewer found no confirmed actionable defects after installed-IDF callback/reset and owner/UI audit; independently reran lifecycle41, cookie lifecycle8+shared, dispatcher, UI153+renderer/HTML/CSP and diff checks PASS. Parent final pio confirmation PASS6.82s100,508/1,821,505 B; diff check PASS. Full exact contracts, test commands/evidence, limits and pending target checklist: `docs/phase8d20_implementation.md`. The prerequisite-only entry below is historical and superseded by this integration. + +- **Historical 8D.20 lifecycle-owner prerequisite (2026-09-13), superseded by integration above:** Scope split before editing under plan600–800-line rule after finding missing HTTPS generation admission and stop/start reservation. `web_server.{c,h}` adds zero-wait secret-free management snapshot, conditional stop/restart under canonical mutex, saturated lifecycle generation across CLI ABA/counter clear and continuously reserved restart. Failed stop skips start; partial cleanup retains ownership and canonical recovery. Repeated init no longer clears retained invalid-state lifecycle failure. No new HTTP routes/UI/ACK handoff/typed reboot; these remain next authorized8D.20 integration, not complete phase delivery. Existing Network Wi-Fi controls must be reused; unrelated19 other-service and21 excluded. Initial worktree clean. PASS lifecycle34 (27+7 owner groups), broad console/auth all variants/UI143/SSH/Network/broker/store/transports/idle/performance/security/login/parser/diagnostics and diff check. Baseline pio21.10s100,404 RAM/1,808,441 flash → post-production35.05s100,404/1,808,589 (+0/+148 B); final pio20.02s same sizes, lifecycle34/diff confirmation PASS. Object generation4 B; unused public management entry points linker-discarded, so not full feature cost. CPU160 active/default/generated verified; UI18/SSH19 and combined WS source unchanged. No new task/timer/queue/depth/stack/socket/route/heap/assets/SDK/dependency/config/secret/device/upload/erase/commit changes. Self-review only; no independent reviewer tool, parent review pending. Target/runtime reserves/M3 pending. Exact owner contract, seven new fault groups, split rationale, remaining ACK/API/UI/reboot scope and target checklist: `docs/phase8d20_implementation.md`. Earlier no8D.20 wording below is historical;19 remains SSH-only completed/reviewed, other-service slice unimplemented. + - **8D.19 first service slice — SSH (2026-09-13), user-authorized; implemented/host/build verified, independently reviewed; target sign-off pending:** Admin Settings/SSH provides safe two-row status and confirmed SSH start/stop/one-session disconnect only. `web_ssh_settings` owns256-byte/four-receive request,768-byte snapshot,96-byte result and one login-bound slot/30-second dequeue deadline; existing four-entry dispatcher receives IDs only. SSH owns zero-wait published snapshot, command-mutex conditional lifecycle admission with saturated service generation (CLI stop/start ABA included), exact-ID external close and retired exhausted session slots. HTTPD never waits for SSH/wolfSSH; no new task/timer/stack/depth/socket/asset/config/dependency.36 handlers/six sockets. UI uses existing Settings styles/native confirmations, sticky stale target selection,15-second whole-request deadline and explicit manual Check Result then Refresh (no polling/replay). Invoking HTTPS login/browser terminals/Wi-Fi/USB/UART0 are not stopped. Canonical browser-shell restrictions unchanged. All web-session/HTTPS/USB actions excluded; other-service8D.19 requires separate owner/scope audit, phase incomplete; no8D.20/21. Initial worktree clean. PASS canonical SSH4, cookie SSH6+shared/all existing variants, dispatcher, lifecycle27, UI143+six-view HTML/CSP, broad accounts/Network/broker/policy/auth/transport/store/idle/diagnostics/performance/security/login and diff check. Independent reviewer found no confirmed actionable defects; canonical SSH4, cookie SSH6+shared, dispatcher, lifecycle27, UI143+renderer/HTML/CSP and diff checks independently PASS. Parent final pio confirmation PASS19.95s100,404/1,808,441 B; diff check PASS. Baseline pio6.87s100,300 RAM/1,793,301 flash; final35.31s100,404/1,808,441 (+104/+15,140 B). CPU160 defaults/active/generated confirmed;8D.18 contexts/combined binary WS path preserved. No hardware/real-browser geometry/heap/stack validation, upload/erase/commit or target/reserve/M3 sign-off. Exact API/owner race/uncertainty/resource/test limits and pending checks: `docs/phase8d19_implementation.md`. - **8D.18 implemented (2026-09-13), user-authorized; host/build verified, independently reviewed; target sign-off pending:** UI-only Broker clients/Active writer contexts reuse8D.16 snapshot/lease fence/operation controller and8D.17 single host/native confirmation/dismissal focus fix.5-second-after-success single-flight live refresh with5-second whole-read deadline; errors/timeouts stop, no auto error/mutation retry. Explicit selection identity+lease token is never rebased; stale/absent latches prevent resurrection, consumed selection requires explicit reselection. New triggers preserve full-page Serial/account/Network drafts even on activation; quick promotion preserves selection/controller and stops live timer. Focused controls use aria-disabled plus existing handler guards rather than native focus loss; previous terminals keep receiving with input isolated. Initial worktree clean. PASS135 UI groups+renderer/HTML/CSP, broker management/wrap, cookie Broker+base/all existing variants, lifecycle25, dispatcher/canonical accounts/Network, parser294, transports/tickets/store, idle/diagnostics/performance/security/login and diff check. Broad tests preceded final focus refinement; full UI/build rerun afterward. Independent reviewer found no confirmed actionable defects and reran UI135+renderer/HTML/CSP, canonical broker management/wrap, cookie Broker and diff checks PASS. Parent final pio confirmation PASS6.89s100,300/1,793,301 B; diff check PASS. Native picker during live updates and actual blank/reselect remain browser-validation gaps, not proven defects. Baseline pio7.02s100,300 RAM/1,788,661 flash; final21.52s100,300/1,793,301 (+0/+4,640 B). CPU160 defaults/active/generated verified, combined WS send unchanged. No backend/API/policy/assets/config/dependencies/icons/8D.15/19/later/upload/erase/commit/SDK changes. No real-browser geometry/accessibility or device tests this phase; no reserve/M3 claim. Exact contract, test-development corrections and pending parent/target checklist: `docs/phase8d18_implementation.md`. Stop before any separately authorized next phase. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index 203c14e..f6fcc48 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -104,6 +104,10 @@ Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs ## Selected self-affecting admin SSH actions use bounded deferred control +**8D.20 integrated typed HTTPS/reboot decision (supersedes prerequisite-only status below):** A successful synchronous HTTP response send return is the ACK boundary, not peer receipt. Queue exactly one nonreused-ID HTTPD callback after sending; that callback only submits an ID to the existing dispatcher, never waits or runs lifecycle. No captured request/fd/reusable slot pointer. A lost callback remains reserved even after its two-second admission deadline; only its arrival or successful HTTPD destruction releases that reservation. Do not retry queue submission or permit callback accumulation. Original-login/current-admin/post-validation30-second deadline precede canonical generation-conditional owner admission; no cancellation claim after admission, including detach failures and deliberately login-invalidating reserved restart. Typed reboot uses shared `esp_restart()` outside locks after HTTPS generation reservation, not a console string/self-cleanup wait or new runner. UI requires explicit confirmation, fences15-second whole requests and late results, retains unknown/duplicate gates across navigation, never restores/retries mutations and requires fresh login after HTTPS restart. Network controls remain the sole Wi-Fi domain; USB is UART1 serial recovery, not administration or uninterrupted whole-device reboot. Exact contracts and target limits: `docs/phase8d20_implementation.md`. + +**Historical 8D.20 HTTPS owner prerequisite (superseded above):** Conditional HTTPS stop/restart compares an expected saturated lifecycle generation under the canonical server mutex, not snapshot-check-unlock-unconditional-stop. Restart retains transition ownership through stop and start; a failed stop never admits start, and failed cleanup requires canonical recovery. Repeated init must not clear the retained lifecycle failure; counter clear must not reset generation. The zero-wait management projection does not authorize a request or prove reachability. All lifecycle execution remains off HTTPD and outside the server mutex during owner waits. Future typed ACK handling must precede admission on the existing dispatcher, with original-login currentness; later revocation is not cancellation of an admitted restart. No ACK/API/UI/reboot integration exists in this prerequisite, and the full phase remains incomplete. `src/web_server.{c,h}`, `docs/phase8d20_implementation.md`. + **Decision:** Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset are deferred until command state and administration/transport application buffers appear drained, with a ten-second limit and short final delay. **Rationale/evidence:** `admin_ssh_console` has a separate bounded control task and pending-action state. The check is a best-effort application-buffer heuristic, not peer-delivery confirmation. User account mutations and their immediate revocation calls do not use this path. diff --git a/docs/phase8d20_implementation.md b/docs/phase8d20_implementation.md new file mode 100644 index 0000000..614a08f --- /dev/null +++ b/docs/phase8d20_implementation.md @@ -0,0 +1,163 @@ +# Phase 8D.20 — confirmed HTTPS lifecycle and reboot + +Date: 2026-09-13. **The authorized HTTPS stop/restart/reboot API, ACK handoff, dispatcher integration and Settings UI are implemented and host-tested/build-verified. Independent review complete with no confirmed actionable defects; target validation remains pending.** Existing Network controls are reused, not duplicated. No unrelated remaining 8D.19 domain or 8D.21 identity work is included. + +Independent reviewer verified installed-IDF callback/request ordering, nonreused IDs and lost-work reservation, original-login/deadline checks, canonical lifecycle ABA and failure handling, dispatcher/HTTPD lock separation, reboot shutdown path and UI duplicate gates. Reviewer independently reran lifecycle41, cookie lifecycle8+shared, dispatcher, UI153+renderer/HTML/CSP and diff checks PASS. Parent final `pio run` confirmation PASS6.82s,100,508 B RAM/1,821,505 B flash; diff check PASS. These are host/source/build checks, not real TLS/UDP/reset or runtime-reserve validation. + +## End-to-end integration contract (supersedes prerequisite-only status below) + +The continuation session found uncommitted integration source/tests already present beyond the prerequisite described by project memory. It preserved that work, inspected the actual implementation, ran all focused/broad checks, extended send-return/request-lifetime/relogin tests, clarified post-restart session expiry in the UI and completed these records. The previous prerequisite evidence is retained below as history; it is not the final feature cost. + +### API and authorization + +`web_lifecycle_settings.{c,h}` owns three optional exact routes: current-admin bodyless GET `/api/settings/lifecycle`, GET `/api/settings/lifecycle-operation`, and JSON POST `/api/settings/lifecycle-operation`. Existing cookie policy enforces the original live login/current principal, admin role, bounded framing, no query/body on reads, and Origin/CSRF on mutations. No CLI string, password, ticket, private key, identity mutation or new authentication bypass is exposed. + +- Snapshot: `{generation,running,transitioning,controllable}`, 128-byte output buffer, zero-wait canonical owner copy. Busy/unavailable returns 503. +- POST: exactly `{action,generation}` with action `stop`, `restart` or `reboot`, unsigned nonzero/nonexhausted generation; no duplicate/extra fields, escapes, coercion or numeric overflow. At most 256 body bytes and four receives. Body/request scratch is wiped. Parser errors are 400; occupied/exhausted admission is 503. +- Accepted response: 202 with `{id,action,state:"pending"}`. This acknowledges **pending admission**, not execution, completion or peer receipt. Result buffer is 96 bytes; reads expose only the invoking session's slot, otherwise `{id:0,action:"none",state:"idle"}`. States are `idle`, `pending`, `ok`, `failed`, `cancelled`; executing is projected as pending. Result IDs are not secrets or authorization tokens. +- One static operation/result slot and one firmware-lifetime non-reused uint32 ID sequence. Exhaustion rejects until reboot; successful HTTPS restart does not reset IDs. Pending/executing work cannot be overwritten. Completed results are replaceable, not durable history or an idempotency/replay facility. + +### ACK ownership, sequencing and failure bounds + +1. HTTPD authenticates/parses and reserves the slot with the copied session/principal, expected HTTPS generation, two-second ACK deadline and 30-second total dequeue-admission deadline. +2. The synchronous response send must return successfully before one `httpd_queue_work()` submission. The callback argument contains only the ID cast through `uintptr_t`: no request, response, fd, socket DB or reusable operation pointer. The saved server handle is comparison-only: its matching callback clears it, or successful HTTPD destruction retires the lost reservation. +3. HTTPD invokes `ack_handoff` after the synchronous handler has returned. It checks/consumes the exact ACK ID, rejects expired work and submits only the operation ID with zero wait on the existing four-entry administration queue. It does no lifecycle work, socket IO, authentication or waiting. The dispatcher may be scheduled before this short callback returns; the callback holds no lock across queue submission and never waits for that dispatcher. +4. The existing dispatcher reserves the executing slot, copies/wipes shared principal material, validates the **original** session/principal/current admin, checks the 30-second deadline again after validation, then invokes the generation-conditional owner API outside all slot/console locks. A newer login for the same account never substitutes. Ordinary HTTP socket loss does not itself revoke the login or cancel admitted work. +5. Stop/restart uses canonical owner orchestration and its continuously held transition reservation. Once admitted, restart deliberately invalidates the old login and still completes its reserved start without revalidating that invalidated login. Owner failure is `failed`, not `cancelled`: even `ESP_ERR_INVALID_STATE` can follow auth invalidation/detach side effects. `cancelled` is reserved for rejection before owner admission. + +Send failure never queues a callback. HTTPD queue failure or dispatcher queue failure cancels before admission without retry. An accepted-but-lost HTTPD callback keeps **one** ACK reservation, even after its operation deadline expires: subsequent POSTs fail busy rather than accumulating delayed callbacks. A late callback clears only its own reservation and cannot execute expired work. Successful canonical HTTPD destruction retires the reservation before a replacement server starts; failed destruction retains it. Same-handle/request-storage reuse and duplicate/old IDs are inert. Canonical UART0/reachable SSH `web stop` then `web start` is the recovery for a lost reservation; this closes every web client. A delayed callback that eventually arrives can also release its reservation. + +Deadlines are checked lazily on callback, dequeue and API activity; there is no new firmware timer and no hard wall-clock cancellation/erasure promise. The two-second ACK phase includes synchronous response sending; a send returning at/after the deadline cannot admit work. Socket timeout, scheduling delay, successful send, aborted fetch or missing result proves neither peer receipt nor cancellation of an already-admitted action. HTTPD shutdown/start and reset are not bounded by these admission deadlines. + +### SDK and reboot audit + +Installed ESP-IDF 5.5.0 `httpd_main.c` was inspected: `httpd_queue_work` sends a control message, `httpd_process_ctrl_msg` calls it on HTTPD, and request/session processing is synchronous on that same owner. No inline callback from queue submission. Existing `CONFIG_HTTPD_QUEUE_WORK_BLOCKING` is disabled in defaults/active/generated configuration; the new module fails compilation if enabled. No private HTTPD access was added outside the established adapter. Real UDP loss, TLS timing and task scheduling remain target tests, not established by dependency doubles. + +`web_server_reboot_current()` checks zero-wait canonical mutex/state/generation, reserves transition and advances generation, releases the mutex, then calls **the same `esp_restart()` API** used by `system_console` UART0, SSH owner, browser-admin owner and local reboot. No duplicate reboot runner or CLI dispatch. Source audit found no application shutdown-handler registration and no typed operation console-session pending/self-cleanup reservation. The dispatcher typed branch executes outside the console lock before ordinary console-session execution handling. ESP-IDF reset invokes its registered shutdown handlers then the no-OS reset; the typed path does not synchronously stop HTTPD first. Unexpected reset return reports failure and deliberately retains the transition reservation to prevent duplicate admission. Target reset/SDK shutdown behavior is unmeasured. + +### UI and recovery contract + +`web_ui.c` adds admin-only Settings → **HTTPS / Reboot**, using existing Settings navigation, same-session validation, bounded API reader and native explicit confirmations. Opening/refreshing/selecting does not mutate or change either terminal/broker lease. Service-wide confirmations include clients admitted after the snapshot and before execution, all web logins/both browser terminal routes, and unchanged identity/device working configuration for HTTPS-only actions. Reboot warns that all clients, SSH, USB and UART operation are interrupted and unsaved RAM-only working configuration can be lost. No saved configuration/identity reset is performed. + +Requests have a single-flight 15-second whole-request deadline including session validation, headers and body, with controller/generation/navigation fences. Pending/unknown outcomes block duplicate submission across navigation/pagehide. Results are checked explicitly, not automatically polled. A lost ACK or replaced/mismatched ID/action remains uncertain; Refresh cannot clear that uncertainty or replay the operation. Known matched terminal results still require a fresh snapshot before another explicit confirmation. There is no automatic mutation retry, persistence, restored mutation or fresh-login result adoption. HTTPS restart expires the login; reload and explicitly sign in after recovery. Existing session-expiry handling closes both sockets and fences stale responses/401s. + +The view links directly to the **existing Network** controller. Its backend and mutation/check logic are unchanged; existing bounded confirms, generation/current-login checks, uncertainty handling and no-replay tests pass. Network help/confirmation wording now accurately distinguishes restoring STA/AP reachability/UART0 administration from native USB's network-independent **UART1 serial**, not Wi-Fi or web administration. SSH recovery requires that SSH still runs and is reachable; Wi-Fi loss can remove it. USB is not promised uninterrupted access through whole-device reboot. + +### Validation performed in the continuation + +All commands passed with finite tool timeouts; no timeout or failed build occurred: + +- `python3 tests/web_admin_transport/server_lifecycle.py`: **41 groups**, 34 prior + seven integration groups. Extracted real owner and ACK/execute functions are joined for canonical stop/reserved restart/reboot, ABA during validation, every stop failure, lost reservation through failed/successful destruction, same-server reuse, expiry/queue/revocation, reset admission/defensive return and all six optional route allocation positions plus unregister failure/restart. +- `python3 tests/web_cookie_auth/run.py --lifecycle`: **eight lifecycle groups plus shared auth**. Real handlers/parser/store, dependency doubles for HTTPD queue/owner/dispatcher; strict policy/parser/bounds, send/queue failures, ACK and dequeue/post-validation deadlines, original-login revocation races, duplicates, lost/late callback/ID exhaustion, admitted revocation and uncertain owner failure. Added send-return-at-deadline rejection and overwritten request/connection/scratch storage before callback/dispatch, demonstrating no retained request pointer. +- `python3 tests/admin_console_boundary/run.py`: real typed dispatcher ID routing, not-ready/full queue, unchanged four-entry queue and no command runner; existing owner/self-detach/deferred regressions. +- `python3 tests/web_ui_session/run.py`: **153 browser groups** (143 prior + ten lifecycle), seven-view renderer/HTML/CSP. Includes bounded late headers/body/session/ACK/401, navigation/pagehide, strict schemas, no mutation on view/Network link, confirmation/recovery wording, duplicate/unknown gating, both terminal drains, explicit accepted restart followed by expired login and fresh-document no-replay. +- `python3 tests/admin_console_boundary/lifecycle.py`, `accounts.py`; `tests/admin_ssh_policy/run.py`. +- `python3 tests/web_cookie_auth/run.py` and each existing variant `--ssh`, `--network`, `--accounts`, `--serial-settings`, `--broker`, `--display`, `--admin`, `--settings`. +- `python3 tests/web_admin_transport/run.py --tickets`, `tests/web_admin_tickets/run.py`, `tests/web_httpd_idle/run.py`, `tests/web_serial_performance/run.py`. +- `python3 tests/web_session_store/run.py` and `--serial`; `tests/ssh_management/run.py`, `tests/web_network_settings/run.py`, `tests/session_broker_diagnostics/run.py`, `tests/web_auth_parse/run.py` (294 cases), `tests/web_diagnostics/run.py`, `tests/web_security/run.py`, `tests/web_login_ui/run.py`. +- `git --no-pager diff --check`, source self-review and bounded `pio run`. Final resource confirmation follows below. + +These are host/renderer tests with controlled boundary failures, not real HTTPD scheduling/TLS/on-wire delivery, physical reboot, browser geometry, hardware/full-mix throughput or independent review. No upload/erase/commit/device action, identity/SDK/dependency/generated-asset change or full M3 sign-off. + +### Integrated resource evidence + +Reference before 8D.20: **100,404 B RAM / 1,808,441 B flash** (previous agent's measured baseline). Prerequisite build: **100,404 / 1,808,589 B**. Continuation first `pio run` passed in **21.86 s**, **100,508 / 1,821,441 B** before the final explicit post-restart-login wording. This is **+104/+13,000 B** versus pre-phase baseline, **+104/+12,852 B** versus prerequisite. Final wording/build measurement is recorded separately below; the original baseline was not recreated by reverting uncommitted work. + +Target `nm -S` for the new module: operation slot **88 B**, lock **8 B**, ID sequence/ACK ID/server comparison handle **4 B each** = **108 B mutable object storage** (linked RAM delta includes alignment/placement, not simply the object sum). Read-only action/state pointer arrays **12/24 B**. No module heap/PSRAM allocation or internal fallback. Handler locals include one operation copy, authenticated session view,256-byte body,40-byte content type and96-byte response; snapshot response128 B; dispatcher has one operation copy. Compiler stack frames/runtime high-water remain unmeasured. + +Handlers increase **36→39** (three optional routes and three table pointers plus HTTPD-owned descriptor/URI allocations). Six HTTPS sockets, two serial slots, one browser-admin slot, four cookie sessions, existing ticket capacities, four-entry dispatcher/item capacity, task count/priorities/stacks and firmware timers remain unchanged. At most one lifecycle HTTPD control work item and one operation ID on the existing dispatcher; lost HTTPD work stays reserved rather than growing queued work. Optional route allocation failure cannot take down other domains. CPU160 defaults/active/generated and nonblocking HTTPD queue configuration verified; no CPU/config/throughput tuning. Static size is not approval of runtime heap/largest-block/HTTPD or dispatcher stack reserves. + +### Final confirmation + +After the explicit post-restart session-expiry UI wording, `pio run` passed in **22.85 s** (120,000 ms timeout): **100,508 B linked RAM / 1,821,505 B flash**. Final total delta versus pre-phase100,404/1,808,441 is **+104 B RAM / +13,064 B flash**; versus prerequisite100,404/1,808,589 it is **+104/+12,916 B**. The final UI153, cookie lifecycle8+shared, owner/pipeline41 and dispatcher suites were rerun and passed after the wording change. Broad regressions above passed before that wording-only change. No build timed out. Independent parent review is the next handoff, not represented by these self-checks. + +## Historical prerequisite record + +The following sections preserve the previous internal-slice evidence. Statements that integration was unimplemented describe that earlier checkpoint only. + +## Scope and split decision + +Read agent code-map, relevant architecture/design decisions, current-state, exact phase8d_plan 8D.20 row and work-unit/resource rules. Inspected `main.c`, canonical system reboot, administration dispatcher, browser-admin owner lifecycle, existing SSH typed backend/UI and existing Network confirmations. Initial worktree was clean. + +The explicit user request permits proceeding with 8D.20 despite 8D.19 being only the completed/reviewed SSH slice. No unrelated other-service 8D.19 prerequisite is invented. Existing Network already owns Wi-Fi controls and must not be duplicated. + +The concrete missing HTTPS prerequisite was generation-checked public lifecycle admission and a restart reservation spanning stop through start. Existing `web_server_stop()` followed by `web_server_start()` has a lifecycle-admission gap and no expected-generation argument. A snapshot comparison followed by unconditional stop would allow stale work to affect a replacement server. + +Before editing, split under the plan's 600–800-authored-line scope threshold: owner adaptation, bounded HTTPD ACK/dispatcher handoff, authenticated typed routes, UI session/uncertainty handling and their owner/HTTP/UI fault suites were estimated to exceed one bounded chunk. Selected an internal prerequisite, which the plan permits, rather than exposing partially protected routes or omitting acceptance tests. This is **not delivery of the requested full API/UI feature**. The remaining complete integration is listed below. + +Changed production files: `src/web_server.{c,h}` only. Extended existing `tests/web_admin_transport/server_lifecycle.py`; related phase/agent documents updated. No new task, runner, queue, timer, route, dispatcher item, stack size, socket/session/ticket capacity, NVS schema, configuration, dependency, SDK or asset change. No reboot/identity/certificate/key/factory-reset operation added. Existing 8D.18/UI and SSH19, signed-off CPU160 combined binary WebSocket send, Network, UART0 recovery and USB ownership are preserved. + +## Public owner contract + +- `web_server_get_management_snapshot()` is a zero-wait, secret-free projection `{generation, running, transitioning, controllable}` using the existing server mutex. Failure clears the destination; null is invalid argument, uninitialized owner is invalid state, mutex contention is timeout. It performs no HTTPD work, socket query, task scan, authentication or persistence. +- `web_server_stop_current(expected_generation)` and `web_server_restart_current(expected_generation)` acquire the existing mutex with zero wait, then atomically compare generation and lifecycle state before canonical stop admission. Zero generation is invalid argument. Stale/saturated generation, absent server, transition or retained lifecycle failure is invalid state without lifecycle side effects. +- These APIs are **off-HTTPD only**, just like ordinary start/stop. A future caller must authorize the original login/current admin, complete its bounded ACK handoff and invoke from the existing dispatcher. Neither lifecycle API authenticates, sends an ACK, waits a bounded time for HTTPD, or cancels admitted work after revocation. +- Restart uses the same canonical stop implementation. On successful stop it keeps `s_transitioning` reserved while clearing the old handle, then invokes the private reserved start path. Competing canonical/conditional lifecycle callers cannot acquire the stopped-to-start gap. The server mutex is released around authentication, transport detach, TLS start/stop, registration and external callbacks; it is not held while waiting for HTTPD. +- Any idle/admin/serial hard-detach or SSL-stop failure skips start, releases the transition reservation, retains existing ownership/error semantics and makes conditional controls unavailable. Canonical `web_server_stop()` remains available for recovery. Existing serial-detach timeout semantics are unchanged: this timeout permits SSL destruction, then restart after successful destruction. +- Start-side failure releases the reservation. If cleanup fails, the partial server remains owned and canonical stop must clean it before another start. No identity/configuration rollback or automatic retry occurs. +- Repeated `web_server_init()` no longer clears a retained `ESP_ERR_INVALID_STATE` lifecycle error. Only first initialization resets its initial sentinel, preventing an unsuccessful recovery start/init from making failed cleanup appear controllable. + +### Generation and completion limits + +One firmware-lifetime uint32 generation begins at 1. Every admitted canonical or conditional start/stop advances it, including an admission that later fails. Rejected calls do not advance it; counter clear and repeated init do not reset it. A successful restart normally advances twice. Same HTTPD-handle reuse and canonical stop/start ABA cannot restore an old confirmation. + +Generation saturates at UINT32_MAX. Conditional admission then fails closed, while ordinary start/stop recovery remains available. A restart already admitted at UINT32_MAX−1 can complete its reserved start at saturation; no new conditional action is admitted with the saturated token. Reboot will reset firmware state and invalidate all old browser sessions; no typed reboot admission is part of this slice. + +`controllable` means a running, nontransitioning server with a successful last lifecycle result and a nonexhausted generation. It does not establish network reachability, response receipt, browser-session validity, or a hard shutdown deadline. A lifecycle failure may already have invalidated all web logins. Once stop is admitted, later logout/revocation/timeout must not be reported as cancellation of that stop or its reserved restart. + +## Validation actually performed + +All commands below passed, each terminal group with a finite timeout. No build timed out. + +- `python3 tests/web_admin_transport/server_lifecycle.py`: **34 groups**, 27 existing plus seven new owner groups. Compiles extracted production lifecycle functions, public header and real URI initializers against bounded dependency doubles. +- `python3 tests/admin_console_boundary/run.py`, `lifecycle.py`, `accounts.py`; `python3 tests/admin_ssh_policy/run.py`. +- `python3 tests/web_admin_transport/run.py --tickets`; `python3 tests/web_admin_tickets/run.py`. +- `python3 tests/web_httpd_idle/run.py`; `python3 tests/web_serial_performance/run.py` (existing combined-send/SDK-boundary regressions). +- `python3 tests/web_session_store/run.py`, and `--serial`. +- `python3 tests/web_ui_session/run.py`: existing 143 browser groups plus renderer/HTML/CSP checks. No UI edits or new 8D.20 UI test claims. +- `python3 tests/web_cookie_auth/run.py` and each existing variant: `--ssh`, `--network`, `--accounts`, `--serial-settings`, `--broker`, `--display`, `--admin`, `--settings`. +- `python3 tests/ssh_management/run.py`, `tests/web_network_settings/run.py`, `tests/session_broker_diagnostics/run.py`, `tests/web_auth_parse/run.py`, `tests/web_diagnostics/run.py`, `tests/web_security/run.py`, `tests/web_login_ui/run.py`. +- `git --no-pager diff --check`; production diff self-review. Final lifecycle34/diff rerun and bounded build confirmation also passed. This is not independent parent review. + +New owner cases cover preinit/null/zero arguments; zero-wait mutex contention and cleared snapshots; same-handle canonical ABA; counter clear; successful stop; reserved restart with deterministic competing canonical and conditional calls at the released-mutex gap; each stop failure stage; repeated init preserving cleanup failure; six restart start-side failures including failed partial-server cleanup; saturation without wrap; transition rejection; and inherited serial timeout behavior. All external lifecycle doubles assert that the server mutex is not held where required. Failure recovery uses actual canonical orchestration, not replacement management functions. + +Limits: no real TLS/HTTPD scheduler, device sockets, physical reboot, browser geometry, queue/ACK pipeline, original-login typed admission, UI stale-result or typed reboot tests were added or claimed. Existing idle/admin/store tests exercise their own callback/lifecycle fences, **not an unimplemented 8D.20 ACK handoff**. No secrets are added to production projections/logs or new test output. No independent reviewer tool is available in this session; parent review is pending, not simulated by self-review. + +## Build and resource evidence + +`pio run` timeout: 120,000 ms. + +| Measurement | Time | Linked RAM | Flash | +|---|---:|---:|---:| +| Actual pre-edit baseline | 21.10 s | 100,404 B | 1,808,441 B | +| Post-production-change build | 35.05 s | 100,404 B | 1,808,589 B | +| Final confirmation | 20.02 s | 100,404 B | 1,808,589 B | +| Delta | — | +0 B | +148 B | + +Target object `nm -S` reports `s_generation` 4 B, management snapshot function 165 B, conditional wrappers 26 B each. The public management entry points have no production callers yet and are linker-discarded; the linked delta is **not** the eventual end-to-end feature cost. The additional generation exists in the firmware, with placement yielding no net linked-RAM increase. No heap/PSRAM allocation is introduced. The new management projection has four scalar fields; no request/response buffer exists yet. Existing 36 handlers, six HTTPS sockets, two web serial slots, one browser-admin socket, four cookie sessions and four-entry dispatcher remain unchanged. + +CPU160 was checked in `sdkconfig.defaults`, active sdkconfig and generated sdkconfig.h. PlatformIO's generic board banner says 240 MHz; it does not override the verified generated configuration. No CPU tuning occurred. Runtime HTTPD/dispatcher stack floors, heap minima/largest blocks and reserve approval remain unmeasured; static size is not runtime reserve evidence. + +## Historical remaining-integration checklist — now implemented above + +1. Add one narrow bounded HTTPS/reboot operation domain on the existing dispatcher, with optional current-admin cookie/Origin/CSRF routes and a secret-free bounded snapshot/result. Do not introduce a generic runner or increase queue/task budgets. +2. Specify and test ACK handoff before stop/restart/reboot: HTTPD must never wait for its own shutdown. Owner completion must be bounded/generation-qualified without request/raw-fd lifetime capture. Handle response send failure, queue failure, accepted-but-lost/delayed callback, expired/revoked original login, shutdown and callback ABA. A late callback must not revive a retired action or authorize a replacement login/server. A bounded ACK phase is not a bounded lifecycle completion or peer-receipt guarantee. +3. Revalidate original login, principal/current admin and dequeue deadline immediately before action admission. Use the new conditional owner APIs for HTTPS stop/restart, and establish narrow canonical reboot admission rather than executing a CLI string. Do not revalidate a deliberately invalidated login between the already-admitted stop and its reserved start. No certificate/key/identity/reset action. +4. Add coherent Settings API/UI with explicit service-wide confirmations, warning that all affected clients—including clients admitted after the snapshot—disconnect. HTTPS stop needs UART0 or still-running reachable admin SSH `web start`; native USB remains UART1 serial, not an administrative web-start route. Reboot affects the entire device, including SSH, USB and UART operation during restart, loses unsaved RAM-only working configuration, and does not imply uninterrupted recovery links. +5. Reuse existing Network controls instead of duplicating Wi-Fi. Audit their confirmation/recovery wording and uncertainty behavior within this phase: Wi-Fi disruption can also remove SSH reachability. USB preserves network-independent UART1 access for network-only operations, not administration or uninterrupted reboot. +6. Bound pending/result and browser request lifetimes. Disable duplicate submission during pending/unknown outcomes; never automatically retry or restore a mutation. Original-login/result-ID/session fences must prevent old responses from being adopted after reconnect. A missing ACK, timeout, revocation, connection loss or result replacement is not evidence that admitted work was cancelled. Recovery reads and explicit reconnect/relogin must not replay an operation. +7. Add real production-path owner/dispatcher/HTTP/UI tests for the above, rerun regressions/build, measure incremental linked/object resources, and request independent parent review. No 8D.21 or unrelated remaining 8D.19 work. + +## Pending target and review checklist + +- [ ] Independent parent review of this prerequisite and later integrated ACK/API/UI work. +- [ ] Canonical UART0 and SSH web stop/start/recovery after these internal changes. +- [ ] Target typed HTTPS stop/restart/reboot flows (integration now implemented and host-tested). +- [ ] Slow/lost ACK, delayed callback, busy dispatcher, expired/revoked login and failed stop target fault cases where practical; no stale execution/replay. +- [ ] HTTPS-stop recovery through UART0/reachable SSH; explicit fresh web login after restart, no old operation restore. +- [ ] Reboot/all-client/USB interruption and unsaved-working-state warnings verified against target behavior. +- [ ] Existing Network Wi-Fi loss/recovery without duplicated controls or false USB-admin claims. +- [ ] Existing UI18/SSH19/Network/auth/logout and full supported client mix at signed-off CPU160/combined WS send; broker byte/drop accounting. +- [ ] Settled boot/full-mix/recovery memory minima, largest blocks and HTTPD/dispatcher stack margins; no invented reserves. + +No device operation, upload, erase, commit, target sign-off, full 8D.20 completion or M3 claim. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index 4101fd8..5a07649 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -191,11 +191,13 @@ Typed operations must preserve subsystem owner/lock/persistence contracts and co | **8D.17 — Serial/Wi-Fi quick popovers** | **Implemented, host/build verified; independent parent review and target sign-off pending.** UI-only reuse of completed typed endpoints through one settings DOM/controller, full-page link and shared validation; `web_ui`. [Contract/evidence](phase8d17_implementation.md). | Hover/focus/click/tap, Escape/outside dismissal, no opening/selection mutation, explicit Apply/Save, Network password exclusion; draft/session/pending-operation regressions pass. Full-page hover deliberately preserves its existing draft; explicit activation navigates that page. Optional geometry blocked by sandbox; real pointer/keyboard/touch/full-mix checklist pending. No duplicate backend/new settings scope/icons/8D.18. | | **8D.18 — Client/writer contextual dialogs** | **Implemented, host/build verified; independent parent review and target sign-off pending.** UI-only reuse of8D.16 and8D.17's single host for live client popover and confirmed Active writer dialog. [Contract/tests/checklist](phase8d18_implementation.md). | Native pointer/keyboard/touch entrances; single-flight5-second live refresh/deadline, explicit selection preserved without lease-token renewal, sticky stale/absent rejection, full-page draft protection and focus-safe updates. Ordinary users retain only ordinary status.135 UI groups plus broad broker/auth/lifecycle regressions pass; real browser/device checks pending. No new writer policy/backend/icons/8D.19/later. | | **8D.19 — Ordinary service/session controls** | **First service slice SSH implemented, host/build verified; independent parent review and target sign-off pending. Phase incomplete.** Typed SSH status and confirmed exact-session disconnect/start/stop via existing dispatcher/SSH owner, excluding invoking HTTPS-session-cutting actions. [SSH contract/tests/resources](phase8d19_implementation.md). | Explicit SSH/all-SSH/one-session confirmation; owner lock/service generation/retired session IDs reject stale/reused targets and stop/start ABA. No settings/identity clear. SSH4, cookie SSH6+shared, dispatcher, lifecycle27 and UI143 PASS. Split-by-service rule applied: all web-session/HTTPS/USB controls excluded; next other-session web slice requires explicit login/owner-safety audit, USB actions are not promised. No generic broker disconnect or8D.20/21. Target full-mix/heap/stack/recovery checks pending. | -| **8D.20 — Self-affecting service actions and reboot** | Confirmed typed operations for connection-losing HTTPS/Wi-Fi/service actions and reboot, reusing lifecycle behavior established in 8D.7/8D.13. | Acknowledgement/pending-action handling is bounded, no duplicate action on UI retry, connection loss is explained, recovery and reconnect work. No new unbounded queue or certificate/key rotation yet. | +| **8D.20 — Self-affecting service actions and reboot** | **User-authorized HTTPS stop/restart/reboot integration implemented, host/build verified; independent parent review and target sign-off pending.** Current-admin typed routes, bounded send-return/HTTPD ID callback/existing dispatcher handoff, canonical generation/reserved lifecycle and shared reset API; explicit Settings HTTPS/Reboot and link to existing Network. [Contracts, tests, costs and checklist](phase8d20_implementation.md). | PASS lifecycle41, cookie lifecycle8+shared, UI153+HTML/CSP, dispatcher and broad regressions. Tests cover queue/send/lost/late callback/request-lifetime/ABA/login revocation/deadlines/owner failures, no replay/late result adoption, all-client/unsaved-state and accurate UART0/SSH/USB recovery. Final pio100,508 RAM/1,821,505 flash (+104/+13,064 vs pre-phase).39 handlers/six sockets, no new tasks/timers/queue growth/assets/config/identity or unrelated19/21. Real TLS/scheduling/reboot/full-mix/runtime reserves and independent review remain pending. | | **8D.21 — Security/danger-zone settings** | Carefully separated HTTPS/SSH identity rotation/reset and any explicitly retained recovery-secret operation through existing security APIs. Enumerate allowed operations first; split HTTPS and SSH work into separate requests if both need owner changes. | Confirmation, secret-safe one-time responses, no routine private-key export, expected trust/fingerprint changes, bounded self-disconnect, no accidental configuration wipe. Bootstrap/unavailable-database recovery remain UART0-only. NVS encryption, secure boot, OTA, and new factory-reset semantics stay out of scope. | **Scope decision (2026-09-09):** Phase 8D.15 has been removed at the user's request. Network diagnostics remain exclusive to the admin shell; no dedicated typed diagnostic endpoints or settings UI are planned. Existing shell transport permissions and implemented Network settings/status remain unchanged. Later phase numbers are retained for stable references; the next planned chunk after 8D.14 is 8D.16, requiring a separate implementation request. +**8D.20 authorization clarification (2026-09-13):** The user explicitly authorized20 with knowledge that19 completed/reviewed only its SSH slice. Proceed using needed established prerequisites; do not implement unrelated other-service19 as a dependency. The internal owner prerequisite was not full20 delivery; the explicitly authorized continuation now completes API/ACK/dispatcher/UI integration and records pending independent review/target checks in the linked contract. + Dependencies: 8D.8 establishes only the minimal typed-request/UI pattern. 8D.9 follows 8D.8; 8D.11 follows 8D.10; 8D.13 follows 8D.12; 8D.17 follows 8D.9/8D.13; 8D.18 follows 8D.16; 8D.20 follows 8D.7/8D.13/8D.19; 8D.21 follows 8D.20. Independent domains can be reordered by the user, but should not be implemented concurrently against shared `web_server`/`web_ui` files. ## 8D.22 — Final integration acceptance and documentation diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4711f64..5c1b2ca 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -37,6 +37,7 @@ idf_component_register( "web_display_settings.c" "web_broker_settings.c" "web_ssh_settings.c" + "web_lifecycle_settings.c" "web_admin_tickets.c" "web_admin_transport.c" "web_assets_data.c" diff --git a/src/admin_ssh_console.c b/src/admin_ssh_console.c index 5b0ee57..f0b8a47 100644 --- a/src/admin_ssh_console.c +++ b/src/admin_ssh_console.c @@ -22,6 +22,7 @@ #include "web_display_settings.h" #include "web_broker_settings.h" #include "web_ssh_settings.h" +#include "web_lifecycle_settings.h" #define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U #define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U @@ -93,6 +94,7 @@ typedef enum { ADMIN_REQUEST_DISPLAY_SETTINGS, ADMIN_REQUEST_BROKER_SETTINGS, ADMIN_REQUEST_SSH_SETTINGS, + ADMIN_REQUEST_LIFECYCLE_SETTINGS, } admin_request_origin_t; typedef struct { @@ -109,6 +111,7 @@ typedef struct { uint32_t display_settings_id; uint32_t broker_settings_id; uint32_t ssh_settings_id; + uint32_t lifecycle_settings_id; }; } admin_request_t; @@ -724,6 +727,16 @@ esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id) return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; } +esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id) +{ + taskENTER_CRITICAL(&s_lock); + bool ready = s_dispatch_ready; + taskEXIT_CRITICAL(&s_lock); + if (!ready || !id) return ESP_ERR_INVALID_STATE; + admin_request_t request = {.origin = ADMIN_REQUEST_LIFECYCLE_SETTINGS, .lifecycle_settings_id = id}; + return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; +} + static void worker_task(void *context) { (void)context; @@ -734,13 +747,15 @@ static void worker_task(void *context) } 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_BROKER_SETTINGS || request.origin == ADMIN_REQUEST_SSH_SETTINGS) { + request.origin == ADMIN_REQUEST_BROKER_SETTINGS || request.origin == ADMIN_REQUEST_SSH_SETTINGS || + request.origin == ADMIN_REQUEST_LIFECYCLE_SETTINGS) { if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id); else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id); else if (request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) web_network_settings_execute(request.network_settings_id); else if (request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) web_display_settings_execute(request.display_settings_id); else if (request.origin == ADMIN_REQUEST_BROKER_SETTINGS) web_broker_settings_execute(request.broker_settings_id); - else web_ssh_settings_execute(request.ssh_settings_id); + else if (request.origin == ADMIN_REQUEST_SSH_SETTINGS) web_ssh_settings_execute(request.ssh_settings_id); + else web_lifecycle_settings_execute(request.lifecycle_settings_id); secure_wipe(&request, sizeof(request)); continue; } diff --git a/src/admin_ssh_console.h b/src/admin_ssh_console.h index d7efff1..c99c940 100644 --- a/src/admin_ssh_console.h +++ b/src/admin_ssh_console.h @@ -21,6 +21,7 @@ esp_err_t admin_ssh_console_submit_network_settings(uint32_t id); esp_err_t admin_ssh_console_submit_display_settings(uint32_t id); esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id); esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id); +esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id); /* Fits the longest supported ECDSA P-256 OpenSSH key import command. */ #define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U diff --git a/src/web_lifecycle_settings.c b/src/web_lifecycle_settings.c new file mode 100644 index 0000000..ece7e63 --- /dev/null +++ b/src/web_lifecycle_settings.c @@ -0,0 +1,263 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "web_lifecycle_settings.h" + +#include +#include +#include +#include "admin_ssh_console.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "secure_random.h" +#include "web_cookie_auth.h" +#include "web_httpd_adapter.h" +#include "web_server.h" + +#if CONFIG_HTTPD_QUEUE_WORK_BLOCKING +#error "Lifecycle ACK handoff requires nonblocking HTTPD work submission" +#endif + +enum { IDLE, PENDING, EXECUTING, OK, FAILED, CANCELLED }; +static const char *const s_states[] = {"idle", "pending", "pending", "ok", "failed", "cancelled"}; +static const char *const s_actions[] = {"stop", "restart", "reboot"}; +typedef struct { + uint32_t id, generation; + web_session_id_t session; + user_principal_t principal; + int64_t ack_deadline, deadline; + unsigned action, state; + bool queued; +} lifecycle_operation_t; +static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; +static lifecycle_operation_t s_operation; +static uint32_t s_next_id, s_ack_id; +/* Comparison only; never dereferenced outside the invoking HTTPD handler. */ +static httpd_handle_t s_ack_server; + +static void cancel_locked(void) +{ + s_operation.state = CANCELLED; + secure_wipe(&s_operation.principal, sizeof(s_operation.principal)); +} + +static void expire_locked(int64_t now) +{ + if (s_operation.state == PENDING && + now >= (s_operation.queued ? s_operation.deadline : s_operation.ack_deadline)) + cancel_locked(); +} + +/* Exactly action + generation; no escapes, duplicates, coercions or extra fields. */ +static bool parse(const char *body, size_t length, lifecycle_operation_t *operation) +{ + const char *keys[] = {"action", "generation"}; + 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 < 2; ++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 < 2; ++key) + if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break; + if (key == 2 || (seen & (1U << key))) return false; + ++pos; TAKE(':'); SPACE(); + if (key == 0) { + TAKE('"'); start = pos; + while (pos < length && body[pos] != '"') ++pos; + if (pos == length) return false; + unsigned action = 0; + for (; action < 3; ++action) + if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break; + if (action == 3) return false; + operation->action = action; ++pos; + } else { + uint32_t number = 0; start = pos; + while (pos < length && body[pos] >= '0' && body[pos] <= '9') { + unsigned digit = (unsigned)(body[pos++] - '0'); + if (number > (UINT32_MAX - digit) / 10U) return false; + number = number * 10U + digit; + } + if (pos == start || (pos - start > 1 && body[start] == '0')) return false; + operation->generation = number; + } + seen |= 1U << key; + } + TAKE('}'); SPACE(); +#undef TAKE +#undef SPACE + return pos == length && seen == 3 && operation->generation && operation->generation != UINT32_MAX; +} + +/* Runs on HTTPD after its synchronous response handler returns. No socket IO, + * wait, authorization or lifecycle call here. A duplicate/late ID is inert. */ +static void ack_handoff(void *argument) +{ + uint32_t id = (uint32_t)(uintptr_t)argument; + int64_t now = esp_timer_get_time(); + taskENTER_CRITICAL(&s_lock); + bool submit = id && s_ack_id == id; + if (submit) { + s_ack_id = 0; s_ack_server = NULL; + expire_locked(now); + submit = s_operation.id == id && s_operation.state == PENDING; + if (submit) s_operation.queued = true; + } + taskEXIT_CRITICAL(&s_lock); + if (submit && admin_ssh_console_submit_lifecycle_settings(id) != ESP_OK) { + taskENTER_CRITICAL(&s_lock); + if (s_operation.id == id && s_operation.state == PENDING) cancel_locked(); + taskEXIT_CRITICAL(&s_lock); + } +} + +void web_lifecycle_settings_stopped(httpd_handle_t server) +{ + taskENTER_CRITICAL(&s_lock); + if (server && s_ack_server == server) { s_ack_id = 0; s_ack_server = NULL; } + /* Successful shutdown invalidates all old logins. Executing work owns its + * slot until return, including its deliberately session-invalidating stop. */ + if (s_operation.state == PENDING) cancel_locked(); + taskEXIT_CRITICAL(&s_lock); +} + +void web_lifecycle_settings_execute(uint32_t id) +{ + lifecycle_operation_t operation = {0}; + int64_t now = esp_timer_get_time(); + taskENTER_CRITICAL(&s_lock); + expire_locked(now); + bool execute = id && s_operation.id == id && s_operation.state == PENDING && s_operation.queued; + if (execute) { + s_operation.state = EXECUTING; + operation = s_operation; + secure_wipe(&s_operation.principal, sizeof(s_operation.principal)); + } + taskEXIT_CRITICAL(&s_lock); + if (!execute) return; + bool current = false; + esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t); + unsigned state = CANCELLED; + if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN && + esp_timer_get_time() < operation.deadline) { + error = operation.action == 0 ? web_server_stop_current(operation.generation) : + operation.action == 1 ? web_server_restart_current(operation.generation) : + web_server_reboot_current(operation.generation); + /* Even INVALID_STATE can be a detach failure after stop admission. */ + state = error == ESP_OK ? OK : FAILED; + } + taskENTER_CRITICAL(&s_lock); + if (s_operation.id == id && s_operation.state == EXECUTING) s_operation.state = state; + taskEXIT_CRITICAL(&s_lock); + secure_wipe(&operation, sizeof(operation)); +} + +static esp_err_t respond(httpd_req_t *request, const char *status, const char *body) +{ + esp_err_t error = httpd_resp_set_status(request, status); + if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8"); + if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff"); + if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer"); + if (error == ESP_OK) error = httpd_resp_sendstr(request, body); + return web_httpd_unread_body(request) ? ESP_FAIL : error; +} + +esp_err_t web_lifecycle_operation_handler(httpd_req_t *request) +{ + web_session_view_t view = {0}; + lifecycle_operation_t operation = {0}; + bool allowed = false, mutation = request->method == HTTP_POST; + esp_err_t error = mutation + ? web_cookie_auth_require_json(request, 256, &view, &allowed) + : web_cookie_auth_require(request, false, false, &view, &allowed); + if (error != ESP_OK || !allowed) goto done; + if (view.principal.role != USER_ROLE_ADMIN) { + error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done; + } + if (mutation) { + char type[40] = {0}, body[256]; + size_t received = 0; + bool valid = request->content_len && request->content_len <= sizeof(body) && + httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK && + (!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8")); + for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) { + int count = httpd_req_recv(request, body + received, request->content_len - received); + if (count <= 0 || (size_t)count > request->content_len - received) valid = false; + else received += (size_t)count; + } + valid = valid && received == request->content_len && parse(body, received, &operation); + secure_wipe(body, sizeof(body)); + if (!valid) { error = respond(request, "400 Bad Request", "{\"error\":\"invalid_lifecycle_request\"}"); goto done; } + operation.session = view.id; operation.principal = view.principal; + int64_t now = esp_timer_get_time(); + operation.ack_deadline = now + 2000000LL; + operation.deadline = now + 30000000LL; + operation.state = PENDING; + taskENTER_CRITICAL(&s_lock); + expire_locked(now); + bool busy = s_ack_id || s_operation.state == PENDING || s_operation.state == EXECUTING || s_next_id == UINT32_MAX; + if (!busy) { + operation.id = ++s_next_id; s_operation = operation; + s_ack_id = operation.id; s_ack_server = request->handle; + } + taskEXIT_CRITICAL(&s_lock); + if (busy) { error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}"); goto done; } + } else { + int64_t now = esp_timer_get_time(); + taskENTER_CRITICAL(&s_lock); + expire_locked(now); + if (s_operation.session == view.id) { + operation.id = s_operation.id; operation.state = s_operation.state; operation.action = s_operation.action; + } + taskEXIT_CRITICAL(&s_lock); + } + char response[96]; + int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}", + operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]); + error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : + respond(request, mutation ? "202 Accepted" : "200 OK", response); + if (mutation) { + /* No lifecycle can run before this send returns and HTTPD hands off. + * Successful send is not peer receipt. Never retry queue submission. */ + if (error != ESP_OK || httpd_queue_work(request->handle, ack_handoff, (void *)(uintptr_t)operation.id) != ESP_OK) { + taskENTER_CRITICAL(&s_lock); + if (s_ack_id == operation.id) { s_ack_id = 0; s_ack_server = NULL; } + if (s_operation.id == operation.id && s_operation.state == PENDING) cancel_locked(); + taskEXIT_CRITICAL(&s_lock); + } + } +done: + secure_wipe(&operation, sizeof(operation)); + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); + return error; +} + +esp_err_t web_lifecycle_settings_handler(httpd_req_t *request) +{ + web_session_view_t view = {0}; + bool allowed = false; + esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed); + if (error != ESP_OK || !allowed) goto done; + if (view.principal.role != USER_ROLE_ADMIN) { + error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); goto done; + } + web_server_management_snapshot_t snapshot; + if (web_server_get_management_snapshot(&snapshot) != ESP_OK) { + error = respond(request, "503 Service Unavailable", "{\"error\":\"lifecycle_unavailable\"}"); goto done; + } + char response[128]; + int written = snprintf(response, sizeof(response), + "{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,\"controllable\":%s}", + snapshot.generation, snapshot.running ? "true" : "false", + snapshot.transitioning ? "true" : "false", snapshot.controllable ? "true" : "false"); + error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response); +done: + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); + return error; +} diff --git a/src/web_lifecycle_settings.h b/src/web_lifecycle_settings.h new file mode 100644 index 0000000..d6f84e9 --- /dev/null +++ b/src/web_lifecycle_settings.h @@ -0,0 +1,11 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#pragma once +#include +#include "esp_http_server.h" + +esp_err_t web_lifecycle_settings_handler(httpd_req_t *request); +esp_err_t web_lifecycle_operation_handler(httpd_req_t *request); +/* Existing dispatcher only; callbacks submit IDs, never execute lifecycle work. */ +void web_lifecycle_settings_execute(uint32_t id); +/* Only after successful HTTPD destruction, before another server can start. */ +void web_lifecycle_settings_stopped(httpd_handle_t server); diff --git a/src/web_server.c b/src/web_server.c index 9b810a8..4630068 100644 --- a/src/web_server.c +++ b/src/web_server.c @@ -13,6 +13,7 @@ #include "esp_log.h" #include "esp_netif_ip_addr.h" #include "esp_timer.h" +#include "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" #include "secure_random.h" @@ -29,6 +30,7 @@ #include "web_display_settings.h" #include "web_broker_settings.h" #include "web_ssh_settings.h" +#include "web_lifecycle_settings.h" #include "web_admin_transport.h" #include "web_session_store.h" #include "web_cookie_auth.h" @@ -45,6 +47,8 @@ static SemaphoreHandle_t s_server_mutex; static httpd_handle_t s_server; static bool s_initialized; static bool s_transitioning; +/* Firmware-lifetime lifecycle fence, independent of counters and handle reuse. */ +static uint32_t s_generation = 1U; static bool s_serial_transport_init_attempted; static bool s_serial_transport_initialized; static bool s_serial_transport_attached; @@ -416,6 +420,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_lifecycle_settings_uri = { + .uri = "/api/settings/lifecycle", .method = HTTP_GET, .handler = web_lifecycle_settings_handler, +}; +static const httpd_uri_t s_lifecycle_operation_get_uri = { + .uri = "/api/settings/lifecycle-operation", .method = HTTP_GET, .handler = web_lifecycle_operation_handler, +}; +static const httpd_uri_t s_lifecycle_operation_post_uri = { + .uri = "/api/settings/lifecycle-operation", .method = HTTP_POST, .handler = web_lifecycle_operation_handler, +}; static const httpd_uri_t s_ssh_settings_uri = { .uri = "/api/settings/ssh", .method = HTTP_GET, .handler = web_ssh_settings_handler, }; @@ -619,30 +632,27 @@ esp_err_t web_server_init(void) s_serial_transport_error = serial_transport_error; s_serial_transport_initialized = serial_transport_error == ESP_OK; } - s_initialized = true; - if (s_last_error == ESP_ERR_INVALID_STATE) { + if (!s_initialized && s_last_error == ESP_ERR_INVALID_STATE) { s_last_error = ESP_OK; } + s_initialized = true; xSemaphoreGive(s_server_mutex); /* The Phase 5A HTTPS recovery surface remains available if WebSocket setup fails. */ return ESP_OK; } -esp_err_t web_server_start(void) +static esp_err_t start_server(bool reserved) { - esp_err_t error = web_server_init(); - if (error != ESP_OK) { - return error; - } - + esp_err_t error; bool serial_transport_ready; xSemaphoreTake(s_server_mutex, portMAX_DELAY); - if (s_server != NULL || s_transitioning) { + if (s_server != NULL || s_transitioning != reserved) { xSemaphoreGive(s_server_mutex); return ESP_ERR_INVALID_STATE; } s_transitioning = true; + if (s_generation != UINT32_MAX) ++s_generation; serial_transport_ready = s_serial_transport_initialized; xSemaphoreGive(s_server_mutex); @@ -665,7 +675,7 @@ esp_err_t web_server_start(void) config.httpd.max_open_sockets = 6; config.httpd.max_uri_handlers = sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) + - sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 22U; + sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 25U; /* Exhaustion rejects new sockets, never evicts an existing serial writer. */ config.httpd.lru_purge_enable = false; config.httpd.recv_wait_timeout = 1; @@ -741,6 +751,10 @@ esp_err_t web_server_start(void) web_httpd_register_optional_get(server, &s_ssh_operation_get_uri) == ESP_OK && web_httpd_register_optional(server, &s_ssh_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_ssh_operation_get_uri.uri, HTTP_GET); + if (web_httpd_register_optional_get(server, &s_lifecycle_settings_uri) == ESP_OK && + web_httpd_register_optional_get(server, &s_lifecycle_operation_get_uri) == ESP_OK && + web_httpd_register_optional(server, &s_lifecycle_operation_post_uri) != ESP_OK) + (void)httpd_unregister_uri_handler(server, s_lifecycle_operation_get_uri.uri, HTTP_GET); } if (error != ESP_OK) { web_cookie_auth_stop(); @@ -750,6 +764,7 @@ esp_err_t web_server_start(void) if (cleanup_error == ESP_OK) cleanup_error = httpd_ssl_stop(server); if (cleanup_error == ESP_OK) { web_httpd_idle_stopped(server); + web_lifecycle_settings_stopped(server); server = NULL; } else { /* Retain ownership so stop can retry and start cannot allocate a second server. */ @@ -775,14 +790,23 @@ esp_err_t web_server_start(void) return error; } -esp_err_t web_server_stop(void) +esp_err_t web_server_start(void) +{ + esp_err_t error = web_server_init(); + return error == ESP_OK ? start_server(false) : error; +} + +static esp_err_t stop_server(uint32_t expected_generation, bool restart) { if (s_server_mutex == NULL) { return ESP_ERR_INVALID_STATE; } - xSemaphoreTake(s_server_mutex, portMAX_DELAY); - if (s_server == NULL || s_transitioning) { + if (xSemaphoreTake(s_server_mutex, expected_generation ? 0U : portMAX_DELAY) != pdTRUE) + return ESP_ERR_TIMEOUT; + if (s_server == NULL || s_transitioning || + (expected_generation && (expected_generation != s_generation || + s_generation == UINT32_MAX || s_last_error != ESP_OK))) { xSemaphoreGive(s_server_mutex); return ESP_ERR_INVALID_STATE; } @@ -791,6 +815,7 @@ esp_err_t web_server_stop(void) bool admin_transport_owned = s_admin_transport_owned; esp_err_t serial_transport_error = s_serial_transport_error; s_transitioning = true; + if (s_generation != UINT32_MAX) ++s_generation; xSemaphoreGive(s_server_mutex); web_cookie_auth_stop(); @@ -829,7 +854,10 @@ esp_err_t web_server_stop(void) } esp_err_t error = httpd_ssl_stop(server); - if (error == ESP_OK) web_httpd_idle_stopped(server); + if (error == ESP_OK) { + web_httpd_idle_stopped(server); + web_lifecycle_settings_stopped(server); + } if (error == ESP_OK && admin_transport_owned) web_admin_transport_stopped(server); if (error != ESP_OK && serial_transport_attached) { /* Stay detached: old HTTPD work may still be reading static TX storage. */ @@ -837,7 +865,8 @@ esp_err_t web_server_stop(void) } xSemaphoreTake(s_server_mutex, portMAX_DELAY); - s_transitioning = false; + /* Do not expose a stopped/unreserved gap to another lifecycle caller. */ + s_transitioning = error == ESP_OK && restart; s_last_error = error; s_serial_transport_error = serial_transport_error; s_serial_transport_attached = false; @@ -847,7 +876,56 @@ esp_err_t web_server_stop(void) ++s_counters.stops; } xSemaphoreGive(s_server_mutex); - return error; + return error == ESP_OK && restart ? start_server(true) : error; +} + +esp_err_t web_server_stop(void) +{ + return stop_server(0U, false); +} + +esp_err_t web_server_stop_current(uint32_t expected_generation) +{ + if (!expected_generation) return ESP_ERR_INVALID_ARG; + return stop_server(expected_generation, false); +} + +esp_err_t web_server_restart_current(uint32_t expected_generation) +{ + if (!expected_generation) return ESP_ERR_INVALID_ARG; + return stop_server(expected_generation, true); +} + +esp_err_t web_server_reboot_current(uint32_t expected_generation) +{ + if (!expected_generation) return ESP_ERR_INVALID_ARG; + if (s_server_mutex == NULL) return ESP_ERR_INVALID_STATE; + if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT; + if (s_server == NULL || s_transitioning || s_last_error != ESP_OK || + s_generation == UINT32_MAX || expected_generation != s_generation) { + xSemaphoreGive(s_server_mutex); + return ESP_ERR_INVALID_STATE; + } + s_transitioning = true; + ++s_generation; + xSemaphoreGive(s_server_mutex); + esp_restart(); + return ESP_FAIL; /* Defensive only: reset normally never returns. */ +} + +esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *snapshot) +{ + if (snapshot == NULL) return ESP_ERR_INVALID_ARG; + memset(snapshot, 0, sizeof(*snapshot)); + if (s_server_mutex == NULL) return ESP_ERR_INVALID_STATE; + if (xSemaphoreTake(s_server_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT; + snapshot->generation = s_generation; + snapshot->running = s_server != NULL; + snapshot->transitioning = s_transitioning; + snapshot->controllable = s_initialized && s_server != NULL && !s_transitioning && + s_last_error == ESP_OK && s_generation != UINT32_MAX; + xSemaphoreGive(s_server_mutex); + return ESP_OK; } esp_err_t web_server_get_snapshot(web_server_snapshot_t *snapshot) diff --git a/src/web_server.h b/src/web_server.h index 9bbc1c6..a653ba1 100644 --- a/src/web_server.h +++ b/src/web_server.h @@ -39,7 +39,31 @@ typedef struct { /* Initialize runtime state without requiring valid certificate material. */ esp_err_t web_server_init(void); -/* Start one TLS-only server on all active network interfaces. */ +typedef struct { + uint32_t generation; + bool running; + bool transitioning; + bool controllable; +} web_server_management_snapshot_t; + +/* Zero-wait, secret-free projection; controllable excludes failed cleanup and + * exhausted generations. No HTTPD work or owner wait is performed. */ +esp_err_t web_server_get_management_snapshot(web_server_management_snapshot_t *snapshot); + +/* Conditional lifecycle admission under the canonical server mutex. Call only + * off HTTPD, after caller-owned authorization and bounded ACK handoff. These + * APIs do not authenticate, acknowledge, cancel on revocation, or bound HTTPD + * shutdown time. Restart reserves the lifecycle through stop and start; a failed + * stop never starts another server. Stale/exhausted/unclean state rejects without + * side effects. Canonical stop/start below remain the recovery path. */ +esp_err_t web_server_stop_current(uint32_t expected_generation); +esp_err_t web_server_restart_current(uint32_t expected_generation); +/* Reserves this HTTPS generation before canonical whole-device esp_restart(). + * Admission cannot be cancelled; normally does not return. Same caller rules. */ +esp_err_t web_server_reboot_current(uint32_t expected_generation); + +/* Start one TLS-only server on all active network interfaces. + * Start/stop may wait for HTTPD; never call from its task or queued callbacks. */ esp_err_t web_server_start(void); esp_err_t web_server_stop(void); diff --git a/src/web_ui.c b/src/web_ui.c index 09f1b1d..528a01b 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -190,7 +190,14 @@ static const char s_index_html[] = "
" "" "" - "
" + "" + "\n" "\n" "\n" "