From 989821b7c42736085aaf7ed1e41c3d17156a7d04 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Tue, 8 Sep 2026 20:57:27 +0200 Subject: [PATCH] Add Typed Admin Network Settings --- README.md | 10 +- docs/agent/architecture.md | 12 +- docs/agent/code-map.md | 6 +- docs/agent/current-state.md | 2 + docs/agent/design-decisions.md | 8 + docs/phase8d12_13_implementation.md | 138 ++++++ docs/phase8d_plan.md | 4 +- docs/user_administration_tests.md | 12 + src/CMakeLists.txt | 1 + src/admin_ssh_console.c | 19 +- src/admin_ssh_console.h | 1 + src/mdns_service.c | 57 ++- src/mdns_service.h | 11 + src/web_network_settings.c | 446 ++++++++++++++++++ src/web_network_settings.h | 32 ++ src/web_server.c | 19 +- src/web_ui.c | 304 +++++++++++- src/wifi_manager.c | 152 +++++- src/wifi_manager.h | 46 ++ tests/admin_console_boundary/fakes.h | 3 +- tests/admin_console_boundary/test.c | 13 + tests/web_admin_transport/server_lifecycle.py | 140 +++++- tests/web_cookie_auth/network_settings_test.c | 225 +++++++++ tests/web_cookie_auth/run.py | 17 + tests/web_cookie_auth/test.c | 13 +- tests/web_network_settings/README.md | 236 +++++++++ tests/web_network_settings/run.py | 101 ++++ tests/web_network_settings/test.c | 214 +++++++++ tests/web_ui_session/browser.cjs | 5 +- tests/web_ui_session/network.cjs | 382 +++++++++++++++ tests/web_ui_session/run.py | 1 + 31 files changed, 2568 insertions(+), 62 deletions(-) create mode 100644 docs/phase8d12_13_implementation.md create mode 100644 src/web_network_settings.c create mode 100644 src/web_network_settings.h create mode 100644 tests/web_cookie_auth/network_settings_test.c create mode 100644 tests/web_network_settings/README.md create mode 100644 tests/web_network_settings/run.py create mode 100644 tests/web_network_settings/test.c create mode 100644 tests/web_ui_session/network.cjs diff --git a/README.md b/README.md index 6a2eb1e..ac0e776 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,15 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one ## Development status -Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D.3 browser login/logout is implemented, host-tested and build-verified; [M1 is validated by user sign-off](docs/phase8d3_implementation.md) after both-role login, mixed-client operation and post-soak evidence. Numeric memory reserve gates remain open. Browser admin-shell mode, typed settings, and contextual administration remain planned, including preserving serial writer ownership across future terminal-mode changes. Configurable STA-only mDNS naming as `sak-.local` is implemented with independent NVS persistence; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details. +Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D.3 browser login/logout is implemented, host-tested and build-verified; [M1 is validated by user sign-off](docs/phase8d3_implementation.md) after both-role login, mixed-client operation and post-soak evidence. Numeric memory reserve gates remain open. Browser admin-shell mode is implemented with M2 signed off; typed Serial/account settings through 8D.10 are accepted, and 8D.11 key settings are implemented. Admin-only Network settings (8D.12/8D.13, jointly authorized) now deliver STA/AP/profile and mDNS edits, explicit persistence, password replacement/disabled-STA clear and connection controls; final parent build/tests and target acceptance are pending. Settings navigation preserves terminal sessions and serial writer ownership; actual network disruption can disconnect network clients. Further contextual administration and full M3 acceptance remain pending. Configurable STA-only mDNS naming as `sak-.local` is implemented with independent NVS persistence; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details. + +### Browser Network settings (8D.12/8D.13) + +Administrators can open **Settings → Network**; normal users cannot access its APIs. Refresh reads working configuration/runtime without exporting saved passwords or their lengths. SSIDs have UTF-8 text and reversible hex-byte modes (32-byte maximum). Password **Keep** preserves the current secret; **Replace** requires explicit new input; **Clear** is allowed only for a disabled STA profile, never AP. Inputs are transient and never prefilled from storage. + +Apply changes RAM; **Save** explicitly persists device working state, not unsent drafts. Wi-Fi Load uses stored configuration only; there is no browser Wi-Fi reset/default-secret generation or secret export. mDNS Set/Load/Defaults request STA reannouncement; Save persists the name. The profile selector chooses what to edit, not what to connect to: **Next profile** follows enabled profiles in canonical priority order. + +Confirm disruptive actions only with a recovery route ready. `accepted` does not mean online or verified DNS, and HTTPS/SSH/both browser terminals may disconnect before acknowledgement. Never automatically replay uncertain operations: reconnect via STA/AP, use Check Result/Refresh and inspect state. UART0 remains administrative recovery and native USB remains network-independent UART1 access. Changed hostnames require client DNS/trust/login checks. Browser-shell command restrictions are unchanged. See the [full bounded API, implementation evidence and pending target checklist](docs/phase8d12_13_implementation.md); no new commands or generated assets are introduced. ## Documentation diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 445be34..6647441 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -23,6 +23,16 @@ SSH role=admin ------> shared administration dispatcher <------ UART0 (does not join the broker) ``` +## Typed Network settings (8D.12/8D.13) + +`web_network_settings` provides admin-only secret-free GET snapshot and GET/POST operation routes; `web_ui.c` adds Network without changing broker/terminal ownership. Wi-Fi working/runtime projection is zero-wait under one mutex; mDNS is a separate consistent projection. SSIDs use reversible byte JSON, with explicit UTF-8-text/hex UI conversion; passwords are never returned/prefilled, only `password_configured`. Omitted passwords preserve current secrets; explicit replacement and disabled-STA clear are distinct, AP clear is always denied. + +Only an ID enters the existing administration dispatcher. Its session/deadline revalidation precedes canonical generation-checked mutations; `wifi_manager` remains radio and reannouncement owner. Wi-Fi generation compare/merge/validation and queue-before-publication occur under its mutex; Save holds selected bytes stable, Load is stored-only without default-secret generation. mDNS independently checks generation for Set/Save/Load/Defaults and queues reannouncement; changed RAM with queue failure is explicitly reported, not rolled back. Edits require explicit Save. Next profile follows canonical enabled-priority order, not the profile editor's selected index. + +One static login-bound pending/result slot and one firmware-lifetime one-second timer bound queued secret retention to 30 seconds plus scheduling latency. Dequeued locals wipe on return; admitted work is not hard-cancelled by logout/deadline. `accepted` means apply/owner admission, not online/DNS completion. Response delivery before disruption is not guaranteed; recovery is STA/AP inspection plus independent UART0 administration/native USB UART1 access, never automatic mutation replay. UI navigation preserves serial traffic/lease; actual network loss can disconnect network clients. + +Bounds: 768-byte/four-receive request, 2,048-byte snapshot, 128-byte result; 27 handlers/six sockets, no task/stack-size/dispatcher-item/queue-depth/schema growth. Optional staged Network registration failures preserve unrelated routes; timer failure denies mutation without gating snapshot reads. Timer heap and target HTTPD/dispatcher stack/memory floors remain unmeasured. Full fields, registration rollback, states, UI and validation limits: `docs/phase8d12_13_implementation.md`. Browser-shell restrictions remain unchanged. + ## Startup and initialization `app_main()` in `src/main.c` is the composition root. The implemented order matters: @@ -35,7 +45,7 @@ SSH role=admin ------> shared administration dispatcher <------ UART0 6. Load/generate HTTPS material, then initialize the independent user database, committing an empty database when storage is missing. User-database failure makes network authentication fail closed. 7. Initialize the HTTPS runtime, SSH host-key material, and permanent SSH owner task. 8. Load Wi-Fi configuration and the independent mDNS hostname configuration, persist generated first-boot Wi-Fi defaults when appropriate, initialize the nonfatal mDNS configuration service and Wi-Fi manager, and start Wi-Fi when configured for boot. The Wi-Fi manager owns subsequent mDNS announcement transitions. -9. Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. Both gates also require HTTPS security readiness, and SSH additionally requires its own security/runtime readiness. The HTTPS-security gate on SSH is an implemented dependency even though SSH has a separate host key. +9. Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. HTTPS additionally requires its own security/runtime readiness; SSH independently requires its own security/runtime readiness, not HTTPS identity readiness. This reflects `main.c` after accepted legacy-credential cleanup. 10. Start the local status/control task if button initialization succeeded. 11. Construct ESP-IDF's UART REPL to initialize `esp_console`, but do not start the stock REPL task. Register command groups, install completion, and start the custom UART frontend that feeds the shared dispatcher. diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 2eb222b..8502aa6 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -58,6 +58,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the ## Web and WebSocket serial +- **Current 8D.12/8D.13:** `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. Backend/cookie PASS, UI agent 97+renderer/CSP and review PASS, lifecycle agent21 PASS; final parent build/tests and target/resource validation pending. Full contract/exclusions/checklist: `docs/phase8d12_13_implementation.md`. Both phases user-authorized together; no 8D.14/M3/sign-off claim. Older next-phase statements below are historical. + - **8D.11:** `web_account_settings.{c,h}` extends Accounts with fingerprint-only POST `/api/settings/accounts/keys` and key-add/key-delete/key-clear on the existing operation endpoint/dispatcher. `user_database.{c,h}` owns zero-wait target-checked snapshots and canonical conditional key mutations. `web_ui.c` handles confirmations, sparse stable indices and self-revocation uncertainty. 24 handlers, six sockets; no new task/stack/queue depth. Host-tested/build-verified, target pending. Contracts/tests/checklist: `docs/phase8d11_implementation.md`. **Responsibility:** serve authenticated HTTPS UI/API, issue WebSocket tickets, and adapt browser serial sessions to broker clients. @@ -109,7 +111,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the - Files: `src/ssh_transport.{h,c}`, `src/ssh_security.{h,c}`, `src/ssh_console.{h,c}` - Interfaces: init/start/stop, session snapshots/disconnect/revocation, host-key replacement, counters - Called by: startup, network clients, user revocation, console/local UI -- Dependencies: user database, broker, admin SSH console, secure random, wolfSSH/wolfSSL; current boot start gate also depends on `web_security` readiness +- Dependencies: user database, broker, admin SSH console, secure random, wolfSSH/wolfSSL; boot start gate requires Wi-Fi and SSH security/runtime readiness, independently of HTTPS identity readiness (verified in `main.c` after accepted legacy cleanup). - Flow: role `user` -> broker; role `admin` -> `admin_ssh_console` - Ownership: after caller-side library initialization, one task pinned to core 1 owns runtime wolfSSH contexts/sessions; two fixed generation-tagged slots. - Security constraint: an interactive shell request is required; exec and subsystems are rejected, and no project file-transfer or forwarding route exists. PTY is not explicitly required. @@ -146,7 +148,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the - Files: `src/wifi_config.{h,c}`, `src/wifi_manager.{h,c}`, `src/wifi_console.{h,c}`, `src/mdns_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_console.{h,c}`, `src/network_console.{h,c}` - Interfaces: config defaults/validate/load/save; manager init/start/stop/apply/reconnect/next-profile/snapshot -- Called by: startup, console, local UI, ESP event callbacks +- Called by: startup, console, local UI, ESP event callbacks; typed Network settings uses secret-free zero-wait projections and dispatcher-owned canonical conditional mutations (8D.12/8D.13). - Dependencies: secure random for default AP password, NVS, ESP-NETIF/Wi-Fi/events, Espressif mDNS, lwIP diagnostics - Lifecycle: permanent manager task and bounded queue; callbacks enqueue compact events only. - Constraint: application NVS is authoritative (`WIFI_STORAGE_RAM`); working edits are not persisted until save. Start/stop, including local controls, intentionally update the RAM `enabled_at_boot` field. Working-config copies contain PSKs and must be tightly scoped and wiped; routine status/local UI must use secret-free snapshots. diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index ac15043..8257f53 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.12/8D.13 implemented together by user authorization (2026-09-08); host-tested/build-verified, target pending:** Backend and admin Network UI deliver 8D.12 nonsecret STA/AP/profile/mDNS edits and explicit persistence, then 8D.13 secret replacement/disabled-STA clear and connection controls. Exact API, byte SSID/UTF-8+hex UI, owner/generation/persistence/uncertainty contracts: `docs/phase8d12_13_implementation.md`. One 768-byte request, 2,048-byte snapshot, 128-byte result; one login-bound slot and one-second ESP timer, 30-second non-executing expiry plus scheduling latency, not hard cancellation. Existing dispatcher IDs/manager owner; accepted != online. Wi-Fi Load stored-only, no reset/default-secret/export; mDNS separate generation/Set/Save/Load/Defaults/reannouncement. 27 handlers/six sockets, no stack/task/queue/schema growth; staged optional route failures preserve unrelated routes. Reported backend/cookie Network PASS; backend P3 queue-drop-counter fix complete; UI agent97+renderer/CSP/review PASS; lifecycle agent21 PASS. Parent final reruns PASS Network five production-path groups, cookie Network five+shared/accounts/serial-settings/admin, console boundary/canonical accounts, lifecycle21, UI97+CSP, idle18+guards, transport25/tickets12, store--serial, diagnostics12+guard and diff check. Independent reviews no remaining actionable findings. Parent pio run PASS24.99s, 99,548 B RAM / 1,742,437 B flash (+288/+36,656 vs accepted legacy cleanup). Earlier pre-final-UI integration build emitted nonfatal FATFS_PRINT_FLOAT config warning; no unrelated config edits. Host owner paths use radio/scheduler/storage doubles, not real network validation. Timer heap, memory floors, HTTPD/dispatcher margins, live Wi-Fi/mDNS/DNS/trust and target checklist remain pending. Profile editor is not explicit-index connection selection: only canonical Next profile. UART0/USB recovery, danger confirmations, no same-response delivery guarantee and unchanged browser-shell restrictions documented. No 8D.14, full M3, target acceptance or reserve approval. Documentation agent touched only authorized docs, no source/tests/assets/build/device/commands; also corrected stale SSH-to-HTTPS startup dependency against `main.c` from accepted legacy cleanup. Older wait-for-8D.12 statements below are superseded, not earlier scoped sign-offs. + - **Legacy-credential cleanup signed off (2026-09-08):** User explicitly accepts cleanup, confirms unchanged HTTPS certificate fingerprint and continued use of preexisting test users, supplementing the clean full-mix telemetry below. Supersedes prior target-pending/fingerprint-unconfirmed status for this cleanup; idle-cleanup scoped acceptance stands. User suspects two boot auth failures were stale pre-flash sessions in two browser tabs; plausible, not traced/confirmed. Exact acceptance and limits: `docs/legacy_credential_removal.md`. No unreported blank provisioning/recovery/fault-injection/soak/all-key checks, numeric reserve approval or broader phase/M3 acceptance inferred. Documentation only; no source/config/build/test/device/assets/commit action. - **Post-legacy-removal target evidence (2026-09-08):** User provides 60-second boot/full-mix telemetry and reports substantial traffic. Five accounts/two admins; two SSH public-key sessions and two browser password logins operational. USB writer16, SSH observer9, web observers10/11, both admin consoles active at 230400 8N1 RTS/CTS DTR active. Serial WS connect2/disconnect0, admin1/0, all supplied web send/queue/protocol/close and SSH handshake/auth/IO failure counters zero; tickets all consumed/no expiry. Two web auth failures already present at boot unchanged under load, zero invalid login credentials; request origin unknown. Boot internal/DMA/PSRAM free66,488/58,732/8,246,148 B; loaded31,820/24,064/8,112,076 B, minima15,740/7,984/8,074,196 B, largest20,480/20,480/7,995,392 B. SSH stack minimum-free18,476 boot/16,284 loaded B. Exact counters/evidence limits in `docs/legacy_credential_removal.md`. No certificate fingerprint comparison, blank provisioning/recovery, all-key verification, exact revision/duration or explicit sign-off supplied; browser RX0 and no broker drop counters prevent all-route/bidirectional/lossless claims. No source/build/device action; documents only. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index d62e606..f4478c4 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -2,6 +2,14 @@ Only constraints supported by implementation or current project documentation belong here. When original rationale is unknown, the entry describes the observable constraint without inventing intent. +## Typed Network edits preserve manager ownership and current secret bytes + +**Decision (8D.12/8D.13):** `web_network_settings` admits bounded typed operations into one login-bound slot; the existing dispatcher carries IDs only and calls canonical generation-checked Wi-Fi/mDNS APIs. HTTPD reads only zero-wait secret-free projections. Wi-Fi mutex-local compare/merge/validation preserves omitted PSKs and prevents stale edits undoing CLI/local changes; queue admission precedes RAM publication. Save stabilizes selected bytes under the mutex; browser Wi-Fi Load reads stored configuration only, never generates fallback AP secrets. mDNS uses its own generation and reports RAM-applied/reannouncement-not-queued separately. + +**Consequence:** Keep SSIDs byte-reversible (UTF-8 text must first become bytes; arbitrary existing bytes require hex), password omission/Replace/disabled-STA Clear distinct, and AP clear denied even while off. No default/reset/secret-export route or explicit-index connection selection: only canonical Next. One-second timer/30-second queued expiry plus scheduling latency is not hard cancellation of admitted work. `accepted` is owner admission, not online or verified DNS; acknowledgements may be lost before network disruption. Recovery/confirmation and no automatic mutation replay are correctness requirements, not UI polish. Optional route failures must not gate unrelated services; browser-shell policy remains separate. No task/stack/queue/schema expansion; new timer/slot/buffer costs still require target heap and HTTPD/dispatcher margin evidence. + +**Relevant files and full contract:** `src/web_network_settings.{c,h}`, `src/wifi_manager.{c,h}`, `src/mdns_service.{c,h}`, `src/web_ui.c`, `docs/phase8d12_13_implementation.md`. + ## One broker mediates all production serial transports **Decision:** USB CDC, WebSocket, and role-`user` SSH access UART1 through `session_broker`; transports do not independently own the serial service. diff --git a/docs/phase8d12_13_implementation.md b/docs/phase8d12_13_implementation.md new file mode 100644 index 0000000..aff25ca --- /dev/null +++ b/docs/phase8d12_13_implementation.md @@ -0,0 +1,138 @@ +# Phase 8D.12/8D.13 — Typed Network settings + +## Status and scope (2026-09-08) + +The user authorized both phases together. Backend and admin-only Settings/Network UI are implemented: **8D.12** delivers secret-free STA/AP/profile projections, non-secret edits, explicit persistence and mDNS; **8D.13** adds explicit Wi-Fi password replacement/disabled-STA clear and manager-owned connection controls. This supersedes older wait-for-8D.12 statements, not previous scoped acceptance. No 8D.14 work, M3 completion, target sign-off or numeric memory reserve approval is claimed. + +Source authority: `src/web_network_settings.{c,h}`, `wifi_manager.{c,h}`, `wifi_config.{c,h}`, `mdns_service.{c,h}`, `mdns_config.{c,h}`, `admin_ssh_console.{c,h}`, integration in `web_server.c`/`src/CMakeLists.txt`, and authored `web_ui.c`. Contract/test details: `tests/web_network_settings/README.md`, `tests/web_ui_session/network.cjs`, cookie Network tests and server lifecycle tests. This documentation handoff changes no source, tests, generated assets or commands. Browser-shell Wi-Fi/mDNS restrictions are unchanged; typed routes do not grant general command execution. + +## Routes, authorization and isolation + +| Method | Path | Purpose | +|---|---|---| +| GET | `/api/settings/network` | Secret-free working/runtime snapshot | +| GET | `/api/settings/network-operation` | Latest result for initiating login | +| POST | `/api/settings/network-operation` | One typed operation | + +All require a current admin cookie/principal. Normal users are denied, including direct API access. Existing duplicate-header, framing and Fetch-Metadata protections apply. GET is bodyless; all routes reject queries. GET permits absent Origin but rejects a supplied mismatch. POST requires matching Origin, CSRF and exactly `application/json` or `application/json; charset=utf-8`. JSON responses are no-store, nosniff and no-referrer. There is no credential-export endpoint. + +The three added method/path handlers bring the configured budget to **27 handlers**, with **six sockets**, LRU policy unchanged. Registration is optional and staged: snapshot failure skips Network operation registration; operation GET failure skips POST; POST failure unregisters operation GET while retaining the snapshot. This preserves unrelated Settings, login/status, serial and admin routes rather than making Network a base-service startup dependency. Snapshot reads do not depend on successful secret-timer admission. Optional transport failures retain their existing independence. Host lifecycle coverage is not proof of live low-memory behavior. + +## Complete bounded snapshot + +The JSON object has exactly these domains/fields: + +| Object | Fields and meanings | +|---|---| +| `wifi` | `generation` (nonzero uint32), `enabled_at_boot` (boolean), `ap`, `profiles` | +| `wifi.ap` | `policy` (`off`, `fallback`, `always`), `channel` (1..11), `ssid` (byte string), `password_configured` (boolean) | +| each `wifi.profiles` entry | `index` (stable 0..3), `enabled` (boolean), `priority` (0..255), `security` (`mixed`, `wpa3`), `ssid`, `password_configured` | +| `runtime` | `started` (boolean), `state`, `active_profile` (-1 means none, otherwise 0..3), `ip` (dotted IPv4 string), `ap_running` (boolean), `ap_clients` (count), `last_error` (numeric `esp_err_t`) | +| `mdns` | `generation` (nonzero uint32), `suffix`, `hostname` (without `.local`), `announced` (boolean), `last_error` (numeric `esp_err_t`) | + +All four profiles are always present. Runtime states are `stopped`, `starting`, `connecting`, `waiting-ip`, `online`, `backoff`, `ap-only`, `error`, with `unknown` fallback. `mixed` means WPA2-or-stronger, not open or a WPA2-only guarantee. `announced` is expected STA announcement state, not client-verified DNS. + +Wi-Fi working configuration and runtime are copied together under the Wi-Fi mutex; mDNS is a separate consistent projection, **not an atomic cross-domain snapshot**. Both acquisitions are zero-wait. Contention/unavailability returns 503 `snapshot_unavailable`, never inferred partial values. HTTPD performs no driver/NVS call or secret-bearing configuration read. Neither projection structs nor JSON contain saved PSKs or PSK lengths; only `password_configured` is exposed to support staging/enabling validation. + +### SSIDs are reversible bytes, not JSON Unicode text + +SSID limits are **0..32 decoded bytes**. AP and enabled STA SSIDs must be nonempty. Empty STA SSID requires disabled status and no password. + +The wire codec accepts printable ASCII, standard single-character JSON escapes (`\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`) and case-insensitive `\u00HH`; each decoded codepoint is one byte. Raw non-ASCII, non-byte Unicode, surrogates and malformed escapes are rejected. Snapshot encoding uses `\u00hh` for nonprintable/non-ASCII bytes, quote and backslash. Thus `"A\u0000\u00ff"` represents `41 00 ff`, including embedded NUL and non-UTF-8 bytes. + +The UI offers **UTF-8 text** and **literal hex byte pairs**. It UTF-8-encodes text before byte-preserving JSON serialization; it does not submit raw JS Unicode strings as SSIDs. Existing bytes enter text mode only after fatal UTF-8 decoding and exact re-encoding (including BOM preservation), with control bytes excluded; otherwise hex is selected. Failed conversion preserves the original input. Hex accepts byte pairs with optional single spaces; the decoded limit remains 32 bytes. Summaries display printable ASCII SSIDs as quoted text (empty SSID as `""`), with exact hex fallback when any byte is outside ASCII 0x20..0x7e. Quotes/backslashes are escaped for unambiguous display; rendering uses DOM text, not HTML. No silent replacement decoding, double encoding or truncation is intended. + +## Complete POST contract + +One flat JSON object, at most 13 distinct keys; unknown/duplicate fields are rejected. No nested config, arrays, nulls, signed/fractional/exponent integers or leading-zero numbers. Booleans are JSON booleans. Every optional patch field preserves the current value when omitted; patches require at least one patch field. A request selects one domain/target only. + +| `action` | Required fields besides `action` | Optional fields | +|---|---|---| +| `wifi-patch` | Wi-Fi `generation` | `enabled_at_boot`, `ap_policy` (`off/fallback/always`), `channel` (1..11), `ssid`, `password`, `clear_password:true` | +| `profile-patch` | Wi-Fi `generation`, `profile` (0..3) | `enabled`, `priority` (0..255), `security` (`mixed/wpa3`), `ssid`, `password`, `clear_password:true` | +| `wifi-save`, `wifi-load` | Wi-Fi `generation` | none | +| `start`, `stop`, `reconnect`, `next-profile` | none | none | +| `mdns-set` | mDNS `generation`, `suffix` | none | +| `mdns-save`, `mdns-load`, `mdns-defaults` | mDNS `generation` | none | + +Generation is the selected domain's nonzero uint32 snapshot value. Replacement password is **8..63 printable ASCII bytes**; empty replacement is invalid. Omission means Keep, never clear. Replacement and clear cannot coexist; `clear_password:false` is rejected. A disabled STA password can be cleared, including a single patch that disables and clears. Enabled STA requires a valid password. AP clear is canonically invalid **even with AP policy off**; no open-AP path exists. Syntactically admitted but canonically invalid requests can return 202 followed by `invalid`. + +### Ownership, concurrency and persistence + +HTTPD validates/adopts a bounded request; **only its operation ID** enters the existing administration dispatcher. The dispatcher rechecks initiating session/principal/admin currentness and dequeue deadline, then invokes canonical APIs. The existing Wi-Fi manager task remains the radio/event/mDNS-transition owner; no second driver owner or generic job executor is added. + +Wi-Fi patch checks generation, merges omitted fields against current secret bytes and validates the whole candidate **under the configuration mutex**. Required restart queue admission precedes publication; queue failure leaves RAM unchanged. Generations do not wrap/reuse. CLI applies and local Start/Stop participate, so stale browser edits cannot undo newer state. Save holds the selected generation stable under the mutex during canonical persistence. Load reads only the existing canonical stored blob and conditionally installs it; missing, invalid/incompatible or failed storage does not generate/install a new AP secret or change RAM. + +Edits are RAM-only until explicit Save. Disabled-profile-only edits do not restart the radio; enabling/disabling and enabled-profile/AP changes follow canonical asynchronous restart policy. `enabled_at_boot` alone changes next-boot policy, not immediate radio state. Start/Stop also change RAM `enabled_at_boot`; Save persists that choice. Reconnect/Next are no-ops when stopped. **Next profile** means the next enabled profile in canonical priority order, wrapping. The UI profile selector chooses the configuration to edit, **not the profile to connect to**; it labels the connection action Next profile rather than promising explicit-index selection. + +mDNS has its own mutex/generation and conditional Set/Save/Load/Defaults. Suffix is 1..55 lowercase ASCII letters/digits/hyphens with no leading/trailing hyphen; hostname is `sak-`. Set/Load/Defaults change RAM and request manager-owned reannouncement; Save persists. Load may select deterministic MAC-derived defaults and reports that outcome. Offline edits are applied to an already-initialized responder on the next STA IP. mDNS is STA-only and failure is nonfatal. A RAM change followed by reannouncement queue failure is not rolled back. Existing NVS remains unencrypted; logical clear/replacement is not secure flash erasure. + +## Admission, result states and secret lifetime + +Successful POST returns HTTP 202; GET returns HTTP 200. Both contain exactly `id`, `action`, `state`, `error`, for example `{"id":42,"action":"profile-patch","state":"pending","error":0}`. Only the initiating login can retrieve the slot. Other logins/no retained result see `{"id":0,"action":"none","state":"idle","error":0}`. No query ID or history exists: UI compares acknowledged ID/action. Later admission replaces the previous result. IDs never wrap; exhaustion denies admission until reboot. + +| State | Meaning | +|---|---| +| `idle` | No result retained for this login | +| `pending` | Queued or executing | +| `accepted` | RAM apply/owner queue request accepted; **not** association, DHCP, online, completed radio transition or verified DNS | +| `ok` | Explicit Wi-Fi/mDNS Save succeeded | +| `failed` | Canonical/owner/storage failure | +| `cancelled` | Queued expiry or session/currentness/dequeue deadline denial before canonical admission | +| `stale` | Selected generation mismatched | +| `invalid` | Canonical configuration rejected patch/load | +| `loaded_defaults` | mDNS Load selected deterministic RAM defaults and queued reannouncement | +| `applied_not_queued` | mDNS RAM changed but reannouncement queue failed; refresh, do not assume rollback | + +`error` is numeric `esp_err_t`, not arbitrary input/error-text echo or a state override; cancellation can have zero error. Later runtime errors appear in fresh snapshots, not by rewriting `accepted`. + +Existing HTTP errors include 400 framing/query/body errors, 401 authentication, 403 Origin/CSRF/admin denial, and 503 auth unavailable; unsupported handler methods return 405. Backend errors are 400 `invalid_network_request`, 503 `timer_unavailable`, 503 `busy` with `Retry-After: 1`, and 503 `snapshot_unavailable`. Malformed input never queues; unread-body/receive failures close rather than drain. + +One static session-bound pending/result slot has an executing reservation under a short portMUX. One firmware-lifetime **one-second ESP timer** cancels and wipes non-executing inputs at **30 seconds plus scheduling latency**. Shared input wipes on dequeue before auth checks; dispatcher-local inputs wipe on every return. HTTP body/parser/operation inputs wipe on rejection and before response IO. Already-admitted work may finish after logout/disconnect/deadline: no hard cancellation, transactional session-liveness or hard wall-clock erasure guarantee. Expired IDs cannot execute a replacement operation. Queue entries never carry credentials. + +## UI behavior and connection-loss safety + +Network is an admin-only Settings subview with strict snapshot/result shape validation, independent request ownership and stale/session/navigation fencing. Apply submits changed fields for the selected target. Refresh discards drafts; Save persists device working state, not unsubmitted browser inputs. Stale/unavailable snapshots disable mutation instead of inferring values. + +Passwords are never fetched/prefilled: explicit Keep/Replace/Clear, with Clear restricted to disabled STA. Replacement input has a **60-second context-bound browser lifetime** and best-effort clearing on expiry, context/navigation/session change, refresh, submission and rejection. This does not promise secure erasure of immutable JS/browser copies. AP clear is unavailable in UI and denied by canonical validation. + +Disruptive Start/Stop/Reconnect/Next/Load, AP changes and enabled-profile changes require explicit confirmation and recovery warnings; mDNS Load/Defaults confirm replacement of working state. Persistence and hostname consequences remain explicit. No automatic mutation replay. After an acknowledged POST, checks run at one-second intervals, at most ten GETs/15 seconds, with session checks; known terminal results refresh the snapshot. Manual Check Result/Refresh handles pending, replaced or uncertain results. A lost acknowledgement may leave the latest result attributable to an earlier request/another tab; an unknown ID must not be treated as proof of completion. + +There is **no same-response delivery guarantee**: HTTPS, SSH and both browser WebSockets can disconnect before the POST acknowledgement or result arrives. `accepted`, a lost response, 401 or disconnect proves neither online nor cancellation. Reconnect through the available STA/AP address and inspect state before retrying. Changed hostname requires DNS verification and browser trust/login review at the new origin; host-only cookies do not move with the name. UART0 remains administrative recovery; native USB remains network-independent UART1 access, not a replacement admin console. + +Settings navigation itself does not close terminals, release writer ownership or reconfigure UART1. Hidden terminal draining and selected-keyboard rules remain. Actual network disruption can close network transports and consequently release their broker client/lease; it does not intentionally stop the serial service or USB. Do not claim uninterrupted network serial delivery across a radio restart. + +## Resources and evidence + +- POST maximum **768 bytes**, at most **four receives**, **13 keys**, **64-byte parser value scratch**; no heap JSON tree. +- Snapshot buffer **2,048 bytes**; backend maximum escaped fixture has **1,877 payload bytes** (fixture bound, not runtime heap measurement). +- Result buffer **128 bytes**; **one slot and one small persistent timer**. +- **27 handlers/six sockets**; no task count, task stack size, dispatcher item size, queue depth or persisted schema growth. Added state/timer/buffers are not zero-cost: runtime timer heap, internal/DMA/PSRAM floors, allocation overhead and HTTPD/dispatcher stack margins remain pending. + +Reported evidence, not reruns by this documentation agent: + +- Backend agent: Network backend and cookie Network suites PASS; its contract README records additional cookie/settings/account/admin and canonical console regressions. Backend P3 queue-drop-counter finding fixed, preserving failed queue-admission observability. +- UI agent: **97 groups plus renderer/CSP checks PASS**, review PASS. +- Integration/lifecycle agent: **21 groups PASS**. +- Backend sanitizer attempt could not link because host ASan/UBSan libraries were missing; no sanitizer pass claimed. +- **Final parent integrated validation PASS:** `python3 tests/web_network_settings/run.py` (five production-path groups), cookie `--network` (five Network groups plus shared auth), `--accounts`, `--serial-settings`, `--admin`; canonical console boundary `run.py` and `accounts.py`; server lifecycle **21**; browser UI **97 plus renderer/CSP**; idle cleanup **18 + SDK guards**; admin transport **25**/tickets **12**; session-store `--serial`; diagnostics **12 + integration/secrecy**; `git diff --check`. The backend queue-drop projection finding is fixed and covered in these reruns. Independent backend and UI reviews reported no other actionable findings. +- Parent `pio run` **PASS, 24.99 s**, **99,548 B RAM / 1,742,437 B flash**, **+288 B RAM / +36,656 B flash** versus accepted legacy-cleanup build (99,260 / 1,705,781). Earlier integration-only build was 99,548 / 1,718,721 before final UI; it emitted a nonfatal `FATFS_PRINT_FLOAT` boolean-configuration warning. The final parent incremental build did not emit it. No unrelated configuration change was made. +- UI agent measured authored rendered HTML **23,184 B (+5,245)** and app.js **86,535 B (+23,843)**. These are uncompressed renderer sizes, not separate target heap measurements. Generated embedded vendor assets were not regenerated. +- No upload, erase, hardware validation, commit, heap reserve or target sign-off. Real association/DHCP/AP transitions, mDNS announcement, concurrent radio-owner behavior and HTTPD/dispatcher stack floors remain pending; host manager tests exercise extracted production paths with driver/scheduler/storage doubles rather than a full real radio loop. + +## Pending target checklist + +Record revision/browser/client mix and only nonsecret evidence. Prepare UART0 and native USB before deliberate network disruption; use disposable profile changes with an explicit recovery plan. This is a procedure, not completed validation. + +1. Verify admin-only UI and direct-route normal-user denial, missing/wrong Origin/CSRF, expiry/logout, unavailable snapshot and malformed/boundary fields. Check no PSK/value/length leakage through JSON, UI summaries, logs, completion or local display. +2. Round-trip printable, UTF-8, BOM, control/NUL, non-UTF-8 and maximum 32-byte SSIDs in text/hex; verify failed conversions preserve drafts. Test Keep/Replace/disabled-STA Clear, combined disable/clear, enabled-STA and AP-clear denial, expiry/context changes and failed submissions without stored-secret prefill. +3. Race browser generations against CLI/local Start/Stop and another tab. Exercise queue saturation/drop accounting, stale patches and save/load. Confirm failed admission leaves RAM unchanged. Test stored-only Wi-Fi Load with missing/invalid/read/commit failures without default-secret generation; distinguish working edits, explicit Save and reboot persistence. +4. Exercise Start/Stop/Reconnect/Next and AP policies, stopped no-ops, canonical priority/wrap, and editing versus connection selection. Cancel confirmations. Deliberately lose acknowledgements/results, revisit Settings and use Check Result/Refresh without replay. Verify STA/AP recovery and UART0/USB availability. +5. Exercise live and offline mDNS Set/Save/Load/Defaults, stale generation, queue failure (`applied_not_queued`), responder init/live failures and next-STA-IP reconciliation. Verify actual client DNS withdrawal/reannouncement, changed-hostname trust/login and separate IP/name origins; `announced` alone is insufficient. +6. Keep USB, two browser serial clients, SSH serial, and both admin routes active where possible. Check one writer/isolated observers, hidden output draining and no navigation-induced serial disruption. Separate expected losses from actual network changes from unrelated serial/broker regression; capture broker drops and transport errors, not merely UI responsiveness. +7. Exercise optional route registration/allocation failure and stop/restart isolation on target where fault injection is available; retain base login/status, serial/admin and other settings. Confirm timer-unavailable admission fails safely and no queued stale ID mutates newer work. Delayed dispatcher/scheduler behavior is not a hard-cancellation test guarantee. +8. Measure settled boot/full-mix internal/DMA/PSRAM free/minimum/largest blocks, memory floor during TLS/admission and Network reads/writes, timer/slot overhead, repeated-operation cleanup and soak. Capture **HTTPD and administration-dispatcher stack high-water margins**, not SSH alone; no stack/task/queue increase is authorized by this checklist. Completed parent build/tests and pending user target acceptance remain separate evidence. + +## Explicit exclusions + +No specific-index connection selection (only canonical Next), Wi-Fi reset/default generation, AP-open mode, secret export/fetch, durable operation history/idempotency, cancellation endpoint, generic jobs/command runner, scans or new diagnostics workflow. No display settings/8D.14, M3 completion, browser-shell policy widening, new commands, generated-asset changes, schema migration, task/stack/queue expansion, factory erase or new security hardening. Accepted legacy-cleanup startup correction is documentary only: `main.c` independently gates SSH on Wi-Fi plus SSH security/runtime readiness, not HTTPS identity readiness. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index f45729a..3a8701c 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -1,6 +1,8 @@ # Phase 8D — Incremental web administration plan -**Latest implementation (2026-09-08):** User-requested **8D.11 implemented, host-tested/build-verified; target sign-off pending**. Accounts fingerprint listing and Ed25519/P256 import/delete/clear use canonical target-checked APIs and the existing dispatcher. Sparse-slot selection regression fixed; 24 handlers/six sockets, no new task/stack-size/queue expansion. Final build 96,076 B RAM / 1,703,685 B flash. [8D.11 record](phase8d11_implementation.md) contains API/bounds, test attribution and pending hardware checklist. Supersedes historical wait-for-8D.11 instructions below; 8D.8–8D.10/M2 remain accepted. No M3 completion or 8D.12 work. +**Current implementation (2026-09-08):** User authorized **8D.12 and 8D.13 together**, backend and Network UI delivered. 8D.12 covers nonsecret STA/AP/profile/mDNS edits and persistence; 8D.13 adds explicit secret replacement/disabled-STA clear and connection controls. Profile selection means selecting a configuration to edit; connection control is canonical **Next profile**, not explicit-index selection. 27 handlers/six sockets, one bounded slot/timer, no task/stack/queue/schema growth. Backend/cookie Network PASS, UI agent97+renderer/CSP/review PASS, lifecycle agent21 PASS; backend P3 queue-drop-counter finding fixed. **Parent integrated suites and build PASS:** 24.99 s, 99,548 B RAM / 1,742,437 B flash (+288/+36,656 vs legacy-cleanup baseline). Parent UI97/CSP, lifecycle21, Network/HTTP policy, canonical console/accounts, transport/tickets, idle/store/diagnostics checks passed; exact attribution below. **Target behavior, timer heap/memory floors and HTTPD/dispatcher stack margins remain pending.** [8D.12/8D.13 implementation](phase8d12_13_implementation.md) is the exact API/SSID/secret/uncertainty contract and checklist. No Wi-Fi reset/default-secret/export, browser-shell policy widening, 8D.14 work, M3 completion or target sign-off. Supersedes historical next-request restrictions below; previous scoped acceptance stands. + +**8D.11 implementation history (2026-09-08):** User-requested **8D.11 implemented, host-tested/build-verified; target sign-off pending**. Accounts fingerprint listing and Ed25519/P256 import/delete/clear use canonical target-checked APIs and the existing dispatcher. Sparse-slot selection regression fixed; 24 handlers/six sockets, no new task/stack-size/queue expansion. Final build 96,076 B RAM / 1,703,685 B flash. [8D.11 record](phase8d11_implementation.md) contains API/bounds, test attribution and pending hardware checklist. Supersedes historical wait-for-8D.11 instructions below; 8D.8–8D.10/M2 remain accepted. No M3 completion or 8D.12 work. **Latest target sign-off (2026-09-08):** User reports thorough Serial parameter display/settings and user/account testing, supplies settled boot/full-client-mix evidence, and explicitly says implemented work can be signed off. **Implemented 8D.8–8D.10 are accepted**, including 8D.9 UX and both 8D.10 slices. Supersedes target/signoff-pending statements below, not build/test evidence or restrictions. [8D.10 target acceptance record](phase8d10_implementation.md) contains all six loaded samples, client mix, counters and evidence limits. Full mix at 230400 baud confirms SSH sole writer + USB/two web observers with both admin routes. Final internal/DMA free 32,556/24,800 B, lifetime minima 19,228/11,472 B, largest 23,552 B; isolated failures retained without diagnosis. Exact revision/browser/durations/individual cases are unspecified; no leak-free-soak or reserve approval inferred. M2 stands; full M3/browser-shell parity is not claimed. **Next is 8D.11 only when separately requested; this sign-off authorizes no implementation.** diff --git a/docs/user_administration_tests.md b/docs/user_administration_tests.md index ad5f3e2..3942d9d 100644 --- a/docs/user_administration_tests.md +++ b/docs/user_administration_tests.md @@ -2,6 +2,18 @@ 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 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. + +- Verify admin-only Settings/Network and direct-route normal-user denial, current cookie/principal, body/query/framing/Origin/CSRF checks, unavailable/contended snapshots and generation races against CLI/local controls. +- Round-trip UTF-8 and arbitrary SSID bytes through text/hex, including NUL/BOM/non-UTF-8 and 32-byte boundaries. No saved PSK or length may appear in responses, status/logs/completion/local display. Keep omits credentials; Replace never accepts blank; disabled-STA Clear (including disable+clear) works; enabled-STA/AP clear is denied even when AP policy is off. Check transient-input expiry and context/session clearing. +- Distinguish RAM Apply, explicit Save, stored-only Wi-Fi Load and reboot persistence. Missing/invalid/failing Wi-Fi storage must not install generated defaults or new AP secrets. Exercise stale generations, queue failure/drop accounting and NVS failures without secret logging. No Wi-Fi reset/default/export action exists. +- Prepare UART0 and USB before confirming disruptive actions. Test Start/Stop (including RAM boot policy), Reconnect/Next, AP policies and stopped no-ops. The selected profile is an edit target, not explicit connection selection; Next uses canonical priority/wrap. Cancel confirmations and exercise lost ACK/401/disconnect, manual Check Result/Refresh, another-tab result replacement and no automatic replay. `accepted` is not online; delivery before disconnection is not guaranteed. Reconnect via STA/AP; UART0 administers recovery, USB preserves independent UART1 access. +- Test mDNS generation/Set/Save/Load/Defaults, live/offline reannouncement, next STA IP, init/live failure isolation and `applied_not_queued`. Verify actual client DNS and changed-hostname browser trust/login, not merely `announced`. Confirm no unintentional Wi-Fi secret reset. +- With USB/two web serial/SSH serial and both admin routes, verify hidden output draining and writer/observer preservation through Settings navigation. Separate actual network-disruption losses from serial/broker regressions. Exercise optional Network route/timer failure and stop/restart without taking down unrelated routes. Browser-shell restrictions remain unchanged. +- Capture boot/full-mix internal/DMA/PSRAM free/minimum/largest blocks and memory floors during TLS/Network operations; timer heap/slot costs, repeated-operation cleanup/soak and **HTTPD/dispatcher stack margins** remain required. Record exact revision/client mix and nonsecret counters, including broker and manager queue drops. Host tests do not establish target reserve or hard scheduling/cancellation guarantees. + ## Current legacy-removal regression procedure The legacy bootstrap/credential/reconciliation instructions in the phase baselines below are **historical only**, superseded by [legacy credential removal](legacy_credential_removal.md) and the current [command reference](command_reference.md). Basic authentication is also historical; current HTTPS uses cookie login. The current overrides apply to later Phase 8C/browser procedures too: no `user bootstrap` or `web credentials` command remains, first-admin creation uses normal UART0 `user add`, and recovery rebuilds empty. Never treat the checklist below as evidence of execution. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f4edd79..9b9c48e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -33,6 +33,7 @@ idf_component_register( "web_serial_transport.c" "web_serial_settings.c" "web_account_settings.c" + "web_network_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 c47bbb3..16dfcc5 100644 --- a/src/admin_ssh_console.c +++ b/src/admin_ssh_console.c @@ -18,6 +18,7 @@ #include "user_database.h" #include "web_serial_settings.h" #include "web_account_settings.h" +#include "web_network_settings.h" #define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U #define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U @@ -85,6 +86,7 @@ typedef enum { ADMIN_REQUEST_DEFERRED, ADMIN_REQUEST_SERIAL_SETTINGS, ADMIN_REQUEST_ACCOUNT_SETTINGS, + ADMIN_REQUEST_NETWORK_SETTINGS, } admin_request_origin_t; typedef struct { @@ -97,6 +99,7 @@ typedef struct { admin_control_request_t deferred; uint32_t serial_settings_id; uint32_t account_settings_id; + uint32_t network_settings_id; }; } admin_request_t; @@ -672,6 +675,16 @@ esp_err_t admin_ssh_console_submit_account_settings(uint32_t id) return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; } +esp_err_t admin_ssh_console_submit_network_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_NETWORK_SETTINGS, .network_settings_id = id}; + return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; +} + static void worker_task(void *context) { (void)context; @@ -680,9 +693,11 @@ static void worker_task(void *context) if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) { continue; } - if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) { + if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS || + request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) { if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id); - else web_account_settings_execute(request.account_settings_id); + else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id); + else web_network_settings_execute(request.network_settings_id); secure_wipe(&request, sizeof(request)); continue; } diff --git a/src/admin_ssh_console.h b/src/admin_ssh_console.h index d958013..b30c861 100644 --- a/src/admin_ssh_console.h +++ b/src/admin_ssh_console.h @@ -17,6 +17,7 @@ extern "C" { /* Nonblocking typed settings admission to the canonical dispatcher. */ 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); /* Fits the longest supported ECDSA P-256 OpenSSH key import command. */ #define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U diff --git a/src/mdns_service.c b/src/mdns_service.c index 3751273..3624148 100644 --- a/src/mdns_service.c +++ b/src/mdns_service.c @@ -12,6 +12,7 @@ static SemaphoreHandle_t s_mutex; static mdns_config_t s_config; +static uint32_t s_config_generation; static bool s_component_initialized; static bool s_initialization_failed; static bool s_announced; @@ -45,6 +46,7 @@ esp_err_t mdns_service_init(const mdns_config_t *config) return ESP_ERR_NO_MEM; } s_config = *config; + s_config_generation = 1; s_last_error = ESP_OK; return ESP_OK; } @@ -66,27 +68,68 @@ esp_err_t mdns_service_set_config(const mdns_config_t *config) return ESP_ERR_INVALID_ARG; } lock_service(); + if (s_config_generation == UINT32_MAX) { unlock_service(); return ESP_ERR_INVALID_STATE; } s_config = *config; + ++s_config_generation; unlock_service(); return ESP_OK; } -esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot) +static void snapshot_locked(mdns_service_snapshot_t *snapshot) { - if (snapshot == NULL || s_mutex == NULL) { - return ESP_ERR_INVALID_STATE; - } - lock_service(); memset(snapshot, 0, sizeof(*snapshot)); + snapshot->config_generation = s_config_generation; snapshot->initialized = true; snapshot->announced = s_announced; memcpy(snapshot->suffix, s_config.suffix, s_config.suffix_len); make_hostname(&s_config, snapshot->hostname, sizeof(snapshot->hostname)); snapshot->last_error = s_last_error; +} + +esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot) +{ + if (!snapshot || !s_mutex) return ESP_ERR_INVALID_STATE; + lock_service(); + snapshot_locked(snapshot); unlock_service(); return ESP_OK; } +esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *snapshot) +{ + if (!snapshot) return ESP_ERR_INVALID_ARG; + memset(snapshot, 0, sizeof(*snapshot)); + if (!s_mutex) return ESP_ERR_INVALID_STATE; + if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT; + snapshot_locked(snapshot); + unlock_service(); + return ESP_OK; +} + +esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action, + const mdns_config_t *config, bool *stored) +{ + if (!stored || action > MDNS_SETTINGS_DEFAULTS || action < MDNS_SETTINGS_SET || + (action == MDNS_SETTINGS_SET && mdns_config_validate(config) != ESP_OK)) return ESP_ERR_INVALID_ARG; + *stored = true; + if (!s_mutex) return ESP_ERR_INVALID_STATE; + lock_service(); + if (!generation || generation != s_config_generation) { unlock_service(); return ESP_ERR_NOT_FOUND; } + esp_err_t error = ESP_OK; + mdns_config_t candidate = s_config; + if (action == MDNS_SETTINGS_SAVE) error = mdns_config_save(&s_config); + else if (s_config_generation == UINT32_MAX) error = ESP_ERR_INVALID_STATE; + else { + if (action == MDNS_SETTINGS_SET) candidate = *config; + else if (action == MDNS_SETTINGS_LOAD) error = mdns_config_load(&candidate, stored); + else mdns_config_defaults(&candidate); + if (error == ESP_OK) error = mdns_config_validate(&candidate); + if (error == ESP_OK) { s_config = candidate; ++s_config_generation; } + } + unlock_service(); + return error; +} + esp_err_t mdns_service_start(void) { if (s_mutex == NULL) { @@ -96,7 +139,9 @@ esp_err_t mdns_service_start(void) if (s_component_initialized) { s_announced = true; unlock_service(); - return ESP_OK; + /* A suffix staged while offline must reach the already-created responder + * when the next STA IP arrives, even if its reannounce command ran offline. */ + return mdns_service_reannounce(); } if (s_initialization_failed) { esp_err_t error = s_last_error; diff --git a/src/mdns_service.h b/src/mdns_service.h index 39b7f7e..b809ebd 100644 --- a/src/mdns_service.h +++ b/src/mdns_service.h @@ -9,6 +9,7 @@ #include "mdns_config.h" typedef struct { + uint32_t config_generation; bool initialized; bool announced; char suffix[MDNS_CONFIG_SUFFIX_MAX_LEN + 1U]; @@ -21,6 +22,16 @@ esp_err_t mdns_service_get_config(mdns_config_t *config); esp_err_t mdns_service_set_config(const mdns_config_t *config); esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot); +/* Zero-wait secret-free projection for HTTPD; ESP_ERR_TIMEOUT on contention. */ +esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *snapshot); +typedef enum { MDNS_SETTINGS_SET, MDNS_SETTINGS_SAVE, MDNS_SETTINGS_LOAD, + MDNS_SETTINGS_DEFAULTS } mdns_settings_action_t; +/* Dispatcher-only. Check generation and mutate/persist under the service mutex. + * ESP_ERR_NOT_FOUND is stale. LOAD may select deterministic MAC defaults (stored + * reports that distinction). Caller separately queues manager reannouncement. */ +esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action, + const mdns_config_t *config, bool *stored); + /* Only wifi_manager may call these lifecycle operations. */ esp_err_t mdns_service_start(void); void mdns_service_stop(void); diff --git a/src/web_network_settings.c b/src/web_network_settings.c new file mode 100644 index 0000000..f26686f --- /dev/null +++ b/src/web_network_settings.c @@ -0,0 +1,446 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "web_network_settings.h" + +#include +#include +#include +#include +#include "admin_ssh_console.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "mdns_service.h" +#include "secure_random.h" +#include "web_cookie_auth.h" +#include "web_httpd_adapter.h" +#include "wifi_manager.h" + +enum { WIFI_PATCH, PROFILE_PATCH, WIFI_SAVE, WIFI_LOAD, START, STOP, RECONNECT, + NEXT_PROFILE, MDNS_SET, MDNS_SAVE, MDNS_LOAD, MDNS_DEFAULTS, ACTION_COUNT }; +static const char *const s_actions[] = {"wifi-patch", "profile-patch", "wifi-save", "wifi-load", + "start", "stop", "reconnect", "next-profile", "mdns-set", "mdns-save", "mdns-load", "mdns-defaults"}; +enum { IDLE, PENDING, ACCEPTED, OK, FAILED, CANCELLED, STALE, INVALID, + LOADED_DEFAULTS, APPLIED_NOT_QUEUED }; +static const char *const s_states[] = {"idle", "pending", "accepted", "ok", "failed", "cancelled", + "stale", "invalid", "loaded_defaults", "applied_not_queued"}; +typedef struct { + uint32_t id, generation; + web_session_id_t session; + user_principal_t principal; + int64_t deadline; + unsigned action, state; + esp_err_t error; + bool executing; + wifi_manager_patch_t patch; + mdns_config_t mdns; +} network_operation_t; +static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; +static network_operation_t s_operation; +static uint32_t s_next_id; +static esp_timer_handle_t s_secret_timer; +static bool s_secret_timer_started; + +static void wipe_input(network_operation_t *operation) +{ + secure_wipe(&operation->principal, sizeof(operation->principal)); + secure_wipe(&operation->patch, sizeof(operation->patch)); + secure_wipe(&operation->mdns, sizeof(operation->mdns)); + operation->generation = 0; +} + +static void expire_input(void *unused) +{ + (void)unused; + int64_t now = esp_timer_get_time(); + taskENTER_CRITICAL(&s_lock); + if (s_operation.state == PENDING && !s_operation.executing && now >= s_operation.deadline) { + s_operation.state = CANCELLED; + wipe_input(&s_operation); + } + taskEXIT_CRITICAL(&s_lock); +} + +static bool ensure_secret_timer(void) +{ + /* Sole HTTPD admission owner; one firmware-lifetime timer, no extra task. + * Periodic inspection avoids an old captured expiry cancelling a newer ID. */ + if (!s_secret_timer) { + const esp_timer_create_args_t args = {.callback = expire_input, .name = "network-input"}; + if (esp_timer_create(&args, &s_secret_timer) != ESP_OK) return false; + } + if (!s_secret_timer_started) { + if (esp_timer_start_periodic(s_secret_timer, 1000000ULL) != ESP_OK) return false; + s_secret_timer_started = true; + } + return true; +} + +typedef struct { const char *body; size_t size, pos; } parser_t; +static void space(parser_t *p) +{ + while (p->pos < p->size && (p->body[p->pos] == ' ' || p->body[p->pos] == '\r' || + p->body[p->pos] == '\n' || p->body[p->pos] == '\t')) ++p->pos; +} +static bool take(parser_t *p, char c) +{ + space(p); + return p->pos < p->size && p->body[p->pos++] == c; +} +static int hex_digit(unsigned char c) +{ + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} +/* Bounded byte-string decoder, deliberately not Unicode-to-UTF8 conversion. + * See the public contract: \\u00ff is exactly one SSID byte, not two. */ +static bool byte_string(parser_t *p, uint8_t *out, size_t capacity, size_t *length) +{ + *length = 0; + if (!take(p, '"')) return false; + while (p->pos < p->size) { + unsigned char c = (unsigned char)p->body[p->pos++]; + if (c == '"') return true; + if (c < 0x20 || c > 0x7e || *length == capacity) return false; + if (c == '\\') { + if (p->pos == p->size) return false; + c = (unsigned char)p->body[p->pos++]; + switch (c) { + case '"': case '\\': case '/': break; + case 'b': c = '\b'; break; + case 'f': c = '\f'; break; + case 'n': c = '\n'; break; + case 'r': c = '\r'; break; + case 't': c = '\t'; break; + case 'u': { + if (p->size - p->pos < 4 || p->body[p->pos] != '0' || p->body[p->pos + 1] != '0') return false; + int high = hex_digit(p->body[p->pos + 2]), low = hex_digit(p->body[p->pos + 3]); + if (high < 0 || low < 0) return false; + c = (unsigned char)(high * 16 + low); p->pos += 4; break; + } + default: return false; + } + } + out[(*length)++] = c; + } + return false; +} +static bool number(parser_t *p, uint32_t *out) +{ + space(p); size_t start = p->pos; *out = 0; + while (p->pos < p->size && p->body[p->pos] >= '0' && p->body[p->pos] <= '9') { + unsigned digit = (unsigned)(p->body[p->pos++] - '0'); + if (*out > (UINT32_MAX - digit) / 10) return false; + *out = *out * 10 + digit; + } + return p->pos > start && (p->pos - start == 1 || p->body[start] != '0'); +} +static bool boolean(parser_t *p, uint32_t *out) +{ + space(p); + if (p->size - p->pos >= 4 && !memcmp(p->body + p->pos, "true", 4)) { p->pos += 4; *out = 1; return true; } + if (p->size - p->pos >= 5 && !memcmp(p->body + p->pos, "false", 5)) { p->pos += 5; *out = 0; return true; } + return false; +} + +static bool parse_request(const char *body, size_t length, network_operation_t *operation) +{ + enum { ACTION, GENERATION, PROFILE, ENABLED, PRIORITY, SECURITY, SSID, PASSWORD, + CLEAR_PASSWORD, BOOT, POLICY, CHANNEL, SUFFIX, KEY_COUNT }; + static const char *const keys[] = {"action", "generation", "profile", "enabled", "priority", "security", + "ssid", "password", "clear_password", "enabled_at_boot", "ap_policy", "channel", "suffix"}; + parser_t p = {.body = body, .size = length}; + uint32_t seen = 0; + operation->action = ACTION_COUNT; + operation->patch.profile = -1; + if (!take(&p, '{')) return false; + for (unsigned field = 0; field < KEY_COUNT; ++field) { + uint8_t key_text[20] = {0}; size_t n; + if ((field && !take(&p, ',')) || !byte_string(&p, key_text, sizeof(key_text), &n)) return false; + unsigned key = 0; + for (; key < KEY_COUNT; ++key) if (strlen(keys[key]) == n && !memcmp(keys[key], key_text, n)) break; + if (key == KEY_COUNT || (seen & (1U << key)) || !take(&p, ':')) return false; + seen |= 1U << key; + uint32_t value = 0; + uint8_t text[64] = {0}; + bool valid; + if (key == GENERATION || key == PROFILE || key == PRIORITY || key == CHANNEL) valid = number(&p, &value); + else if (key == ENABLED || key == CLEAR_PASSWORD || key == BOOT) valid = boolean(&p, &value); + else valid = byte_string(&p, text, sizeof(text) - 1, &n); + if (!valid) { secure_wipe(text, sizeof(text)); return false; } + switch (key) { + case ACTION: + for (unsigned i = 0; i < ACTION_COUNT; ++i) + if (strlen(s_actions[i]) == n && !memcmp(s_actions[i], text, n)) operation->action = i; + valid = operation->action != ACTION_COUNT; break; + case GENERATION: operation->generation = value; valid = value != 0; break; + case PROFILE: valid = value < WIFI_CONFIG_STA_PROFILE_COUNT; operation->patch.profile = (int8_t)value; break; + case ENABLED: operation->patch.enabled = value; operation->patch.fields |= WIFI_PATCH_ENABLED; break; + case PRIORITY: valid = value <= UINT8_MAX; operation->patch.priority = value; operation->patch.fields |= WIFI_PATCH_PRIORITY; break; + case SECURITY: + valid = !memchr(text, 0, n) && wifi_config_parse_security((char *)text, &operation->patch.security); + operation->patch.fields |= WIFI_PATCH_SECURITY; break; + case SSID: + valid = n <= WIFI_CONFIG_SSID_MAX_LEN; + if (valid) { memcpy(operation->patch.ssid, text, n); operation->patch.ssid_len = n; } + operation->patch.fields |= WIFI_PATCH_SSID; break; + case PASSWORD: + valid = n >= WIFI_CONFIG_PSK_MIN_LEN && n <= WIFI_CONFIG_PSK_MAX_LEN; + for (size_t i = 0; valid && i < n; ++i) valid = text[i] >= 0x20 && text[i] <= 0x7e; + if (valid) { memcpy(operation->patch.password, text, n); operation->patch.password_len = n; } + operation->patch.fields |= WIFI_PATCH_PASSWORD; break; + case CLEAR_PASSWORD: valid = value == 1; operation->patch.fields |= WIFI_PATCH_PASSWORD; break; + case BOOT: operation->patch.enabled_at_boot = value; operation->patch.fields |= WIFI_PATCH_BOOT; break; + case POLICY: + valid = !memchr(text, 0, n) && wifi_config_parse_ap_policy((char *)text, &operation->patch.ap_policy); + operation->patch.fields |= WIFI_PATCH_POLICY; break; + case CHANNEL: valid = value >= WIFI_CONFIG_AP_CHANNEL_MIN && value <= WIFI_CONFIG_AP_CHANNEL_MAX; + operation->patch.ap_channel = value; operation->patch.fields |= WIFI_PATCH_CHANNEL; break; + case SUFFIX: + valid = n <= MDNS_CONFIG_SUFFIX_MAX_LEN; + if (valid) { + operation->mdns.schema_version = MDNS_CONFIG_SCHEMA_VERSION; + operation->mdns.blob_size = MDNS_CONFIG_BLOB_SIZE; + operation->mdns.suffix_len = n; memcpy(operation->mdns.suffix, text, n); + valid = mdns_config_validate(&operation->mdns) == ESP_OK; + } + break; + } + secure_wipe(text, sizeof(text)); + if (!valid) return false; + space(&p); + if (p.pos < p.size && p.body[p.pos] == '}') break; + } + if (!take(&p, '}')) return false; + space(&p); + if (p.pos != p.size || !(seen & 1U) || + ((seen & (1U << PASSWORD)) && (seen & (1U << CLEAR_PASSWORD)))) return false; + uint32_t required = 1U, allowed = 1U; + if (operation->action == WIFI_PATCH || operation->action == PROFILE_PATCH) { + required |= 1U << GENERATION; + allowed = required | (1U << SSID) | (1U << PASSWORD) | (1U << CLEAR_PASSWORD); + if (operation->action == PROFILE_PATCH) { + required |= 1U << PROFILE; + allowed |= (1U << PROFILE) | (1U << ENABLED) | (1U << PRIORITY) | (1U << SECURITY); + } else allowed |= (1U << BOOT) | (1U << POLICY) | (1U << CHANNEL); + if (!operation->patch.fields) return false; + } else if (operation->action == WIFI_SAVE || operation->action == WIFI_LOAD || operation->action >= MDNS_SET) { + required |= 1U << GENERATION; + if (operation->action == MDNS_SET) required |= 1U << SUFFIX; + allowed = required; + } + return operation->action < ACTION_COUNT && (seen & required) == required && !(seen & ~allowed); +} + +void web_network_settings_execute(uint32_t id) +{ + network_operation_t operation = {0}; + taskENTER_CRITICAL(&s_lock); + bool claimed = id && s_operation.id == id && s_operation.state == PENDING && !s_operation.executing; + if (claimed) { + s_operation.executing = true; + operation = s_operation; + wipe_input(&s_operation); + } + taskEXIT_CRITICAL(&s_lock); + if (!claimed) 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) { + state = ACCEPTED; + switch (operation.action) { + case WIFI_PATCH: case PROFILE_PATCH: error = wifi_manager_patch_current(operation.generation, &operation.patch); break; + case WIFI_SAVE: error = wifi_manager_save_current(operation.generation); state = OK; break; + case WIFI_LOAD: error = wifi_manager_load_current(operation.generation); break; + case START: error = wifi_manager_start(); break; + case STOP: error = wifi_manager_stop(); break; + case RECONNECT: error = wifi_manager_reconnect(); break; + case NEXT_PROFILE: error = wifi_manager_next_profile(); break; + default: { + bool stored = true; + mdns_settings_action_t action = operation.action == MDNS_SET ? MDNS_SETTINGS_SET : + operation.action == MDNS_SAVE ? MDNS_SETTINGS_SAVE : + operation.action == MDNS_LOAD ? MDNS_SETTINGS_LOAD : MDNS_SETTINGS_DEFAULTS; + error = mdns_service_update_current(operation.generation, action, &operation.mdns, &stored); + if (error == ESP_OK) { + if (action == MDNS_SETTINGS_SAVE) state = OK; + else { + error = wifi_manager_mdns_reannounce(); + state = error != ESP_OK ? APPLIED_NOT_QUEUED : stored ? ACCEPTED : LOADED_DEFAULTS; + } + } + break; + } + } + if (error != ESP_OK && state != APPLIED_NOT_QUEUED) + state = error == ESP_ERR_NOT_FOUND ? STALE : error == ESP_ERR_INVALID_ARG ? INVALID : FAILED; + } + taskENTER_CRITICAL(&s_lock); + if (s_operation.id == id && s_operation.state == PENDING) { + s_operation.state = state; + s_operation.error = error; + s_operation.executing = false; + } + 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; +} +static esp_err_t authorize(httpd_req_t *request, bool mutation, web_session_view_t *view, bool *allowed) +{ + esp_err_t error = mutation ? web_cookie_auth_require_json(request, WEB_NETWORK_REQUEST_MAX, view, allowed) : + web_cookie_auth_require(request, false, false, view, allowed); + if (error == ESP_OK && *allowed && view->principal.role != USER_ROLE_ADMIN) { + *allowed = false; + error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); + } + return error; +} + +static bool append(char *out, size_t capacity, size_t *used, const char *format, ...) +{ + va_list args; va_start(args, format); + int count = vsnprintf(out + *used, capacity - *used, format, args); + va_end(args); + if (count < 0 || (size_t)count >= capacity - *used) return false; + *used += (size_t)count; return true; +} +static bool append_ssid(char *out, size_t capacity, size_t *used, const uint8_t *ssid, size_t length) +{ + if (length > WIFI_CONFIG_SSID_MAX_LEN || !append(out, capacity, used, "\"")) return false; + for (size_t i = 0; i < length; ++i) { + unsigned c = ssid[i]; + if (c >= 0x20 && c <= 0x7e && c != '"' && c != '\\') { + if (!append(out, capacity, used, "%c", c)) return false; + } else if (!append(out, capacity, used, "\\u%04x", c)) return false; + } + return append(out, capacity, used, "\""); +} +static const char *json_bool(bool value) { return value ? "true" : "false"; } + +static esp_err_t snapshot_response(httpd_req_t *request) +{ + wifi_manager_settings_t wifi; + mdns_service_snapshot_t mdns; + /* No blocking config getters, driver/NVS calls or secret-bearing copies on HTTPD. */ + if (wifi_manager_get_settings(&wifi) != ESP_OK || mdns_service_get_settings(&mdns) != ESP_OK) + return respond(request, "503 Service Unavailable", "{\"error\":\"snapshot_unavailable\"}"); + char response[WEB_NETWORK_SNAPSHOT_MAX]; size_t used = 0; +#define ADD(...) do { if (!append(response, sizeof(response), &used, __VA_ARGS__)) return ESP_FAIL; } while (0) +#define SSID(data, length) do { if (!append_ssid(response, sizeof(response), &used, data, length)) return ESP_FAIL; } while (0) + ADD("{\"wifi\":{\"generation\":%" PRIu32 ",\"enabled_at_boot\":%s,\"ap\":{\"policy\":\"%s\",\"channel\":%u,\"ssid\":", + wifi.runtime.config_generation, json_bool(wifi.enabled_at_boot), + wifi_config_ap_policy_to_string(wifi.ap_policy), (unsigned)wifi.ap_channel); + SSID(wifi.ap_ssid, wifi.ap_ssid_len); + ADD(",\"password_configured\":%s},\"profiles\":[", json_bool(wifi.ap_password_configured)); + for (unsigned i = 0; i < WIFI_CONFIG_STA_PROFILE_COUNT; ++i) { + const wifi_manager_profile_settings_t *p = &wifi.profiles[i]; + ADD("%s{\"index\":%u,\"enabled\":%s,\"priority\":%u,\"security\":\"%s\",\"ssid\":", + i ? "," : "", i, json_bool(p->enabled), (unsigned)p->priority, wifi_config_security_to_string(p->security)); + SSID(p->ssid, p->ssid_len); + ADD(",\"password_configured\":%s}", json_bool(p->password_configured)); + } + const wifi_manager_snapshot_t *r = &wifi.runtime; + /* IPv4 bytes are already in network order, independent of host endianness. */ + const uint8_t *ip = (const uint8_t *)&r->ip; + ADD("]},\"runtime\":{\"started\":%s,\"state\":\"%s\",\"active_profile\":%d,\"ip\":\"%u.%u.%u.%u\"," + "\"ap_running\":%s,\"ap_clients\":%u,\"last_error\":%d},", + json_bool(r->started), wifi_manager_state_to_string(r->state), (int)r->active_profile, + ip[0], ip[1], ip[2], ip[3], json_bool(r->ap_running), (unsigned)r->ap_client_count, (int)r->last_error); + ADD("\"mdns\":{\"generation\":%" PRIu32 ",\"suffix\":\"%s\",\"hostname\":\"%s\",\"announced\":%s,\"last_error\":%d}}", + mdns.config_generation, mdns.suffix, mdns.hostname, json_bool(mdns.announced), (int)mdns.last_error); +#undef SSID +#undef ADD + return respond(request, "200 OK", response); +} + +esp_err_t web_network_snapshot_handler(httpd_req_t *request) +{ + web_session_view_t view = {0}; bool allowed = false; + esp_err_t error = authorize(request, false, &view, &allowed); + if (error == ESP_OK && allowed) { + error = request->method == HTTP_GET ? snapshot_response(request) : + respond(request, "405 Method Not Allowed", "{\"error\":\"method\"}"); + } + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); + return error; +} + +esp_err_t web_network_operation_handler(httpd_req_t *request) +{ + web_session_view_t view = {0}; bool allowed = false; + network_operation_t operation = {0}; + bool mutation = request->method == HTTP_POST; + esp_err_t error = authorize(request, mutation, &view, &allowed); + if (error != ESP_OK || !allowed) goto done; + if (request->method != HTTP_GET && !mutation) { + error = respond(request, "405 Method Not Allowed", "{\"error\":\"method\"}"); goto done; + } + if (mutation) { + char type[40] = {0}, body[WEB_NETWORK_REQUEST_MAX]; size_t received = 0; + bool valid = request->content_len > 0 && 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_request(body, received, &operation); + secure_wipe(body, sizeof(body)); + if (!valid) { + wipe_input(&operation); + error = respond(request, "400 Bad Request", "{\"error\":\"invalid_network_request\"}"); goto done; + } + if (!ensure_secret_timer()) { + wipe_input(&operation); + error = respond(request, "503 Service Unavailable", "{\"error\":\"timer_unavailable\"}"); 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); + wipe_input(&operation); + if (busy || admin_ssh_console_submit_network_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\"}"); + goto done; + } + } else { + taskENTER_CRITICAL(&s_lock); + if (s_operation.session == view.id) { + operation.id = s_operation.id; operation.action = s_operation.action; + operation.state = s_operation.state; operation.error = s_operation.error; + } + taskEXIT_CRITICAL(&s_lock); + } + /* Input is not needed for formatting or potentially blocking socket IO. */ + wipe_input(&operation); + char response[128]; + int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\",\"error\":%d}", + operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state], (int)operation.error); + error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : + respond(request, mutation ? "202 Accepted" : "200 OK", response); +done: + secure_wipe(&operation, sizeof(operation)); + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); + return error; +} diff --git a/src/web_network_settings.h b/src/web_network_settings.h new file mode 100644 index 0000000..e56d33d --- /dev/null +++ b/src/web_network_settings.h @@ -0,0 +1,32 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#pragma once +#include +#include "esp_http_server.h" + +#define WEB_NETWORK_REQUEST_MAX 768U +#define WEB_NETWORK_SNAPSHOT_MAX 2048U + +/* Integration: optional exact GET /api/settings/network -> snapshot_handler; + * exact GET and POST /api/settings/network-operation -> operation_handler. + * All require current admin cookie, same Origin; POST additionally CSRF/JSON. + * No query strings. No changes to browser-shell command authorization. + * + * One session-bound replaceable result, no durable history/idempotency. Only an + * ID enters the existing dispatcher. A periodic one-second ESP timer wipes and + * cancels non-executing input at 30 seconds plus scheduling latency. Executing + * locals wipe on return; already-admitted work can finish after session loss. + * 'accepted' means RAM/owner queue admission, NEVER association or DHCP success. + * + * SSID JSON is a BYTE string: raw printable ASCII, standard single-character + * JSON escapes, and \\u00HH only; each decoded codepoint maps to one byte. NUL and + * non-UTF-8 bytes round-trip. No raw non-ASCII, other Unicode or surrogates. UI + * must encode UTF-8 text into bytes before encoding this field, and retain a + * reversible byte editor for existing arbitrary SSIDs. Length limit: 32 bytes. + * No saved PSK/length is returned, only password_configured. Omitted password + * preserves current bytes; clear_password:true is distinct from replacement. + * Enabled STA requires a PSK; AP clear/open is always rejected, even policy off. + * Wi-Fi Load is stored-only, no generated-default/reset/secret-delivery route. + */ +esp_err_t web_network_snapshot_handler(httpd_req_t *request); +esp_err_t web_network_operation_handler(httpd_req_t *request); +void web_network_settings_execute(uint32_t id); diff --git a/src/web_server.c b/src/web_server.c index 0443cee..2686ad2 100644 --- a/src/web_server.c +++ b/src/web_server.c @@ -25,6 +25,7 @@ #include "web_serial_transport.h" #include "web_serial_settings.h" #include "web_account_settings.h" +#include "web_network_settings.h" #include "web_admin_transport.h" #include "web_session_store.h" #include "web_cookie_auth.h" @@ -409,6 +410,16 @@ static const httpd_uri_t s_account_generate_password_uri = { .handler = web_account_generate_password_handler, }; +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_network_operation_get_uri = { + .uri = "/api/settings/network-operation", .method = HTTP_GET, .handler = web_network_operation_handler, +}; +static const httpd_uri_t s_network_operation_post_uri = { + .uri = "/api/settings/network-operation", .method = HTTP_POST, .handler = web_network_operation_handler, +}; + static const httpd_uri_t s_root_uri = { .uri = "/", .method = HTTP_GET, @@ -624,7 +635,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]) + 10U; + sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 13U; /* Exhaustion rejects new sockets, never evicts an existing serial writer. */ config.httpd.lru_purge_enable = false; config.httpd.recv_wait_timeout = 1; @@ -683,7 +694,11 @@ esp_err_t web_server_start(void) web_httpd_register_optional(server, &s_account_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_account_operation_get_uri.uri, HTTP_GET); (void)web_httpd_register_optional(server, &s_account_generate_password_uri); - (void)web_httpd_register_optional(server, &s_account_keys_uri); + (void)web_httpd_register_optional(server, &s_account_keys_uri); + if (web_httpd_register_optional_get(server, &s_network_uri) == ESP_OK && + web_httpd_register_optional_get(server, &s_network_operation_get_uri) == ESP_OK && + web_httpd_register_optional(server, &s_network_operation_post_uri) != ESP_OK) + (void)httpd_unregister_uri_handler(server, s_network_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 a7ec56e..04f56f8 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -85,7 +85,7 @@ static const char s_index_html[] = ".settings-values dt{color:var(--muted)}.settings-values dd{margin:0;overflow-wrap:anywhere}\n" ".serial-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}" ".serial-edit label{display:grid;gap:4px;color:var(--muted)}.serial-edit input,.serial-edit select{font:inherit;width:100%;min-width:0;padding:8px;background:var(--panel);color:var(--text);border:1px solid var(--line);border-radius:6px}" - ".serial-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}\n" + ".serial-actions{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}#network-summary{white-space:pre-wrap;overflow-wrap:anywhere}\n" ".terminal-toolbar{flex-wrap:wrap}.terminal-toolbar .button{min-height:32px;padding:4px 10px}\n" "@media(max-width:850px){html,body{overflow:auto}.page{height:auto;min-height:100dvh;grid-template-rows:auto auto minmax(280px,1fr)}" ".terminal-panel{min-height:280px}.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}" @@ -179,7 +179,53 @@ static const char s_index_html[] = "\n" "