Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a4d615c59 | ||
|
|
989821b7c4 |
@@ -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-<suffix>.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-<suffix>.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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4,6 +4,10 @@ This file is working memory. Update it during active work and before handoff; do
|
||||
|
||||
## Development state
|
||||
|
||||
- **Settings presentation unified (2026-09-08), user-requested visual refinement:** Accounts/Network now use Serial-style 600px label/value definition lists, shared form styling, compact muted help, consistent Refresh/result labels and action grouping. Public-key textarea/generated-password fields styled; checkboxes intrinsic-width. Safe DOM text replaces preformatted summaries; readable ASCII SSID/hex fallback retained, pre-wrap preserves significant spaces (review finding fixed with rendered-width regression). IDs/events/auth/mutations/secret cleanup/terminal ownership unchanged; no backend/assets changes. Parent UI100 behavior groups plus HTML/renderer/CSP and headless Chromium geometry/whitespace checks at320/600/1200px PASS; pio run PASS23.69s, 99,548 B RAM / 1,744,325 B flash (+0/+1,744 vs preceding ASCII-summary build). Diff check PASS. Fixtures/browser layout checks are not target visual sign-off or live Wi-Fi validation. No upload/erase/commit.
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# 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.
|
||||
|
||||
## Presentation refinement — 2026-09-08
|
||||
|
||||
On user request, Accounts and Network adopt Serial's compact label/value grids, form styles, help typography and action spacing. Account/key/Network summaries are semantic definition lists populated with DOM text; all information remains available. Printable ASCII SSIDs remain quoted, other bytes use hex, and value cells preserve significant spaces while wrapping. Key textarea, generated-password field and checkboxes share form styling. IDs, request/operation behavior, warnings, secret clearing and terminal/lease ownership are unchanged.
|
||||
|
||||
Parent validation PASS: UI **100 behavior groups**, HTML structure/renderer/header/CSP, and headless Chromium geometry/whitespace regressions at **320/600/1200px**. Review's SSID space-collapse issue was corrected and regression-tested. Final `pio run` **23.69s**, **99,548 B RAM / 1,744,325 B flash**, **+0/+1,744** versus the preceding readable-ASCII summary build (1,742,581 B flash); diff check PASS. These are fixture-based browser checks, not on-device visual acceptance. No backend/generated-asset/upload/erase/commit action.
|
||||
|
||||
## 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-<suffix>`. 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.
|
||||
@@ -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.**
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
+17
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+51
-6
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_network_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#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);
|
||||
+16
-1
@@ -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;
|
||||
@@ -684,6 +695,10 @@ esp_err_t web_server_start(void)
|
||||
(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);
|
||||
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();
|
||||
|
||||
+333
-19
@@ -82,10 +82,15 @@ static const char s_index_html[] =
|
||||
".terminal-host .xterm-viewport{border-radius:7px}\n"
|
||||
".settings-page{overflow:auto;padding:8px;min-height:0}.settings-page h2{margin:0 0 8px;font-size:18px}"
|
||||
".settings-values{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,2fr);gap:8px 16px;max-width:600px}"
|
||||
".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"
|
||||
".settings-values dt{color:var(--muted)}.settings-values dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}\n"
|
||||
".serial-edit,.settings-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}"
|
||||
".serial-edit label,.settings-edit label{display:grid;gap:4px;color:var(--muted);min-width:0}.serial-edit input,.serial-edit select,.settings-edit input,.settings-edit select,.settings-edit textarea{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;max-width:600px}\n"
|
||||
".settings-page h3{font-size:1.17em;margin:1em 0}.settings-page .connection-detail{max-width:600px;overflow-wrap:anywhere}"
|
||||
".settings-edit .settings-wide{grid-column:1/-1}.settings-edit textarea{min-height:96px;resize:vertical}"
|
||||
".settings-edit label.settings-check{display:flex;align-items:center;gap:8px}.settings-edit input[type=checkbox]{width:auto;flex:none}"
|
||||
".settings-edit input[readonly]{font-family:monospace}.settings-values dt{overflow-wrap:anywhere}"
|
||||
"@media(max-width:360px){.settings-values{grid-template-columns:minmax(0,1fr) minmax(0,2fr)}.settings-edit{grid-template-columns:minmax(0,1fr)}}\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 +184,53 @@ static const char s_index_html[] =
|
||||
"<div id=\"admin-terminal\" class=\"terminal-host\" hidden></div>\n"
|
||||
"<section id=\"serial-settings\" class=\"settings-page\" aria-label=\"Serial settings\" hidden>"
|
||||
"<div class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
|
||||
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button></div>"
|
||||
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
|
||||
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button></div>"
|
||||
"<div id=\"network-settings\" hidden><h2>Network</h2>"
|
||||
"<p class=\"connection-detail\">Edits apply to RAM only. Save persists the device working configuration, NOT browser drafts. Refresh discards drafts. "
|
||||
"Wi-Fi Load uses stored configuration only; missing or invalid storage leaves RAM unchanged. No Wi-Fi defaults/reset. "
|
||||
"Start/Stop also change RAM enabled-at-boot; Save persists that policy. Reconnect/Next do nothing while stopped. "
|
||||
"Next selects the next enabled profile in priority order, wrapping.</p>"
|
||||
"<p class=\"connection-detail\">Network changes may disconnect HTTPS, SSH and both browser terminals before acknowledgement. Accepted is NOT connected. "
|
||||
"Recover through STA/AP, UART0 or network-independent native USB serial. Navigation itself preserves terminals and writer lease.</p>"
|
||||
"<button id=\"network-refresh\" class=\"button\" type=\"button\">Refresh</button>"
|
||||
"<p id=\"network-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"network-summary\" class=\"settings-values\"></dl>"
|
||||
"<div id=\"network-edit\" hidden><h3>Wi-Fi working configuration</h3><div class=\"settings-edit\">"
|
||||
"<label>Target<select id=\"network-target\"><option value=\"ap\">Access point / boot policy</option>"
|
||||
"<option value=\"0\">STA profile 0</option><option value=\"1\">STA profile 1</option>"
|
||||
"<option value=\"2\">STA profile 2</option><option value=\"3\">STA profile 3</option></select></label>"
|
||||
"<label id=\"network-boot-label\" class=\"settings-check\">Enabled at boot<input id=\"network-boot\" type=\"checkbox\"></label>"
|
||||
"<label id=\"network-policy-label\">AP policy<select id=\"network-policy\"><option>off</option><option>fallback</option><option>always</option></select></label>"
|
||||
"<label id=\"network-channel-label\">AP channel<input id=\"network-channel\" type=\"number\" min=\"1\" max=\"11\" step=\"1\"></label>"
|
||||
"<label id=\"network-enabled-label\" class=\"settings-check\">Profile enabled<input id=\"network-enabled\" type=\"checkbox\"></label>"
|
||||
"<label id=\"network-priority-label\">Priority<input id=\"network-priority\" type=\"number\" min=\"0\" max=\"255\" step=\"1\"></label>"
|
||||
"<label id=\"network-security-label\">Security<select id=\"network-security\"><option value=\"mixed\">WPA2 or stronger (mixed)</option><option value=\"wpa3\">WPA3</option></select></label>"
|
||||
"<label>SSID editor<select id=\"network-ssid-mode\"><option value=\"text\">Ordinary text (UTF-8)</option><option value=\"hex\">Exact bytes (hex)</option></select></label>"
|
||||
"<label>SSID<input id=\"network-ssid\" maxlength=\"256\" autocomplete=\"off\" spellcheck=\"false\"></label>"
|
||||
"<label>Password action<select id=\"network-password-mode\"><option value=\"keep\">Keep existing (default)</option>"
|
||||
"<option value=\"replace\">Replace</option><option id=\"network-password-clear\" value=\"clear\">Clear (disabled STA only)</option></select></label>"
|
||||
"<label>New password<input id=\"network-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\" disabled></label></div>"
|
||||
"<p id=\"network-password-status\" class=\"connection-detail\"></p><p id=\"network-ssid-detail\" class=\"connection-detail\" role=\"status\"></p>"
|
||||
"<p class=\"connection-detail\">SSID maximum: 32 bytes, not characters. Text entry encodes UTF-8 once; hex preserves arbitrary bytes including zero. "
|
||||
"Switching modes is lossless or refused. Password replacement requires 8–63 printable ASCII characters; blank never clears. "
|
||||
"AP always requires a PSK. STA clear requires disabled state. Transient passwords expire after 60 seconds and clear on submission or context change. "
|
||||
"NVS is unencrypted; replacement/clear is not secure flash erasure. JavaScript cannot securely zero strings.</p>"
|
||||
"<div class=\"serial-actions\"><button id=\"network-apply\" class=\"button\" type=\"button\">Apply selected Wi-Fi target to RAM</button>"
|
||||
"<button id=\"network-wifi-save\" class=\"button\" type=\"button\">Save working Wi-Fi to NVS</button>"
|
||||
"<button id=\"network-wifi-load\" class=\"button\" type=\"button\">Load stored Wi-Fi</button>"
|
||||
"</div><h3>Wi-Fi connection</h3><div class=\"serial-actions\"><button id=\"network-start\" class=\"button\" type=\"button\">Start</button><button id=\"network-stop\" class=\"button\" type=\"button\">Stop</button>"
|
||||
"<button id=\"network-reconnect\" class=\"button\" type=\"button\">Reconnect</button><button id=\"network-next-profile\" class=\"button\" type=\"button\">Next profile</button></div>"
|
||||
"<h3>mDNS</h3><p class=\"connection-detail\">STA-only responder. Expected announcement is not client-verified DNS. "
|
||||
"Suffix edits, Load and Defaults change RAM and request live reannouncement; queue failure can leave RAM changed. "
|
||||
"Offline edits are used on the next STA IP. mDNS Load may select deterministic MAC defaults. Save explicitly persists.</p>"
|
||||
"<div class=\"settings-edit\"><label>Hostname suffix (sak-… .local)<input id=\"network-suffix\" maxlength=\"55\" autocomplete=\"off\" spellcheck=\"false\"></label></div>"
|
||||
"<p class=\"connection-detail\">1–55 lowercase ASCII letters, digits or hyphens; no leading/trailing hyphen.</p><div class=\"serial-actions\">"
|
||||
"<button id=\"network-mdns-set\" class=\"button\" type=\"button\">Apply mDNS suffix to RAM</button>"
|
||||
"<button id=\"network-mdns-save\" class=\"button\" type=\"button\">Save working mDNS to NVS</button>"
|
||||
"<button id=\"network-mdns-load\" class=\"button\" type=\"button\">Load mDNS</button>"
|
||||
"<button id=\"network-mdns-defaults\" class=\"button\" type=\"button\">mDNS defaults in RAM</button></div></div>"
|
||||
"<button id=\"network-result\" class=\"button\" type=\"button\">Check Operation Result</button>"
|
||||
"<p id=\"network-operation-detail\" class=\"connection-detail\" role=\"status\">After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.</p></div>"
|
||||
"<div id=\"serial-settings-content\"><h2>Serial</h2><p class=\"connection-detail\">Working UART1 configuration below is not a saved NVS snapshot. "
|
||||
"Navigation leaves both terminals connected and preserves the serial writer lease.</p>"
|
||||
"<button id=\"refresh-settings\" class=\"button\" type=\"button\">Refresh</button>"
|
||||
@@ -218,9 +269,9 @@ static const char s_index_html[] =
|
||||
"<div id=\"account-settings\" hidden><h2>Accounts</h2>"
|
||||
"<p class=\"connection-detail\">Role and deletion changes are saved immediately and revoke only the affected account's network sessions. The final administrator is protected. "
|
||||
"Self changes can immediately close all this account's web/SSH sessions, including browser serial/admin. A disconnect or 401 is NOT proof of success; re-login and inspect before retrying.</p>"
|
||||
"<button id=\"refresh-accounts\" class=\"button\" type=\"button\">Refresh accounts</button>"
|
||||
"<button id=\"refresh-accounts\" class=\"button\" type=\"button\">Refresh</button>"
|
||||
"<p id=\"accounts-detail\" class=\"connection-detail\" role=\"status\">Select Refresh to read accounts.</p>"
|
||||
"<pre id=\"accounts-list\"></pre><div class=\"serial-edit\">"
|
||||
"<dl id=\"accounts-list\" class=\"settings-values\"></dl><h3>Account selection / Edit account</h3><div class=\"settings-edit\">"
|
||||
"<label>Account<select id=\"account-target\">"
|
||||
"<option id=\"account-option-0\" value=\"0\"></option><option id=\"account-option-1\" value=\"1\"></option>"
|
||||
"<option id=\"account-option-2\" value=\"2\"></option><option id=\"account-option-3\" value=\"3\"></option>"
|
||||
@@ -229,24 +280,24 @@ static const char s_index_html[] =
|
||||
"<label>New role<select id=\"account-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label></div>"
|
||||
"<div class=\"serial-actions\"><button id=\"account-change-role\" class=\"button\" type=\"button\">Change role</button>"
|
||||
"<button id=\"account-delete\" class=\"button\" type=\"button\">Delete account</button>"
|
||||
"<button id=\"account-result\" class=\"button\" type=\"button\">Check Result</button></div>"
|
||||
"<h3>Authorized SSH public keys</h3><p>Paste one OpenSSH Ed25519 or ECDSA P-256 public key (maximum 384 UTF-8 bytes). Public keys only; no private-key upload, export or SSH host management. Up to three keys per account.</p>"
|
||||
"<button id=\"account-result\" class=\"button\" type=\"button\">Check Operation Result</button></div>"
|
||||
"<h3>Authorized SSH public keys</h3><p class=\"connection-detail\">Paste one OpenSSH Ed25519 or ECDSA P-256 public key (maximum 384 UTF-8 bytes). Public keys only; no private-key upload, export or SSH host management. Up to three keys per account.</p>"
|
||||
"<button id=\"account-keys-refresh\" class=\"button\" type=\"button\">Read selected account keys</button>"
|
||||
"<p id=\"account-keys-detail\" role=\"status\"></p><pre id=\"account-keys-list\"></pre>"
|
||||
"<p id=\"account-keys-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"account-keys-list\" class=\"settings-values\"></dl><div class=\"settings-edit\">"
|
||||
"<label>Key to delete<select id=\"account-key-index\"><option id=\"key-option-0\" value=\"0\"></option><option id=\"key-option-1\" value=\"1\"></option><option id=\"key-option-2\" value=\"2\"></option></select></label>"
|
||||
"<label>OpenSSH public key<textarea id=\"account-public-key\" maxlength=\"384\" autocomplete=\"off\" spellcheck=\"false\"></textarea></label>"
|
||||
"<label class=\"settings-wide\">OpenSSH public key<textarea id=\"account-public-key\" maxlength=\"384\" autocomplete=\"off\" spellcheck=\"false\"></textarea></label></div>"
|
||||
"<div class=\"serial-actions\"><button id=\"account-key-add\" class=\"button\" type=\"button\">Import public key</button>"
|
||||
"<button id=\"account-key-delete\" class=\"button\" type=\"button\">Delete selected key</button><button id=\"account-key-clear\" class=\"button\" type=\"button\">Clear all authorized keys</button></div>"
|
||||
"<div class=\"serial-edit\"><label>Purpose<select id=\"account-purpose\"><option value=\"create\">Create account</option><option value=\"password\">Change selected account password</option></select></label>"
|
||||
"<h3>Create account / Change password</h3><div class=\"settings-edit\"><label>Purpose<select id=\"account-purpose\"><option value=\"create\">Create account</option><option value=\"password\">Change selected account password</option></select></label>"
|
||||
"<label id=\"account-username-label\">New username<input id=\"account-username\" maxlength=\"16\" autocomplete=\"off\"></label>"
|
||||
"<label id=\"account-create-role-label\">Initial role<select id=\"account-create-role\"><option value=\"user\">user</option><option value=\"admin\">admin</option></select></label>"
|
||||
"<label>Password<input id=\"account-password\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\"></label>"
|
||||
"<label>Confirm password<input id=\"account-password-confirm\" type=\"password\" maxlength=\"64\" autocomplete=\"new-password\"></label></div>"
|
||||
"<p class=\"connection-detail\">12-64 printable ASCII characters; spaces are significant. Confirm every password. Save it before submitting, especially for your own account.</p>"
|
||||
"<button id=\"account-generate\" class=\"button\" type=\"button\">Generate password (not applied)</button>"
|
||||
"<div id=\"account-generated-panel\" hidden><label>Generated password — not applied yet<input id=\"account-generated\" readonly autocomplete=\"off\"></label>"
|
||||
"<p>One-time display, no retrieval. Copy and save now; this display and password fields expire after 60 seconds. Enter it in Confirm password.</p>"
|
||||
"<label><input id=\"account-password-saved\" type=\"checkbox\">I saved this exact password for this operation and account.</label></div>"
|
||||
"<div id=\"account-generated-panel\" class=\"settings-edit\" hidden><label class=\"settings-wide\">Generated password — not applied yet<input id=\"account-generated\" readonly autocomplete=\"off\"></label>"
|
||||
"<p class=\"connection-detail settings-wide\">One-time display, no retrieval. Copy and save now; this display and password fields expire after 60 seconds. Enter it in Confirm password.</p>"
|
||||
"<label class=\"settings-check settings-wide\"><input id=\"account-password-saved\" type=\"checkbox\">I saved this exact password for this operation and account.</label></div>"
|
||||
"<p class=\"connection-detail\">Secrets are cleared on submission and when leaving or changing this form. JavaScript cannot securely zero strings or browser-managed copies.</p>"
|
||||
"<p id=\"account-secret-detail\" class=\"connection-detail\" role=\"status\"></p>"
|
||||
"<button id=\"account-submit-password\" class=\"button\" type=\"button\">Submit create / password change</button>"
|
||||
@@ -261,6 +312,13 @@ static const char s_app_js[] =
|
||||
"(() => {\n"
|
||||
"'use strict';\n"
|
||||
"const element = (id) => document.getElementById(id);\n"
|
||||
"function settingsRows(target, rows) {\n"
|
||||
" target.textContent = '';\n"
|
||||
" for (const [label, value] of rows) {\n"
|
||||
" const dt = document.createElement('dt'), dd = document.createElement('dd');\n"
|
||||
" dt.textContent = label; dd.textContent = value; target.appendChild(dt); target.appendChild(dd);\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"const connectionStatus = element('connection-status');\n"
|
||||
"const roleStatus = element('role-status');\n"
|
||||
"const clientIdField = element('client-id');\n"
|
||||
@@ -319,7 +377,7 @@ static const char s_app_js[] =
|
||||
" element('serial-result').disabled = busy;\n"
|
||||
"}\n"
|
||||
"function clearSettings() {\n"
|
||||
" clearAccounts();\n"
|
||||
" clearAccounts(); clearNetwork();\n"
|
||||
" if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n"
|
||||
" stopSerialAuto(true);\n"
|
||||
" if (settingsAbort) settingsAbort.abort();\n"
|
||||
@@ -332,6 +390,7 @@ static const char s_app_js[] =
|
||||
"}\n"
|
||||
"async function refreshSettings() {\n"
|
||||
" if (settingsDomain === 'accounts') return refreshAccounts();\n"
|
||||
" if (settingsDomain === 'network') return refreshNetwork();\n"
|
||||
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
|
||||
" settingsHost.hidden = false;\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
|
||||
@@ -444,7 +503,7 @@ static const char s_app_js[] =
|
||||
" if (!p || Object.keys(p).length !== 4 || p.username !== t.username || p.user_id !== t.user_id || p.auth_generation !== t.auth_generation) throw new Error('Stale key identity');\n"
|
||||
" if (!Array.isArray(p.keys) || p.keys.length > 3 || new Set(p.keys.map(k => k?.index)).size !== p.keys.length || !p.keys.every(k => k && Object.keys(k).length === 3 && Number.isInteger(k.index) && k.index >= 0 && k.index <= 2 && ['ssh-ed25519','ecdsa-sha2-nistp256'].includes(k.type) && typeof k.fingerprint === 'string' && /^SHA256:[A-Za-z0-9+/]{43}$/.test(k.fingerprint))) throw new Error('Invalid keys');\n"
|
||||
" accountKeys = p.keys; keysIdentity = identity;\n"
|
||||
" element('account-keys-list').textContent = accountKeys.map(k => k.index + ': ' + k.type + ' ' + k.fingerprint).join('\\n');\n"
|
||||
" settingsRows(element('account-keys-list'), accountKeys.map(k => [k.index + ': ', k.type + ' ' + k.fingerprint]));\n"
|
||||
" for (let i = 0; i < 3; ++i) { const o = element('key-option-' + i), key = accountKeys.find(k => k.index === i); o.value = String(i); o.textContent = key ? key.index + ': ' + key.fingerprint : ''; o.hidden = o.disabled = !key; }\n"
|
||||
" element('account-key-index').value = accountKeys.length ? String(accountKeys[0].index) : ''; element('account-keys-detail').textContent = t.username + ': ' + accountKeys.length + ' authorized keys. List refreshed.';\n"
|
||||
" } catch (error) { if (current()) { clearAccountKeys(); element('account-keys-detail').textContent = error.status === 409 || error.message === 'Stale key identity' ? 'Account identity stale. Refresh accounts and select the target again; no automatic retry.' : 'Keys unavailable or invalid. Refresh accounts or explicitly read keys again; no automatic retry.'; accountButtons(); } }\n"
|
||||
@@ -501,7 +560,8 @@ static const char s_app_js[] =
|
||||
"function selectSettingsDomain(domain) {\n"
|
||||
" if (!sessionVerified || accountRole !== 'admin' || selected !== 'settings' || domain === settingsDomain) return;\n"
|
||||
" clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\n"
|
||||
" element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts';\n"
|
||||
" element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts'; element('network-settings').hidden = domain !== 'network';\n"
|
||||
" element('settings-network').setAttribute('aria-pressed', String(domain === 'network'));\n"
|
||||
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
|
||||
" refreshSettings();\n"
|
||||
"}\n"
|
||||
@@ -520,7 +580,7 @@ static const char s_app_js[] =
|
||||
" !payload.users.every(u => u && Object.keys(u).length === 4 && typeof u.username === 'string' && /^[a-z][a-z0-9_-]{0,15}$/.test(u.username) && validId(u.user_id) && validId(u.auth_generation) && ['user','admin'].includes(u.role)) ||\n"
|
||||
" new Set(payload.users.map(u => u.username)).size !== payload.users.length) throw new Error('Invalid account list');\n"
|
||||
" clearAccountSecret(); accounts = payload.users;\n"
|
||||
" element('accounts-list').textContent = accounts.map(u => u.username + ' — ' + u.role + (u.username === sessionIdentity.username ? ' (you)' : '')).join('\\n');\n"
|
||||
" settingsRows(element('accounts-list'), accounts.map(u => [u.username, u.role + (u.username === sessionIdentity.username ? ' (you)' : '')]));\n"
|
||||
" for (let i = 0; i < 8; ++i) { const option = element('account-option-' + i); option.textContent = accounts[i]?.username || ''; option.hidden = option.disabled = !accounts[i]; }\n"
|
||||
" const index = Math.max(0, accounts.findIndex(t => t.username === previous?.username && t.user_id === previous?.user_id));\n"
|
||||
" element('account-target').value = String(index); element('account-role').value = accounts[index]?.role || 'user';\n"
|
||||
@@ -624,6 +684,260 @@ static const char s_app_js[] =
|
||||
"for (const action of ['key-add','key-delete','key-clear']) element('account-' + action).addEventListener('click', () => accountOperation(action));\n"
|
||||
"element('account-key-index').addEventListener('change', clearAccountSecret);\n"
|
||||
"element('account-target').addEventListener('change', () => { clearAccountSecret(); clearAccountKeys(); element('account-role').value = accounts[Number(element('account-target').value)]?.role || 'user'; accountButtons(); });\n"
|
||||
"// Network has independent request ownership and a replaceable, login-bound result.\n"
|
||||
"const networkActions = ['wifi-patch','profile-patch','wifi-save','wifi-load','start','stop','reconnect','next-profile','mdns-set','mdns-save','mdns-load','mdns-defaults'];\n"
|
||||
"const networkControls = ['apply', ...networkActions.filter(a => !a.endsWith('-patch'))];\n"
|
||||
"const networkFields = ['target','boot','policy','channel','enabled','priority','security','ssid-mode','ssid','password-mode','suffix'];\n"
|
||||
"let networkSnapshot = null, networkFresh = false, networkAbort = null, networkId = 0, networkPending = false, networkAwaitingAck = false, networkWarning = '';\n"
|
||||
"let networkSecretTimer = null, networkSecretUntil = 0, networkSecretContext = '', networkSSIDMode = 'text', networkAction = '';\n"
|
||||
"const net = id => element('network-' + id);\n"
|
||||
"const netInteger = (n, low, high) => Number.isInteger(n) && n >= low && n <= high;\n"
|
||||
"const netShape = (v, keys) => v !== null && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === keys.length && keys.every(k => Object.hasOwn(v, k));\n"
|
||||
"const netSuffix = s => typeof s === 'string' && /^[a-z0-9](?:[a-z0-9-]{0,53}[a-z0-9])?$/.test(s);\n"
|
||||
"const netBytes = s => typeof s === 'string' && s.length <= 32 && /^[\\x00-\\xff]*$/.test(s);\n"
|
||||
"function networkLive() { return selected === 'settings' && settingsDomain === 'network' && accountRole === 'admin' && sessionVerified && !suspended && !unloading && !navigating && !loggingOut; }\n"
|
||||
"function validateNetwork(v) {\n"
|
||||
" if (!netShape(v, ['wifi','runtime','mdns'])) return false;\n"
|
||||
" const w = v.wifi, r = v.runtime, m = v.mdns;\n"
|
||||
" return netShape(w, ['generation','enabled_at_boot','ap','profiles']) && netInteger(w.generation, 1, 4294967295) && typeof w.enabled_at_boot === 'boolean' &&\n"
|
||||
" netShape(w.ap, ['policy','channel','ssid','password_configured']) && ['off','fallback','always'].includes(w.ap.policy) && netInteger(w.ap.channel, 1, 11) && netBytes(w.ap.ssid) && w.ap.ssid.length > 0 && w.ap.password_configured === true &&\n"
|
||||
" Array.isArray(w.profiles) && w.profiles.length === 4 && w.profiles.every((p, i) => netShape(p, ['index','enabled','priority','security','ssid','password_configured']) && p.index === i && typeof p.enabled === 'boolean' && netInteger(p.priority, 0, 255) && ['mixed','wpa3'].includes(p.security) && netBytes(p.ssid) && typeof p.password_configured === 'boolean' && (!p.enabled || p.ssid.length > 0 && p.password_configured) && (p.ssid.length > 0 || !p.password_configured)) &&\n"
|
||||
" netShape(r, ['started','state','active_profile','ip','ap_running','ap_clients','last_error']) && typeof r.started === 'boolean' && ['stopped','starting','connecting','waiting-ip','online','backoff','ap-only','error','unknown'].includes(r.state) && netInteger(r.active_profile, -1, 3) && typeof r.ip === 'string' && r.ip.length <= 15 && (r.ip === '' || /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(r.ip) && r.ip.split('.').every(n => Number(n) <= 255)) && typeof r.ap_running === 'boolean' && netInteger(r.ap_clients, 0, 255) && netInteger(r.last_error, -2147483648, 2147483647) &&\n"
|
||||
" netShape(m, ['generation','suffix','hostname','announced','last_error']) && netInteger(m.generation, 1, 4294967295) && netSuffix(m.suffix) && m.hostname === 'sak-' + m.suffix && typeof m.announced === 'boolean' && netInteger(m.last_error, -2147483648, 2147483647);\n"
|
||||
"}\n"
|
||||
"function networkContext() { return JSON.stringify([networkSnapshot?.wifi.generation, networkSnapshot?.mdns.generation, ...networkFields.map(id => [net(id).value, net(id).checked])]); }\n"
|
||||
"function clearNetworkSecret() {\n"
|
||||
" window.clearTimeout(networkSecretTimer); networkSecretTimer = null; networkSecretUntil = 0; networkSecretContext = '';\n"
|
||||
" net('password').value = ''; net('password-mode').value = 'keep'; net('password').disabled = true;\n"
|
||||
"}\n"
|
||||
"function changeNetworkContext() {\n"
|
||||
" clearNetworkSecret();\n"
|
||||
" if (networkAbort) {\n"
|
||||
" networkAbort.abort(); networkAbort = null; networkFresh = false;\n"
|
||||
" net('detail').textContent = 'Context changed; snapshot stale. Refresh before editing.';\n"
|
||||
" if (networkPending) net('operation-detail').textContent = networkWarning + 'Outcome pending or unknown. Context changes do not cancel backend work. Check Result and Refresh; never automatically resubmit.';\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"function networkButtons() {\n"
|
||||
" const busy = !!networkAbort, blocked = busy || networkPending || !networkFresh || !networkSnapshot;\n"
|
||||
" for (const id of networkControls) net(id).disabled = blocked;\n"
|
||||
" for (const id of networkFields) net(id).disabled = blocked;\n"
|
||||
" net('password').disabled = blocked || net('password-mode').value !== 'replace';\n"
|
||||
" net('refresh').disabled = net('result').disabled = busy;\n"
|
||||
" net('password-clear').hidden = net('password-clear').disabled = net('target').value === 'ap';\n"
|
||||
"}\n"
|
||||
"function clearNetwork() {\n"
|
||||
" clearNetworkSecret();\n"
|
||||
" if (networkAbort) networkAbort.abort(); networkAbort = null;\n"
|
||||
" networkSnapshot = null; networkFresh = false;\n"
|
||||
" net('summary').textContent = ''; net('edit').hidden = true;\n"
|
||||
" for (const id of ['ssid','suffix','channel','priority']) net(id).value = '';\n"
|
||||
" net('detail').textContent = 'Refresh to read current network settings.';\n"
|
||||
" if (networkPending) net('operation-detail').textContent = networkWarning + 'Outcome pending or unknown. Check Result on return. Navigation does not cancel work. Recover through STA/AP, UART0 or native USB; never automatically resubmit.';\n"
|
||||
" networkButtons();\n"
|
||||
"}\n"
|
||||
"function networkHex(bytes) { return Array.from(bytes, c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' '); }\n"
|
||||
"function networkSSIDSummary(bytes) { return /^[ -~]*$/.test(bytes) ? 'SSID: ' + JSON.stringify(bytes) : 'SSID hex: ' + networkHex(bytes); }\n"
|
||||
"function networkText(bytes) {\n"
|
||||
" // Fatal decode plus exact round-trip (including UTF-8 BOM) prevents replacement/double encoding.\n"
|
||||
" const text = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(Uint8Array.from(bytes, c => c.charCodeAt(0)));\n"
|
||||
" if (String.fromCharCode(...encoder.encode(text)) !== bytes || /[\\x00-\\x1f\\x7f]/.test(text)) throw new Error('Use hex for control or non-UTF-8 bytes.');\n"
|
||||
" return text;\n"
|
||||
"}\n"
|
||||
"function networkSSID(mode = networkSSIDMode) {\n"
|
||||
" const input = net('ssid').value;\n"
|
||||
" let bytes;\n"
|
||||
" if (mode === 'hex') {\n"
|
||||
" if (input.length > 96 || !/^(?:[0-9a-fA-F]{2}(?: ?[0-9a-fA-F]{2})*)?$/.test(input.trim())) throw new Error('Enter hex byte pairs, optionally separated by single spaces.');\n"
|
||||
" bytes = input.trim().replace(/ /g, '').match(/../g) || [];\n"
|
||||
" bytes = String.fromCharCode(...bytes.map(b => parseInt(b, 16)));\n"
|
||||
" } else {\n"
|
||||
" if (input.length > 64 || /[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(?:^|[^\\uD800-\\uDBFF])[\\uDC00-\\uDFFF]/.test(input)) throw new Error('Invalid UTF-8 text.');\n"
|
||||
" bytes = String.fromCharCode(...encoder.encode(input));\n"
|
||||
" }\n"
|
||||
" if (!netBytes(bytes)) throw new Error('SSID exceeds 32 bytes.');\n"
|
||||
" return bytes;\n"
|
||||
"}\n"
|
||||
"function networkTarget() {\n"
|
||||
" if (!networkSnapshot) return null;\n"
|
||||
" const target = net('target').value;\n"
|
||||
" return target === 'ap' ? networkSnapshot.wifi.ap : /^[0-3]$/.test(target) ? networkSnapshot.wifi.profiles[Number(target)] : null;\n"
|
||||
"}\n"
|
||||
"function renderNetworkTarget() {\n"
|
||||
" clearNetworkSecret();\n"
|
||||
" const p = networkTarget(); if (!p) return;\n"
|
||||
" const ap = net('target').value === 'ap';\n"
|
||||
" for (const id of ['boot','policy','channel']) net(id + '-label').hidden = !ap;\n"
|
||||
" for (const id of ['enabled','priority','security']) net(id + '-label').hidden = ap;\n"
|
||||
" net('boot').checked = networkSnapshot.wifi.enabled_at_boot;\n"
|
||||
" net('policy').value = networkSnapshot.wifi.ap.policy; net('channel').value = String(networkSnapshot.wifi.ap.channel);\n"
|
||||
" net('enabled').checked = !ap && p.enabled; net('priority').value = ap ? '0' : String(p.priority); net('security').value = ap ? 'mixed' : p.security;\n"
|
||||
" try { net('ssid').value = networkText(p.ssid); networkSSIDMode = 'text'; }\n"
|
||||
" catch (_) { net('ssid').value = networkHex(p.ssid); networkSSIDMode = 'hex'; }\n"
|
||||
" net('ssid-mode').value = networkSSIDMode;\n"
|
||||
" net('ssid-detail').textContent = 'Loaded ' + p.ssid.length + ' exact bytes. Text means UTF-8; hex means literal bytes.';\n"
|
||||
" net('password-status').textContent = 'Password configured: ' + (p.password_configured ? 'yes' : 'no') + '. Saved passwords are never returned or prefilled.';\n"
|
||||
" networkButtons();\n"
|
||||
"}\n"
|
||||
"async function refreshNetwork() {\n"
|
||||
" if (!networkLive() || networkAbort) return;\n"
|
||||
" clearNetworkSecret(); networkFresh = false;\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; networkAbort = controller; networkButtons();\n"
|
||||
" const current = () => networkAbort === controller && networkLive();\n"
|
||||
" net('detail').textContent = 'Reading network. Previous snapshot is stale; refresh discards drafts.';\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
||||
" const {payload, status} = await api('/api/settings/network', generation, {signal: controller.signal, limit: 2048, current});\n"
|
||||
" if (status !== 200 || !validateNetwork(payload)) throw new Error('Invalid network snapshot');\n"
|
||||
" networkSnapshot = payload; networkFresh = true;\n"
|
||||
" const w = payload.wifi, r = payload.runtime, m = payload.mdns;\n"
|
||||
" settingsRows(net('summary'), [\n"
|
||||
" ['Wi-Fi generation', w.generation], ['Enabled at boot', w.enabled_at_boot],\n"
|
||||
" ['AP policy / channel', w.ap.policy + ' / ' + w.ap.channel], ['AP SSID', networkSSIDSummary(w.ap.ssid)], ['AP password configured', w.ap.password_configured],\n"
|
||||
" ...w.profiles.flatMap(p => [['STA ' + p.index, 'enabled ' + p.enabled + ', priority ' + p.priority + ', ' + p.security], ['STA ' + p.index + ' SSID', networkSSIDSummary(p.ssid)], ['STA ' + p.index + ' password configured', p.password_configured]]),\n"
|
||||
" ['Runtime: ', r.state], ['Started', r.started], ['Active profile', r.active_profile], ['IP', r.ip || 'none'],\n"
|
||||
" ['AP running', r.ap_running], ['AP clients', r.ap_clients], ['Wi-Fi last error', r.last_error],\n"
|
||||
" ['mDNS generation', m.generation], ['Hostname', m.hostname + '.local'], ['Expected announcement', m.announced],\n"
|
||||
" ['mDNS last error', m.last_error], ['DNS verification', 'Not client-verified DNS.']]);\n"
|
||||
" if (!['ap','0','1','2','3'].includes(net('target').value)) net('target').value = 'ap';\n"
|
||||
" renderNetworkTarget(); net('suffix').value = m.suffix; net('edit').hidden = false;\n"
|
||||
" net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : 'Working snapshot refreshed (Wi-Fi and mDNS are separate consistent copies). ') + 'Browser drafts are not saved; Save persists device working state.';\n"
|
||||
" } catch (error) { if (live(generation) && current()) net('detail').textContent = 'Network snapshot stale or unavailable/invalid. Refresh explicitly to retry. No values inferred.'; }\n"
|
||||
" finally { if (current()) { networkAbort = null; networkButtons(); } }\n"
|
||||
"}\n"
|
||||
"function networkRequest(action) {\n"
|
||||
" if (!networkFresh || !networkSnapshot) throw new Error('Refresh the network snapshot first.');\n"
|
||||
" const w = networkSnapshot.wifi, m = networkSnapshot.mdns, p = networkTarget();\n"
|
||||
" const request = {action};\n"
|
||||
" if (action.startsWith('wifi-') || action === 'profile-patch') request.generation = w.generation;\n"
|
||||
" if (action.startsWith('mdns-')) request.generation = m.generation;\n"
|
||||
" if (action === 'mdns-set') {\n"
|
||||
" if (!netSuffix(net('suffix').value)) throw new Error('Invalid mDNS suffix.');\n"
|
||||
" request.suffix = net('suffix').value;\n"
|
||||
" }\n"
|
||||
" if (action.endsWith('-patch')) {\n"
|
||||
" const ap = action === 'wifi-patch';\n"
|
||||
" if (!p || ap !== (net('target').value === 'ap')) throw new Error('Select a valid target.');\n"
|
||||
" if (!ap) request.profile = Number(net('target').value);\n"
|
||||
" const changed = (key, value, previous) => { if (value !== previous) request[key] = value; };\n"
|
||||
" const integer = (id, max, min = 0) => { const s = net(id).value; if (!/^(?:0|[1-9][0-9]{0,2})$/.test(s) || !netInteger(Number(s), min, max)) throw new Error('Invalid ' + id + '.'); return Number(s); };\n"
|
||||
" if (ap) {\n"
|
||||
" changed('enabled_at_boot', net('boot').checked, w.enabled_at_boot);\n"
|
||||
" if (!['off','fallback','always'].includes(net('policy').value)) throw new Error('Invalid AP policy.');\n"
|
||||
" changed('ap_policy', net('policy').value, p.policy); changed('channel', integer('channel', 11, 1), p.channel);\n"
|
||||
" } else {\n"
|
||||
" changed('enabled', net('enabled').checked, p.enabled); changed('priority', integer('priority', 255), p.priority);\n"
|
||||
" if (!['mixed','wpa3'].includes(net('security').value)) throw new Error('Invalid security.');\n"
|
||||
" changed('security', net('security').value, p.security);\n"
|
||||
" }\n"
|
||||
" const ssid = networkSSID(); changed('ssid', ssid, p.ssid);\n"
|
||||
" const mode = net('password-mode').value, password = net('password').value;\n"
|
||||
" let configured = p.password_configured;\n"
|
||||
" if (mode === 'replace') {\n"
|
||||
" if (!networkSecretUntil || performance.now() >= networkSecretUntil || networkSecretContext !== networkContext() || !/^[ -~]{8,63}$/.test(password)) throw new Error('Re-enter an unexpired, context-bound 8\u201363 printable ASCII password. Blank never clears.');\n"
|
||||
" configured = true;\n"
|
||||
" } else if (mode === 'clear') {\n"
|
||||
" if (ap || net('enabled').checked) throw new Error('Only a disabled STA profile password can be cleared.');\n"
|
||||
" configured = false; request.clear_password = true;\n"
|
||||
" } else if (mode !== 'keep') throw new Error('Select Keep, Replace or disabled-STA Clear.');\n"
|
||||
" if ((ap || net('enabled').checked) && (!ssid.length || !configured) || !ssid.length && configured) throw new Error('AP/enabled STA requires SSID and PSK; empty STA SSID requires disabled with no password.');\n"
|
||||
" // Add the transient secret last, after all validation that could throw.\n"
|
||||
" if (mode === 'replace') request.password = password;\n"
|
||||
" if (Object.keys(request).length === (ap ? 2 : 3)) throw new Error('No selected-target changes to apply.');\n"
|
||||
" }\n"
|
||||
" return request;\n"
|
||||
"}\n"
|
||||
"function networkWire(request) {\n"
|
||||
" // Escape byte codepoints, not Unicode text: every non-ASCII SSID byte is \\u00HH.\n"
|
||||
" const body = JSON.stringify(request).replace(/[\\u007f-\\uffff]/g, c => '\\\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));\n"
|
||||
" if (encoder.encode(body).length > 768) throw new Error('Request exceeds 768 bytes.');\n"
|
||||
" return body;\n"
|
||||
"}\n"
|
||||
"async function networkOperation(action) {\n"
|
||||
" if (!networkLive() || networkAbort || action && networkPending) return;\n"
|
||||
" let body, request;\n"
|
||||
" const detail = net('operation-detail');\n"
|
||||
" if (action) {\n"
|
||||
" try {\n"
|
||||
" if (!networkActions.includes(action)) throw new Error('Invalid action.');\n"
|
||||
" request = networkRequest(action); body = networkWire(request);\n"
|
||||
" const disruptive = ['start','stop','reconnect','next-profile','wifi-load'].includes(action) || action === 'wifi-patch' && Object.keys(request).some(k => !['action','generation','enabled_at_boot'].includes(k)) || action === 'profile-patch' && (networkTarget().enabled || net('enabled').checked);\n"
|
||||
" if ((disruptive || ['mdns-load','mdns-defaults'].includes(action)) && !window.confirm(action + ': ' + (disruptive ? 'May disconnect HTTPS/SSH and BOTH browser terminals before acknowledgement. Accepted is NOT online. Recover through STA/AP, UART0 or native USB. ' : 'Replace working mDNS with loaded/default settings and request reannouncement. ') + 'RAM changes require explicit Save. Continue?')) { body = undefined; return; }\n"
|
||||
" } catch (error) { body = undefined; detail.textContent = 'Not submitted. ' + error.message; return; }\n"
|
||||
" finally { clearNetworkSecret(); if (request) request.password = ''; request = null; }\n"
|
||||
" } else clearNetworkSecret();\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; networkAbort = controller; networkButtons();\n"
|
||||
" const current = () => networkAbort === controller && networkLive();\n"
|
||||
" controller.signal.addEventListener('abort', () => { body = undefined; }, {once: true});\n"
|
||||
" let deadline, until = Infinity, refresh = false;\n"
|
||||
" detail.textContent = networkWarning + (action ? 'Submitting once; acknowledgement is not completion or connection.' : 'Reading latest result for this login...');\n"
|
||||
" net('detail').textContent = 'Snapshot stale until outcome is known and refresh succeeds.'; networkFresh = false;\n"
|
||||
" const read = async (method, input) => {\n"
|
||||
" const response = api('/api/settings/network-operation', generation, {method, body: input, signal: controller.signal, limit: 128, current: () => current() && performance.now() < until}); input = undefined;\n"
|
||||
" const {payload: r, status} = await response;\n"
|
||||
" if (performance.now() >= until || status !== (method === 'POST' ? 202 : 200) || !netShape(r, ['id','action','state','error']) || !netInteger(r.id, 0, 4294967295) || !netInteger(r.error, -2147483648, 2147483647) || !['none',...networkActions].includes(r.action) || !['idle','pending','accepted','ok','failed','cancelled','stale','invalid','loaded_defaults','applied_not_queued'].includes(r.state) || ((r.id === 0) !== (r.state === 'idle')) || ((r.id === 0) !== (r.action === 'none')) || ['idle','pending'].includes(r.state) && r.error !== 0 || method === 'POST' && (!r.id || r.action !== action || r.state !== 'pending') || r.state === 'ok' && !['wifi-save','mdns-save'].includes(r.action) || r.state === 'accepted' && ['wifi-save','mdns-save'].includes(r.action) || r.state === 'loaded_defaults' && r.action !== 'mdns-load' || r.state === 'applied_not_queued' && !['mdns-set','mdns-load','mdns-defaults'].includes(r.action)) throw new Error('Invalid network result');\n"
|
||||
" if (method === 'GET' && networkAwaitingAck) networkWarning = 'Acknowledgement lost: latest result may belong to an earlier request or another tab. Inspect before retrying. ';\n"
|
||||
" else if (method === 'GET' && networkId && r.id !== networkId) networkWarning = 'Previous result replaced or unavailable; its outcome is unknown. Inspect before retrying. ';\n"
|
||||
" else if (method === 'POST') networkWarning = '';\n"
|
||||
" if (method === 'GET' && networkId && networkId === r.id && networkAction && networkAction !== r.action) throw new Error('Operation action changed for the same ID');\n"
|
||||
" const matched = method === 'POST' || !networkId || networkId === r.id; networkAction = r.action;\n"
|
||||
" networkId = r.id; networkPending = r.state === 'pending'; networkAwaitingAck = false;\n"
|
||||
" const messages = {idle: 'No retained result; outcome may be unknown. Inspect before retrying.', pending: 'Queued or executing; do not resubmit.', accepted: 'Accepted: RAM apply / owner queue request only. NOT association, DHCP, online, radio completion or verified DNS. Inspect refreshed runtime; recover through STA/AP, UART0 or native USB if disconnected.', ok: 'Explicit Save completed successfully; device working state persisted, not browser drafts.', failed: 'Canonical/owner/storage failure. Inspect refreshed working state and runtime before retrying.', cancelled: 'Cancelled before canonical admission (queue deadline or session/currentness).', stale: 'Generation stale. No automatic retry. Refresh and review changes before a new explicit submission.', invalid: 'Canonical configuration rejected. Inspect refreshed values and correct the draft.', loaded_defaults: 'mDNS Load selected deterministic defaults in RAM and queued reannouncement. NVS unchanged.', applied_not_queued: 'mDNS RAM changed but live reannouncement queue failed. NOT rolled back. Refresh; do not assume DNS changed.'};\n"
|
||||
" detail.textContent = networkWarning + r.action + ': ' + messages[r.state] + ' Error: ' + r.error + '.';\n"
|
||||
" // A replaced result is not completion of our acknowledged operation; never auto-follow it.\n"
|
||||
" return {state: r.state, matched};\n"
|
||||
" };\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
||||
" if (action) { networkPending = true; networkAwaitingAck = true; }\n"
|
||||
" const response = read(action ? 'POST' : 'GET', body); body = undefined;\n"
|
||||
" let result = await response;\n"
|
||||
" if (action) {\n"
|
||||
" until = performance.now() + 15000; deadline = window.setTimeout(() => controller.abort(), 15000);\n"
|
||||
" controller.signal.addEventListener('abort', () => window.clearTimeout(deadline), {once: true});\n"
|
||||
" for (let attempt = 0; result.state === 'pending' && result.matched && attempt < 10; ++attempt) {\n"
|
||||
" await new Promise((resolve, reject) => {\n"
|
||||
" const abort = () => { window.clearTimeout(timer); controller.signal.removeEventListener('abort', abort); reject(new Error('Cancelled')); };\n"
|
||||
" const timer = window.setTimeout(() => { controller.signal.removeEventListener('abort', abort); resolve(); }, 1000);\n"
|
||||
" controller.signal.addEventListener('abort', abort, {once: true}); if (controller.signal.aborted) abort();\n"
|
||||
" });\n"
|
||||
" if (!current() || performance.now() >= until || !await loadSession(generation, controller.signal, false) || !current() || performance.now() >= until) throw new Error('Session changed or deadline');\n"
|
||||
" result = await read('GET');\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" if (result.state === 'pending') detail.textContent += ' Automatic checking stopped. Use Check Result; do not resubmit.';\n"
|
||||
" refresh = result.state !== 'pending' && result.state !== 'idle';\n"
|
||||
" } catch (error) { if (live(generation) && current()) detail.textContent = networkWarning + (error.status ? error.message : 'Operation outcome unknown.') + ' Check Result and Refresh manually. No automatic mutation retry. Connection loss/401 is NOT success or cancellation; recover through STA/AP, UART0 or native USB.'; }\n"
|
||||
" finally { body = undefined; window.clearTimeout(deadline); if (current()) { networkAbort = null; networkButtons(); if (refresh) await refreshNetwork(); } }\n"
|
||||
"}\n"
|
||||
"net('target').value = 'ap';\n"
|
||||
"element('settings-network').addEventListener('click', () => selectSettingsDomain('network'));\n"
|
||||
"net('refresh').addEventListener('click', refreshNetwork);\n"
|
||||
"net('result').addEventListener('click', () => networkOperation(null));\n"
|
||||
"net('apply').addEventListener('click', () => networkOperation(net('target').value === 'ap' ? 'wifi-patch' : 'profile-patch'));\n"
|
||||
"for (const action of networkActions.filter(a => !a.endsWith('-patch'))) net(action).addEventListener('click', () => networkOperation(action));\n"
|
||||
"net('target').addEventListener('change', () => { changeNetworkContext(); renderNetworkTarget(); });\n"
|
||||
"net('password-mode').addEventListener('change', () => {\n"
|
||||
" const mode = net('password-mode').value; changeNetworkContext();\n"
|
||||
" net('password-mode').value = ['keep','replace','clear'].includes(mode) && !(mode === 'clear' && net('target').value === 'ap') ? mode : 'keep'; networkButtons();\n"
|
||||
"});\n"
|
||||
"net('password').addEventListener('input', () => {\n"
|
||||
" if (net('password-mode').value !== 'replace' || !networkLive() || networkAbort || networkPending) { clearNetworkSecret(); return; }\n"
|
||||
" if (!networkSecretTimer) { networkSecretContext = networkContext(); networkSecretUntil = performance.now() + 60000; networkSecretTimer = window.setTimeout(() => { clearNetworkSecret(); networkButtons(); net('ssid-detail').textContent = 'Transient password expired; Keep restored. Re-enter explicitly to replace.'; }, 60000); }\n"
|
||||
"});\n"
|
||||
"net('ssid-mode').addEventListener('change', () => {\n"
|
||||
" changeNetworkContext(); const next = net('ssid-mode').value;\n"
|
||||
" try {\n"
|
||||
" if (!['text','hex'].includes(next)) throw new Error('Select text or hex.');\n"
|
||||
" const bytes = networkSSID(); net('ssid').value = next === 'hex' ? networkHex(bytes) : networkText(bytes); networkSSIDMode = next;\n"
|
||||
" net('ssid-detail').textContent = bytes.length + ' bytes; lossless mode change.';\n"
|
||||
" } catch (error) { net('ssid-mode').value = networkSSIDMode; net('ssid-detail').textContent = error.message + ' Original input retained.'; }\n"
|
||||
" networkButtons();\n"
|
||||
"});\n"
|
||||
"for (const id of ['boot','policy','channel','enabled','priority','security','ssid','suffix']) net(id).addEventListener(['ssid','suffix','channel','priority'].includes(id) ? 'input' : 'change', () => {\n"
|
||||
" changeNetworkContext(); networkButtons();\n"
|
||||
" if (id === 'ssid') { try { net('ssid-detail').textContent = networkSSID().length + ' / 32 bytes (' + (networkSSIDMode === 'text' ? 'UTF-8' : 'exact hex') + ').'; } catch (error) { net('ssid-detail').textContent = error.message; } }\n"
|
||||
"});\n"
|
||||
"let accountRole = 'user', selected = 'serial';\n"
|
||||
"let adminTerminal = null, adminFit = null, adminSocket = null, adminAbort = null;\n"
|
||||
"let adminGeneration = 0, adminTimer = null;\n"
|
||||
|
||||
+133
-19
@@ -18,6 +18,7 @@
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "mdns_service.h"
|
||||
#include "nvs.h"
|
||||
|
||||
#define WIFI_MANAGER_QUEUE_LENGTH 16U
|
||||
#define WIFI_MANAGER_TASK_STACK_SIZE 6144U
|
||||
@@ -1414,36 +1415,147 @@ esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
|
||||
/* Caller holds s_mutex; publication and owner admission are one transaction. */
|
||||
static esp_err_t apply_config_locked(const wifi_app_config_t *config)
|
||||
{
|
||||
esp_err_t error = wifi_config_validate(config);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
if (s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
lock_shared();
|
||||
if (error != ESP_OK) return error;
|
||||
if (s_shared.snapshot.config_generation == UINT32_MAX) return ESP_ERR_INVALID_STATE;
|
||||
bool restart_radio = config_requires_radio_restart(&s_shared.config, config);
|
||||
if (restart_radio) {
|
||||
manager_message_t message = {.type = MESSAGE_COMMAND_APPLY};
|
||||
if (!enqueue_message(&message)) {
|
||||
unlock_shared();
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
if (!enqueue_message(&message)) return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
s_shared.config = *config;
|
||||
++s_shared.snapshot.config_generation;
|
||||
if (s_shared.snapshot.config_generation == 0U) {
|
||||
s_shared.snapshot.config_generation = 1U;
|
||||
}
|
||||
s_shared.snapshot.ap_policy = config->ap_policy;
|
||||
++s_shared.snapshot.counters.applies;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
|
||||
{
|
||||
if (!config) return ESP_ERR_INVALID_ARG;
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
lock_shared();
|
||||
esp_err_t error = apply_config_locked(config);
|
||||
unlock_shared();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *settings)
|
||||
{
|
||||
if (!settings) return ESP_ERR_INVALID_ARG;
|
||||
memset(settings, 0, sizeof(*settings));
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
settings->runtime = s_shared.snapshot;
|
||||
portENTER_CRITICAL(&s_drop_mux);
|
||||
settings->runtime.counters.queue_drops = s_queue_drops;
|
||||
portEXIT_CRITICAL(&s_drop_mux);
|
||||
settings->enabled_at_boot = s_shared.config.enabled_at_boot;
|
||||
settings->ap_policy = s_shared.config.ap_policy;
|
||||
settings->ap_channel = s_shared.config.ap_channel;
|
||||
settings->ap_ssid_len = s_shared.config.ap_ssid_len;
|
||||
memcpy(settings->ap_ssid, s_shared.config.ap_ssid, sizeof(settings->ap_ssid));
|
||||
settings->ap_password_configured = s_shared.config.ap_psk_len != 0;
|
||||
for (unsigned i = 0; i < WIFI_CONFIG_STA_PROFILE_COUNT; ++i) {
|
||||
const wifi_config_sta_profile_t *source = &s_shared.config.profiles[i];
|
||||
wifi_manager_profile_settings_t *target = &settings->profiles[i];
|
||||
target->enabled = source->enabled;
|
||||
target->priority = source->priority;
|
||||
target->security = source->security;
|
||||
target->ssid_len = source->ssid_len;
|
||||
memcpy(target->ssid, source->ssid, sizeof(target->ssid));
|
||||
target->password_configured = source->psk_len != 0;
|
||||
}
|
||||
unlock_shared();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static bool generation_matches(uint32_t generation)
|
||||
{
|
||||
return generation && generation == s_shared.snapshot.config_generation;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_patch_current(uint32_t generation, const wifi_manager_patch_t *patch)
|
||||
{
|
||||
if (!patch || patch->profile < -1 || patch->profile >= (int)WIFI_CONFIG_STA_PROFILE_COUNT ||
|
||||
!patch->fields || patch->ssid_len > WIFI_CONFIG_SSID_MAX_LEN ||
|
||||
patch->password_len > WIFI_CONFIG_PSK_MAX_LEN) return ESP_ERR_INVALID_ARG;
|
||||
uint32_t allowed = WIFI_PATCH_SSID | WIFI_PATCH_PASSWORD |
|
||||
(patch->profile < 0 ? WIFI_PATCH_BOOT | WIFI_PATCH_POLICY | WIFI_PATCH_CHANNEL :
|
||||
WIFI_PATCH_ENABLED | WIFI_PATCH_PRIORITY | WIFI_PATCH_SECURITY);
|
||||
if (patch->fields & ~allowed) return ESP_ERR_INVALID_ARG;
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
lock_shared();
|
||||
if (!generation_matches(generation)) { unlock_shared(); return ESP_ERR_NOT_FOUND; }
|
||||
wifi_app_config_t candidate = s_shared.config;
|
||||
uint8_t *ssid, *ssid_len, *password, *password_len;
|
||||
if (patch->profile < 0) {
|
||||
if (patch->fields & WIFI_PATCH_BOOT) candidate.enabled_at_boot = patch->enabled_at_boot;
|
||||
if (patch->fields & WIFI_PATCH_POLICY) candidate.ap_policy = patch->ap_policy;
|
||||
if (patch->fields & WIFI_PATCH_CHANNEL) candidate.ap_channel = patch->ap_channel;
|
||||
ssid = candidate.ap_ssid; ssid_len = &candidate.ap_ssid_len;
|
||||
password = candidate.ap_psk; password_len = &candidate.ap_psk_len;
|
||||
} else {
|
||||
wifi_config_sta_profile_t *profile = &candidate.profiles[(unsigned)patch->profile];
|
||||
if (patch->fields & WIFI_PATCH_ENABLED) profile->enabled = patch->enabled;
|
||||
if (patch->fields & WIFI_PATCH_PRIORITY) profile->priority = patch->priority;
|
||||
if (patch->fields & WIFI_PATCH_SECURITY) profile->security = patch->security;
|
||||
ssid = profile->ssid; ssid_len = &profile->ssid_len;
|
||||
password = profile->psk; password_len = &profile->psk_len;
|
||||
}
|
||||
if (patch->fields & WIFI_PATCH_SSID) {
|
||||
memset(ssid, 0, WIFI_CONFIG_SSID_MAX_LEN);
|
||||
memcpy(ssid, patch->ssid, patch->ssid_len); *ssid_len = patch->ssid_len;
|
||||
}
|
||||
if (patch->fields & WIFI_PATCH_PASSWORD) {
|
||||
wifi_config_secure_wipe(password, WIFI_CONFIG_PSK_MAX_LEN);
|
||||
memcpy(password, patch->password, patch->password_len); *password_len = patch->password_len;
|
||||
}
|
||||
esp_err_t error = apply_config_locked(&candidate);
|
||||
wifi_config_secure_wipe(&candidate, sizeof(candidate));
|
||||
unlock_shared();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_save_current(uint32_t generation)
|
||||
{
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
lock_shared();
|
||||
/* Hold the config lock through persistence, not HTTPD. Local controls cannot
|
||||
* change the selected generation while its bytes are being committed. */
|
||||
esp_err_t error = generation_matches(generation) ? wifi_config_save(&s_shared.config) : ESP_ERR_NOT_FOUND;
|
||||
unlock_shared();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_load_current(uint32_t generation)
|
||||
{
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
lock_shared();
|
||||
if (!generation_matches(generation)) { unlock_shared(); return ESP_ERR_NOT_FOUND; }
|
||||
/* wifi_config_load intentionally generates fallback credentials. Browser
|
||||
* load must instead read the same canonical blob without that fallback. */
|
||||
wifi_app_config_t candidate = {0};
|
||||
esp_err_t error = wifi_config_storage_init();
|
||||
nvs_handle_t handle;
|
||||
if (error == ESP_OK) {
|
||||
error = nvs_open(WIFI_CONFIG_NVS_NAMESPACE, NVS_READONLY, &handle);
|
||||
if (error == ESP_OK) {
|
||||
size_t size = sizeof(candidate);
|
||||
error = nvs_get_blob(handle, WIFI_CONFIG_NVS_BLOB_KEY, &candidate, &size);
|
||||
nvs_close(handle);
|
||||
if (error == ESP_OK && size != sizeof(candidate)) error = ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
}
|
||||
if (error == ESP_OK) error = apply_config_locked(&candidate);
|
||||
wifi_config_secure_wipe(&candidate, sizeof(candidate));
|
||||
unlock_shared();
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
|
||||
int enabled_at_boot)
|
||||
{
|
||||
@@ -1453,6 +1565,11 @@ static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
|
||||
|
||||
manager_message_t message = {.type = type};
|
||||
lock_shared();
|
||||
if (enabled_at_boot >= 0 && s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot &&
|
||||
s_shared.snapshot.config_generation == UINT32_MAX) {
|
||||
unlock_shared();
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (!enqueue_message(&message)) {
|
||||
unlock_shared();
|
||||
return ESP_ERR_TIMEOUT;
|
||||
@@ -1462,9 +1579,6 @@ static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
|
||||
s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot) {
|
||||
s_shared.config.enabled_at_boot = (uint8_t)enabled_at_boot;
|
||||
++s_shared.snapshot.config_generation;
|
||||
if (s_shared.snapshot.config_generation == 0U) {
|
||||
s_shared.snapshot.config_generation = 1U;
|
||||
}
|
||||
}
|
||||
unlock_shared();
|
||||
return ESP_OK;
|
||||
|
||||
@@ -89,6 +89,52 @@ esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config);
|
||||
*/
|
||||
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config);
|
||||
|
||||
/* Secret-free working projection, copied together with runtime under the mutex.
|
||||
* Zero wait: ESP_ERR_TIMEOUT means no snapshot was obtained. Password presence
|
||||
* is the only credential metadata, needed to stage/enable disabled profiles. */
|
||||
typedef struct {
|
||||
uint8_t enabled, priority;
|
||||
wifi_config_security_t security;
|
||||
uint8_t ssid_len, ssid[WIFI_CONFIG_SSID_MAX_LEN];
|
||||
bool password_configured;
|
||||
} wifi_manager_profile_settings_t;
|
||||
typedef struct {
|
||||
wifi_manager_snapshot_t runtime;
|
||||
uint8_t enabled_at_boot, ap_channel;
|
||||
wifi_config_ap_policy_t ap_policy;
|
||||
uint8_t ap_ssid_len, ap_ssid[WIFI_CONFIG_SSID_MAX_LEN];
|
||||
bool ap_password_configured;
|
||||
wifi_manager_profile_settings_t profiles[WIFI_CONFIG_STA_PROFILE_COUNT];
|
||||
} wifi_manager_settings_t;
|
||||
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *settings);
|
||||
|
||||
enum {
|
||||
WIFI_PATCH_BOOT = 1U << 0, WIFI_PATCH_POLICY = 1U << 1,
|
||||
WIFI_PATCH_CHANNEL = 1U << 2, WIFI_PATCH_ENABLED = 1U << 3,
|
||||
WIFI_PATCH_PRIORITY = 1U << 4, WIFI_PATCH_SECURITY = 1U << 5,
|
||||
WIFI_PATCH_SSID = 1U << 6, WIFI_PATCH_PASSWORD = 1U << 7,
|
||||
};
|
||||
/* profile=-1 selects AP/global fields; 0..3 selects a station profile.
|
||||
* Absent bits preserve CURRENT bytes, never a stale caller's secret copy.
|
||||
* PASSWORD with length zero clears only when canonical validation permits it.
|
||||
* Caller owns and must wipe this transient input after every exit path. */
|
||||
typedef struct {
|
||||
uint32_t fields;
|
||||
int8_t profile;
|
||||
uint8_t enabled_at_boot, ap_channel, enabled, priority;
|
||||
wifi_config_ap_policy_t ap_policy;
|
||||
wifi_config_security_t security;
|
||||
uint8_t ssid_len, ssid[WIFI_CONFIG_SSID_MAX_LEN];
|
||||
uint8_t password_len, password[WIFI_CONFIG_PSK_MAX_LEN];
|
||||
} wifi_manager_patch_t;
|
||||
/* Dispatcher-only conditional operations. A nonzero expected generation must
|
||||
* match under the mutation mutex; ESP_ERR_NOT_FOUND denotes stale selection.
|
||||
* No generation wrap/reuse. Queue failure leaves RAM untouched. */
|
||||
esp_err_t wifi_manager_patch_current(uint32_t generation, const wifi_manager_patch_t *patch);
|
||||
esp_err_t wifi_manager_save_current(uint32_t generation);
|
||||
/* Stored-only load: never generates or installs unknown default credentials. */
|
||||
esp_err_t wifi_manager_load_current(uint32_t generation);
|
||||
|
||||
/* Lifecycle requests are asynchronous and serialized by the manager task. */
|
||||
esp_err_t wifi_manager_start(void);
|
||||
esp_err_t wifi_manager_stop(void);
|
||||
|
||||
@@ -31,7 +31,8 @@ typedef int *SemaphoreHandle_t;
|
||||
#define pdMS_TO_TICKS(x) (x)
|
||||
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
|
||||
static unsigned lock_depth, ticks, runs, actions;
|
||||
static uint32_t serial_settings_executed, account_settings_executed;
|
||||
static uint32_t serial_settings_executed, account_settings_executed, network_settings_executed;
|
||||
static void web_network_settings_execute(uint32_t id) { assert(!lock_depth); network_settings_executed = id; }
|
||||
static void web_account_settings_execute(uint32_t id) { assert(!lock_depth); account_settings_executed = id; }
|
||||
static unsigned serial_settings_preceding_runs, queue_send_wait;
|
||||
static void web_serial_settings_execute(uint32_t id) {
|
||||
|
||||
@@ -351,5 +351,18 @@ int main(void)
|
||||
pump(worker_task);
|
||||
assert(serial_settings_executed == 21 && account_settings_executed == 22 && runs == before_serial + 5);
|
||||
puts("PASS: typed Accounts uses same bounded queue with nonblocking admission and isolated dispatcher routing");
|
||||
assert(admin_ssh_console_submit_network_settings(0) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = false;
|
||||
assert(admin_ssh_console_submit_network_settings(1) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = true; queue_full = true;
|
||||
assert(admin_ssh_console_submit_network_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
|
||||
queue_full = false;
|
||||
assert(admin_ssh_console_submit_serial_settings(31) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_network_settings(32) == ESP_OK && queue_send_wait == 0);
|
||||
assert(admin_ssh_console_submit_account_settings(33) == ESP_OK);
|
||||
pump(worker_task);
|
||||
assert(serial_settings_executed == 31 && network_settings_executed == 32 && account_settings_executed == 33);
|
||||
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
|
||||
puts("PASS: typed Network queues only an ID, shares unchanged queue, executes outside lock without command runner");
|
||||
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ def define(path, name):
|
||||
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
|
||||
source, re.M | re.S)
|
||||
# Non-array declarations have no brackets; explicit shape avoids silent omission.
|
||||
if len(uri_tables) != 21:
|
||||
raise RuntimeError('Review URI extraction: expected 19 descriptors and two tables')
|
||||
if len(uri_tables) != 24:
|
||||
raise RuntimeError('Review URI extraction: expected 22 descriptors and two tables')
|
||||
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
|
||||
source.index('static esp_err_t ensure_mutex(void)')]
|
||||
header = (ROOT / 'src/web_server.h').read_text()
|
||||
@@ -133,6 +133,26 @@ static void web_httpd_idle_stopped(httpd_handle_t s) {
|
||||
HANDLER(serial_settings_handler)
|
||||
HANDLER(web_serial_settings_handler) HANDLER(web_account_settings_handler)
|
||||
HANDLER(web_account_generate_password_handler) HANDLER(web_account_keys_handler)
|
||||
HANDLER(web_network_snapshot_handler) HANDLER(web_network_operation_handler)
|
||||
static unsigned network_calls, network_allocations, network_fail_at;
|
||||
static esp_err_t network_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked);
|
||||
assert(!uri->is_websocket && !uri->handle_ws_control_frames && !uri->user_ctx);
|
||||
++network_calls;
|
||||
if (network_calls == 1) {
|
||||
assert(!strcmp(uri->uri, "/api/settings/network") && uri->method == HTTP_GET);
|
||||
assert(uri->handler == web_network_snapshot_handler);
|
||||
} else {
|
||||
assert(!strcmp(uri->uri, "/api/settings/network-operation"));
|
||||
assert(uri->method == (network_calls == 2 ? HTTP_GET : HTTP_POST));
|
||||
assert(uri->handler == web_network_operation_handler && network_calls <= 3);
|
||||
}
|
||||
/* Model the adapter's staged descriptor/name allocations, before publication. */
|
||||
for (unsigned allocation = 0; allocation < 2; ++allocation)
|
||||
if (++network_allocations == network_fail_at) return ESP_ERR_NO_MEM;
|
||||
registered[registered_count++] = uri;
|
||||
return ESP_OK;
|
||||
}
|
||||
static unsigned keys_calls;
|
||||
static bool keys_fail;
|
||||
static unsigned account_calls, account_fail_at;
|
||||
@@ -149,7 +169,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
|
||||
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
|
||||
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
|
||||
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
|
||||
assert(config->httpd.max_uri_handlers == 24 && config->port_secure == 443);
|
||||
assert(config->httpd.max_uri_handlers == 27 && config->port_secure == 443);
|
||||
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
|
||||
assert(config->tls_handshake_timeout_ms == 5000);
|
||||
assert(config->user_cb == tls_session_callback);
|
||||
@@ -187,11 +207,14 @@ static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
}
|
||||
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(uri->method == HTTP_GET);
|
||||
if (uri->handler == web_network_snapshot_handler || uri->handler == web_network_operation_handler)
|
||||
return network_register(s, uri);
|
||||
if (uri->handler == web_account_settings_handler) return account_register(s, uri);
|
||||
assert(!strcmp(uri->uri, "/api/settings/serial"));
|
||||
return httpd_register_uri_handler(s, uri);
|
||||
}
|
||||
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
if (uri->handler == web_network_operation_handler) return network_register(s, uri);
|
||||
if (uri->handler == web_account_keys_handler) {
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked);
|
||||
assert(!strcmp(uri->uri, "/api/settings/accounts/keys") && uri->method == HTTP_POST);
|
||||
@@ -217,7 +240,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
|
||||
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
|
||||
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
|
||||
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
|
||||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation")) && method == HTTP_GET));
|
||||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation")) && method == HTTP_GET));
|
||||
++unregister_calls;
|
||||
for (unsigned i = 0; i < registered_count; ++i) {
|
||||
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
|
||||
@@ -285,10 +308,14 @@ static void reset(void) {
|
||||
registration_calls = registration_fail_at = registered_count = unregister_calls = 0;
|
||||
unregister_fail = settings_fail = false; settings_calls = 0; clear_events();
|
||||
operation_calls = operation_fail_at = 0;
|
||||
network_calls = network_allocations = network_fail_at = 0;
|
||||
account_calls = account_fail_at = generation_calls = keys_calls = 0;
|
||||
generation_fail = keys_fail = false;
|
||||
}
|
||||
static void fresh_registration(void) { registration_calls = registered_count = 0; }
|
||||
static void fresh_registration(void) {
|
||||
registration_calls = registered_count = 0;
|
||||
network_calls = network_allocations = 0;
|
||||
}
|
||||
static void start(void) {
|
||||
assert(web_server_start() == ESP_OK);
|
||||
assert(s_server == SERVER && s_admin_transport_owned && s_serial_transport_attached);
|
||||
@@ -301,6 +328,37 @@ static const httpd_uri_t *route(const char *uri) {
|
||||
}
|
||||
assert(found); return found;
|
||||
}
|
||||
static const httpd_uri_t *method_route(const char *uri, int method) {
|
||||
const httpd_uri_t *found = NULL;
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
|
||||
assert(!found); found = registered[i];
|
||||
}
|
||||
return found;
|
||||
}
|
||||
static void network_complete(void) {
|
||||
assert(network_calls == 3 && network_allocations == 6);
|
||||
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
||||
for (int method = HTTP_GET; method <= HTTP_POST; ++method) {
|
||||
const httpd_uri_t *r = method_route("/api/settings/network-operation", method);
|
||||
assert(r && r->handler == web_network_operation_handler);
|
||||
}
|
||||
}
|
||||
static void other_domains_complete(void) {
|
||||
assert(auth_live && ssl_live && serial_live && admin_owned && idle_owned);
|
||||
assert(!auth_stops && !ssl_stops && !s_counters.start_failures);
|
||||
assert(route("/api/session")->handler == web_cookie_auth_handler);
|
||||
assert(route("/ws/serial")->handler == traced_websocket_handler);
|
||||
assert(route("/ws/admin")->handler == traced_admin_upgrade_handler);
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
for (int method = HTTP_GET; method <= HTTP_POST; ++method) {
|
||||
assert(method_route("/api/settings/serial-operation", method)->handler == web_serial_settings_handler);
|
||||
assert(method_route("/api/settings/account-operation", method)->handler == web_account_settings_handler);
|
||||
}
|
||||
}
|
||||
int main(void) {
|
||||
reset(); mutex_fail = true;
|
||||
assert(web_server_init() == ESP_ERR_NO_MEM && !s_initialized && !serial_inits);
|
||||
@@ -320,7 +378,7 @@ int main(void) {
|
||||
}
|
||||
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
||||
|
||||
reset(); start(); assert(registered_count == 24 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
reset(); start(); assert(registered_count == 27 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
@@ -374,7 +432,7 @@ int main(void) {
|
||||
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
|
||||
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
|
||||
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
|
||||
assert(registered_count == 22 && unregister_calls == failure - 17);
|
||||
assert(registered_count == 25 && unregister_calls == failure - 17);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
|
||||
assert(route("/ws/serial")->handler == traced_websocket_handler);
|
||||
@@ -383,13 +441,13 @@ int main(void) {
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||
assert(!admin_detaches && !admin_stoppeds);
|
||||
registration_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 24 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(registered_count == 27 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
|
||||
}
|
||||
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
|
||||
|
||||
reset(); registration_fail_at = 18; unregister_fail = true;
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 23);
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 26);
|
||||
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
|
||||
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
|
||||
ticket = route("/api/admin/ws-ticket");
|
||||
@@ -401,7 +459,7 @@ int main(void) {
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||
assert(!admin_detaches && !admin_stoppeds);
|
||||
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 24 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
assert(registered_count == 27 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
|
||||
|
||||
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
|
||||
@@ -423,7 +481,7 @@ int main(void) {
|
||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
|
||||
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
|
||||
reset(); settings_fail = true; start();
|
||||
assert(settings_calls == 1 && registered_count == 23);
|
||||
assert(settings_calls == 1 && registered_count == 26);
|
||||
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
|
||||
settings_fail = false; fresh_registration(); start();
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
@@ -431,7 +489,7 @@ int main(void) {
|
||||
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
|
||||
for (unsigned failure = 1; failure <= 2; ++failure) {
|
||||
reset(); operation_fail_at = failure; start();
|
||||
assert(registered_count == 22 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(registered_count == 25 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
@@ -439,7 +497,7 @@ int main(void) {
|
||||
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
|
||||
for (unsigned failure = 1; failure <= 3; ++failure) {
|
||||
reset(); account_calls = 0; account_fail_at = failure; start();
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 21 : 22));
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 24 : 25));
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
@@ -447,17 +505,17 @@ int main(void) {
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
|
||||
assert(registered_count == 24 && account_calls == 3);
|
||||
assert(registered_count == 27 && account_calls == 3);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
|
||||
assert(registered_count == 23 && auth_live && serial_live && admin_owned);
|
||||
assert(registered_count == 26 && auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
|
||||
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
|
||||
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
|
||||
reset(); generation_fail = true; start();
|
||||
assert(generation_calls == 1 && registered_count == 23 && account_calls == 3);
|
||||
assert(generation_calls == 1 && registered_count == 26 && account_calls == 3);
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
@@ -469,12 +527,12 @@ int main(void) {
|
||||
}
|
||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||
generation_fail = false; fresh_registration(); start();
|
||||
assert(generation_calls == 2 && registered_count == 24);
|
||||
assert(generation_calls == 2 && registered_count == 27);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
|
||||
reset(); keys_fail = true; start();
|
||||
assert(keys_calls == 1 && registered_count == 23 && account_calls == 3 && generation_calls == 1);
|
||||
assert(keys_calls == 1 && registered_count == 26 && account_calls == 3 && generation_calls == 1);
|
||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
@@ -489,7 +547,7 @@ int main(void) {
|
||||
}
|
||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||
keys_fail = false; fresh_registration(); start();
|
||||
assert(keys_calls == 2 && registered_count == 24);
|
||||
assert(keys_calls == 2 && registered_count == 27);
|
||||
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
|
||||
@@ -510,7 +568,49 @@ int main(void) {
|
||||
assert(web_server_stop() == ESP_OK && !idle_owned && idle_stoppeds == 1);
|
||||
fresh_registration(); start(); assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS idle submit fence failure forbids SSL destruction; failed stop retains ownership until retry/restart");
|
||||
puts("18 lifecycle groups passed (16 required fatal positions, 10 optional routes, plus failed unregister)");
|
||||
for (unsigned failure = 1; failure <= 6; ++failure) {
|
||||
reset(); network_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(network_calls == failed_route && network_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 24 : 25));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||
assert(!!method_route("/api/settings/network", HTTP_GET) == (failed_route != 1));
|
||||
other_domains_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
network_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 27); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); network_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 26 && unregister_calls == 1);
|
||||
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
||||
assert(method_route("/api/settings/network-operation", HTTP_GET)->handler == web_network_operation_handler);
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||
other_domains_complete();
|
||||
ssl_stop_error = ESP_FAIL;
|
||||
assert(web_server_stop() == ESP_FAIL && s_server == SERVER);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 27); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
for (unsigned failure = 0; failure < 8; ++failure) {
|
||||
reset();
|
||||
if (failure == 0) settings_fail = true;
|
||||
else if (failure <= 2) operation_fail_at = failure;
|
||||
else if (failure <= 5) account_fail_at = failure - 2;
|
||||
else if (failure == 6) generation_fail = true;
|
||||
else keys_fail = true;
|
||||
start(); network_complete(); assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS every other settings route failure leaves the complete Network domain available");
|
||||
puts("21 lifecycle groups passed (16 required fatal positions, 13 optional routes, all six Network allocation positions, plus failed unregister)");
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
/* Real HTTP policy, store, parser and backend; deterministic owner boundaries.
|
||||
* Real mutex-local Wi-Fi/mDNS behavior is covered in tests/web_network_settings. */
|
||||
#include "../../src/web_network_settings.c"
|
||||
#include "network_parse_production.h"
|
||||
static bool dispatcher, queue_fail, snapshot_fail, timer_create_fail, timer_start_fail;
|
||||
static unsigned mutations, projections, timer_creates, timer_starts;
|
||||
static uint32_t queued;
|
||||
static esp_err_t owner_error, mdns_queue_error;
|
||||
static bool stored_mdns = true;
|
||||
static void (*timer_callback)(void *);
|
||||
static void (*owner_hook)(void);
|
||||
static void (*queue_hook)(void);
|
||||
static unsigned expected_action;
|
||||
static void input_wiped(void) {
|
||||
zero(&s_operation.patch,sizeof(s_operation.patch));
|
||||
zero(&s_operation.mdns,sizeof(s_operation.mdns));
|
||||
zero(&s_operation.principal,sizeof(s_operation.principal));
|
||||
assert(!s_operation.generation);
|
||||
}
|
||||
int esp_timer_create(const esp_timer_create_args_t *args, esp_timer_handle_t *out) {
|
||||
assert(!host_lock_depth); ++timer_creates;
|
||||
if (timer_create_fail) return ESP_FAIL;
|
||||
timer_callback=args->callback; *out=(void *)1; return ESP_OK;
|
||||
}
|
||||
int esp_timer_start_periodic(esp_timer_handle_t handle,uint64_t period) {
|
||||
assert(handle && period==1000000 && !host_lock_depth); ++timer_starts;
|
||||
return timer_start_fail ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id) {
|
||||
assert(!dispatcher && !host_lock_depth && id);
|
||||
if(queue_hook) { void (*hook)(void)=queue_hook; queue_hook=NULL; hook(); }
|
||||
if(queue_fail) return ESP_ERR_TIMEOUT;
|
||||
queued=id; return ESP_OK;
|
||||
}
|
||||
static esp_err_t owner(unsigned action) {
|
||||
assert(dispatcher && !host_lock_depth && action==expected_action && s_operation.executing);
|
||||
input_wiped(); ++mutations;
|
||||
if(owner_hook) { void (*hook)(void)=owner_hook; owner_hook=NULL; hook(); }
|
||||
return owner_error;
|
||||
}
|
||||
esp_err_t wifi_manager_patch_current(uint32_t generation,const wifi_manager_patch_t *patch) {
|
||||
assert(generation==7 && patch->fields);
|
||||
if(expected_action==PROFILE_PATCH) assert(patch->profile==0); else assert(patch->profile==-1);
|
||||
return owner(expected_action);
|
||||
}
|
||||
esp_err_t wifi_manager_save_current(uint32_t generation) { assert(generation==7); return owner(WIFI_SAVE); }
|
||||
esp_err_t wifi_manager_load_current(uint32_t generation) { assert(generation==7); return owner(WIFI_LOAD); }
|
||||
esp_err_t wifi_manager_start(void) { return owner(START); }
|
||||
esp_err_t wifi_manager_stop(void) { return owner(STOP); }
|
||||
esp_err_t wifi_manager_reconnect(void) { return owner(RECONNECT); }
|
||||
esp_err_t wifi_manager_next_profile(void) { return owner(NEXT_PROFILE); }
|
||||
esp_err_t wifi_manager_mdns_reannounce(void) { assert(dispatcher); return mdns_queue_error; }
|
||||
esp_err_t mdns_service_update_current(uint32_t generation,mdns_settings_action_t action,const mdns_config_t *config,bool *stored) {
|
||||
assert(generation==7 && action==(mdns_settings_action_t)(expected_action-MDNS_SET));
|
||||
if(action==MDNS_SETTINGS_SET) assert(mdns_config_validate(config)==ESP_OK);
|
||||
*stored=stored_mdns; return owner(expected_action);
|
||||
}
|
||||
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *out) {
|
||||
assert(!dispatcher && !host_lock_depth); ++projections;
|
||||
if(snapshot_fail) return ESP_ERR_TIMEOUT;
|
||||
memset(out,0,sizeof(*out)); out->runtime.config_generation=UINT32_MAX;
|
||||
out->ap_ssid_len=32; memset(out->ap_ssid,0xff,32); out->ap_policy=WIFI_CONFIG_AP_POLICY_FALLBACK;
|
||||
out->ap_channel=11; out->ap_password_configured=true;
|
||||
for(unsigned i=0;i<4;++i) {
|
||||
out->profiles[i].ssid_len=32; memset(out->profiles[i].ssid,0xff,32);
|
||||
out->profiles[i].priority=255; out->profiles[i].security=WIFI_CONFIG_SECURITY_MIXED;
|
||||
}
|
||||
out->runtime.active_profile=-1; out->runtime.last_error=INT32_MIN;
|
||||
memset(&out->runtime.ip,255,4); out->runtime.ap_client_count=255;
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *out) {
|
||||
assert(!dispatcher && !host_lock_depth); memset(out,0,sizeof(*out));
|
||||
out->config_generation=UINT32_MAX; out->last_error=INT32_MIN;
|
||||
memset(out->suffix,'s',55); strcpy(out->hostname,"sak-"); memset(out->hostname+4,'s',55);
|
||||
return ESP_OK;
|
||||
}
|
||||
const char *wifi_manager_state_to_string(wifi_manager_state_t state) { (void)state; return "waiting-ip"; }
|
||||
static void network_begin(const issued_t *identity,const char *body) {
|
||||
begin("/api/settings/network-operation",body?HTTP_POST:HTTP_GET,body); same_origin();
|
||||
if(body) add("Content-Type","application/json");
|
||||
if(identity) {
|
||||
char cookie[100]; snprintf(cookie,sizeof(cookie),"__Host-sak-session=%s",identity->token); add("Cookie",cookie);
|
||||
if(body) add("X-CSRF-Token",identity->view.csrf);
|
||||
}
|
||||
}
|
||||
static void network_expect(const char *status,bool snapshot_route) {
|
||||
unsigned before=mutations;
|
||||
esp_err_t result=snapshot_route ? web_network_snapshot_handler(&req) : web_network_operation_handler(&req);
|
||||
assert(result==((send_fail || aux.remaining_len || fail_header)?ESP_FAIL:ESP_OK));
|
||||
if(strcmp(status,response_status)) fprintf(stderr,"expected %s got %s (%s)\n",status,response_status,output);
|
||||
assert(!strcmp(status,response_status) && mutations==before);
|
||||
assert(!strstr(output,"supersecret") && !strstr(output,"psk") && !strstr(output,"password_len"));
|
||||
zero(scratch,sizeof(scratch));
|
||||
bool no_store=false;
|
||||
for(unsigned i=0;i<aux.resp_hdrs_count;++i)
|
||||
if(!strcmp(response_headers[i].field,"Cache-Control") && !strcmp(response_headers[i].value,"no-store")) no_store=true;
|
||||
if(!fail_header) assert(no_store);
|
||||
}
|
||||
static const char patch_body[]="{\"action\":\"profile-patch\",\"generation\":7,\"profile\":0,\"password\":\"supersecret\"}";
|
||||
static void submit_network(const issued_t *identity,const char *body) {
|
||||
network_begin(identity,body); network_expect("202 Accepted",false);
|
||||
expected_action=s_operation.action;
|
||||
}
|
||||
static void execute_network(void) { dispatcher=true; web_network_settings_execute(queued); dispatcher=false; }
|
||||
static void expire_queued(void) { now=s_operation.deadline; timer_callback(NULL); }
|
||||
static void executing_tick(void) {
|
||||
assert(s_operation.executing); input_wiped(); now=s_operation.deadline;
|
||||
timer_callback(NULL); assert(s_operation.state==PENDING);
|
||||
}
|
||||
static bool parsed(const char *body) {
|
||||
network_operation_t op={0}; bool valid=parse_request(body,strlen(body),&op); secure_wipe(&op,sizeof(op)); return valid;
|
||||
}
|
||||
static void network_settings_tests(void) {
|
||||
auth_reset(); issued_t admin=mint(&alice), user=mint(&bob); receive_fragment=768;
|
||||
network_begin(NULL,NULL); network_expect("401 Unauthorized",false);
|
||||
network_begin(&user,NULL); network_expect("403 Forbidden",false);
|
||||
network_begin(&user,patch_body); network_expect("403 Forbidden",false);
|
||||
network_begin(&admin,NULL); req.uri="/api/settings/network"; network_expect("200 OK",true);
|
||||
assert(strlen(output)<2048 && strstr(output,"\\u00ff") && strstr(output,"\"generation\":4294967295"));
|
||||
printf("PASS Network maximum escaped snapshot: %zu bytes, no secret fields\n",strlen(output));
|
||||
snapshot_fail=true; network_begin(&admin,NULL); network_expect("503 Service Unavailable",true); snapshot_fail=false;
|
||||
network_begin(&user,NULL); network_expect("403 Forbidden",true);
|
||||
network_begin(&admin,NULL); req.uri="/api/settings/network?secret=x"; network_expect("400 Bad Request",true);
|
||||
network_begin(&admin,patch_body); req.uri="/api/settings/network-operation?x=1"; network_expect("400 Bad Request",false);
|
||||
network_begin(&admin,patch_body); add("X-CSRF-Token",admin.view.csrf); network_expect("400 Bad Request",false);
|
||||
network_begin(&admin,patch_body); add("Origin",origin); network_expect("400 Bad Request",false);
|
||||
begin("/api/settings/network-operation",HTTP_POST,patch_body); same_origin(); add("Content-Type","application/json");
|
||||
char cookie[100]; snprintf(cookie,sizeof(cookie),"__Host-sak-session=%s",admin.token); add("Cookie",cookie);
|
||||
network_expect("403 Forbidden",false);
|
||||
network_begin(&admin,NULL); req.content_len=1; aux.remaining_len=1; network_expect("400 Bad Request",false);
|
||||
char oversized[770]; memset(oversized,' ',769); oversized[769]=0;
|
||||
network_begin(&admin,oversized); network_expect("400 Bad Request",false);
|
||||
puts("PASS Network current-admin, CSRF, duplicate headers, body/query bounds, no-store and zero snapshot writes");
|
||||
|
||||
const char *invalid[]={"{}", "{\"action\":\"defaults\"}", "{\"action\":\"reset\"}",
|
||||
"{\"action\":\"start\",\"generation\":7}", "{\"action\":\"wifi-save\",\"generation\":0}",
|
||||
"{\"action\":\"wifi-save\",\"generation\":4294967296}", "{\"action\":\"wifi-save\",\"generation\":07}",
|
||||
"{\"action\":\"wifi-save\",\"generation\":7.0}", "{\"action\":\"wifi-patch\",\"generation\":7}",
|
||||
"{\"action\":\"wifi-patch\",\"generation\":7,\"enabled\":true}",
|
||||
"{\"action\":\"profile-patch\",\"generation\":7,\"profile\":4,\"enabled\":true}",
|
||||
"{\"action\":\"profile-patch\",\"generation\":7,\"profile\":0,\"priority\":256}",
|
||||
"{\"action\":\"profile-patch\",\"generation\":7,\"profile\":0,\"enabled\":1}",
|
||||
"{\"action\":\"wifi-patch\",\"generation\":7,\"password\":\"\"}",
|
||||
"{\"action\":\"wifi-patch\",\"generation\":7,\"password\":\"short\"}",
|
||||
"{\"action\":\"wifi-patch\",\"generation\":7,\"password\":\"supersecret\",\"clear_password\":true}",
|
||||
"{\"action\":\"wifi-patch\",\"generation\":7,\"clear_password\":false}",
|
||||
"{\"action\":\"wifi-patch\",\"generation\":7,\"ssid\":\"\\u0100\"}",
|
||||
"{\"action\":\"wifi-patch\",\"generation\":7,\"ssid\":\"\\ud800\"}",
|
||||
"{\"action\":\"wifi-patch\",\"generation\":7,\"ssid\":\"é\"}",
|
||||
"{\"action\":\"wifi-patch\",\"generation\":7,\"ap_policy\":\"off\\u0000x\"}",
|
||||
"{\"action\":\"wifi-patch\",\"generation\":7,\"channel\":0}",
|
||||
"{\"action\":\"mdns-set\",\"generation\":7,\"suffix\":\"-bad\"}",
|
||||
"{\"action\":\"mdns-set\",\"generation\":7,\"suffix\":\"BAD\"}",
|
||||
"{\"action\":\"start\",\"action\":\"stop\"}", "{\"action\":\"start\",}",
|
||||
"{\"action\":\"start\",\"unknown\":{}}", "{\"action\":\"start\"}x"};
|
||||
for(unsigned i=0;i<sizeof(invalid)/sizeof(*invalid);++i) {
|
||||
assert(!parsed(invalid[i])); network_begin(&admin,invalid[i]); network_expect("400 Bad Request",false);
|
||||
}
|
||||
/* Every possible byte survives the codec; 32 decoded bytes, not JSON length. */
|
||||
for(unsigned byte=0;byte<256;++byte) {
|
||||
uint8_t raw=(uint8_t)byte; char encoded[16]; size_t used=0;
|
||||
assert(append_ssid(encoded,sizeof(encoded),&used,&raw,1));
|
||||
parser_t p={.body=encoded,.size=used}; uint8_t decoded[1]; size_t n;
|
||||
assert(byte_string(&p,decoded,1,&n) && n==1 && decoded[0]==raw && p.pos==used);
|
||||
}
|
||||
char body[768], ssid[199];
|
||||
for(unsigned n=31;n<=33;++n) {
|
||||
for(unsigned i=0;i<n;++i) memcpy(ssid+i*6,"\\u0000",6);
|
||||
ssid[n*6]=0;
|
||||
snprintf(body,sizeof(body),"{\"action\":\"wifi-patch\",\"generation\":7,\"ssid\":\"%s\"}",ssid);
|
||||
assert(parsed(body)==(n<=32));
|
||||
}
|
||||
for(unsigned n=7;n<=64;++n) {
|
||||
char pass[385]; for(unsigned i=0;i<n;++i) memcpy(pass+i*6,"\\u0022",6); pass[n*6]=0;
|
||||
snprintf(body,sizeof(body),"{\"action\":\"wifi-patch\",\"generation\":7,\"password\":\"%s\"}",pass);
|
||||
assert(parsed(body)==(n>=8 && n<=63));
|
||||
}
|
||||
receive_fragment=1; network_begin(&admin,patch_body); network_expect("400 Bad Request",false); receive_fragment=768;
|
||||
recv_fail=true; network_begin(&admin,patch_body); network_expect("400 Bad Request",false); recv_fail=false;
|
||||
puts("PASS Network strict parser, 256-byte SSID roundtrip, 32-byte SSID and 8..63 password bounds, four receives");
|
||||
|
||||
timer_create_fail=true; network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false); timer_create_fail=false;
|
||||
timer_start_fail=true; network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false); timer_start_fail=false;
|
||||
submit_network(&admin,patch_body); assert(s_operation.patch.password_len==11 && timer_creates==2 && timer_starts==2);
|
||||
network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false);
|
||||
network_begin(&user,NULL); network_expect("403 Forbidden",false);
|
||||
issued_t other=mint(&alice); network_begin(&other,NULL); network_expect("200 OK",false); assert(strstr(output,"\"state\":\"idle\""));
|
||||
uint32_t old=queued; unsigned before=mutations;
|
||||
now=s_operation.deadline-1; timer_callback(NULL); assert(s_operation.state==PENDING);
|
||||
++now; timer_callback(NULL); assert(s_operation.state==CANCELLED); input_wiped();
|
||||
submit_network(&admin,patch_body); dispatcher=true; web_network_settings_execute(old); dispatcher=false;
|
||||
assert(s_operation.state==PENDING && mutations==before); execute_network(); assert(s_operation.state==ACCEPTED); input_wiped();
|
||||
network_begin(&admin,NULL); network_expect("200 OK",false); assert(strstr(output,"\"state\":\"accepted\""));
|
||||
queue_fail=true; network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false); queue_fail=false;
|
||||
zero(&s_operation,sizeof(s_operation));
|
||||
queue_hook=expire_queued; submit_network(&admin,patch_body); assert(s_operation.state==CANCELLED); before=mutations; execute_network(); assert(mutations==before);
|
||||
submit_network(&admin,patch_body); db_hook=executing_tick; execute_network(); assert(s_operation.state==CANCELLED && mutations==before);
|
||||
submit_network(&admin,patch_body); owner_hook=executing_tick; execute_network(); assert(s_operation.state==ACCEPTED); input_wiped();
|
||||
submit_network(&admin,patch_body); web_session_store_invalidate(admin.view.id); execute_network(); assert(s_operation.state==CANCELLED); input_wiped();
|
||||
admin=mint(&alice); submit_network(&admin,patch_body); stale_user=alice.user_id; execute_network(); stale_user=0; assert(s_operation.state==CANCELLED);
|
||||
admin=mint(&alice); submit_network(&admin,patch_body); db_fail=true; execute_network(); db_fail=false; assert(s_operation.state==CANCELLED);
|
||||
puts("PASS Network timer failure/expiry, old IDs, session isolation, executing reservation, logout/revocation/deadline and wiped shared input");
|
||||
|
||||
auth_reset(); admin=mint(&alice);
|
||||
const char *bodies[]={"{\"action\":\"wifi-patch\",\"generation\":7,\"ap_policy\":\"always\"}",patch_body,
|
||||
"{\"action\":\"wifi-save\",\"generation\":7}","{\"action\":\"wifi-load\",\"generation\":7}",
|
||||
"{\"action\":\"start\"}","{\"action\":\"stop\"}","{\"action\":\"reconnect\"}","{\"action\":\"next-profile\"}",
|
||||
"{\"action\":\"mdns-set\",\"generation\":7,\"suffix\":\"new-name\"}","{\"action\":\"mdns-save\",\"generation\":7}",
|
||||
"{\"action\":\"mdns-load\",\"generation\":7}","{\"action\":\"mdns-defaults\",\"generation\":7}"};
|
||||
for(unsigned i=0;i<ACTION_COUNT;++i) {
|
||||
submit_network(&admin,bodies[i]); assert(expected_action==i); execute_network();
|
||||
assert(s_operation.state==((i==WIFI_SAVE || i==MDNS_SAVE)?OK:ACCEPTED));
|
||||
}
|
||||
const esp_err_t errors[]={ESP_ERR_NOT_FOUND,ESP_ERR_INVALID_ARG,ESP_ERR_TIMEOUT,ESP_FAIL};
|
||||
const unsigned states[]={STALE,INVALID,FAILED,FAILED};
|
||||
for(unsigned i=0;i<4;++i) {
|
||||
owner_error=errors[i]; submit_network(&admin,patch_body); execute_network(); assert(s_operation.state==states[i]); input_wiped();
|
||||
}
|
||||
owner_error=ESP_OK; stored_mdns=false; submit_network(&admin,bodies[MDNS_LOAD]); execute_network(); assert(s_operation.state==LOADED_DEFAULTS);
|
||||
mdns_queue_error=ESP_ERR_TIMEOUT; submit_network(&admin,bodies[MDNS_SET]); execute_network(); assert(s_operation.state==APPLIED_NOT_QUEUED); mdns_queue_error=ESP_OK;
|
||||
send_fail=true; submit_network(&admin,patch_body); send_fail=false; execute_network(); assert(s_operation.state==ACCEPTED);
|
||||
s_next_id=UINT32_MAX; network_begin(&admin,patch_body); network_expect("503 Service Unavailable",false);
|
||||
puts("PASS Network all typed actions, owner queue/NVS error mapping, truthful async status, mDNS partial apply, lost ACK and nonwrapping IDs");
|
||||
}
|
||||
@@ -51,6 +51,17 @@ admin = "--admin" in sys.argv
|
||||
settings = "--settings" in sys.argv
|
||||
serial_settings = "--serial-settings" in sys.argv
|
||||
accounts = "--accounts" in sys.argv
|
||||
network = "--network" in sys.argv
|
||||
if network:
|
||||
HEADERS["esp_wifi_types.h"] = "#pragma once\ntypedef int wifi_auth_mode_t;\n"
|
||||
HEADERS["esp_err.h"] += "\n#define ESP_ERR_TIMEOUT 0x107\n"
|
||||
HEADERS["esp_timer.h"] += """
|
||||
#include <stdbool.h>
|
||||
typedef void *esp_timer_handle_t;
|
||||
typedef struct { void (*callback)(void *); const char *name; bool skip_unhandled_events; } esp_timer_create_args_t;
|
||||
int esp_timer_create(const esp_timer_create_args_t *, esp_timer_handle_t *);
|
||||
int esp_timer_start_periodic(esp_timer_handle_t, uint64_t);
|
||||
"""
|
||||
if accounts:
|
||||
HEADERS["mbedtls/base64.h"] = """
|
||||
#pragma once
|
||||
@@ -167,6 +178,11 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
settings_source += ' serial_service_counters_t serial_counters = {0};\n' + acquisition
|
||||
settings_source += ' return snprintf(response, capacity,\n' + serial_format + ',\n' + serial_arguments + ');\n}\n'
|
||||
(tmp / 'settings_production.h').write_text(settings_source)
|
||||
if network:
|
||||
wifi_source = (ROOT / 'src/wifi_config.c').read_text()
|
||||
mdns_source = (ROOT / 'src/mdns_config.c').read_text()
|
||||
names = ['wifi_config_parse_ap_policy', 'wifi_config_ap_policy_to_string', 'wifi_config_parse_security', 'wifi_config_security_to_string']
|
||||
(tmp / 'network_parse_production.h').write_text('\n'.join(function(wifi_source, name) for name in names) + '\n' + '\n'.join(function(mdns_source, name) for name in ('suffix_character_is_valid', 'mdns_config_validate')))
|
||||
if accounts:
|
||||
db_source = (ROOT / 'src/user_database.c').read_text()
|
||||
alphabet_start = db_source.index('static const uint8_t s_generated_alphabet')
|
||||
@@ -196,6 +212,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
*(["-DHOST_SETTINGS"] if settings else []),
|
||||
*(["-DHOST_SERIAL_SETTINGS"] if serial_settings else []),
|
||||
*(["-DHOST_ACCOUNTS"] if accounts else []),
|
||||
*(["-DHOST_NETWORK"] if network else []),
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||
|
||||
@@ -18,7 +18,12 @@
|
||||
static struct httpd_data server = {.config.max_resp_headers = 8};
|
||||
static struct sock_db socket_state;
|
||||
static struct resp_hdr response_headers[8];
|
||||
static char scratch[1024], output[1024], cookie_values[2][200];
|
||||
static char scratch[1024], cookie_values[2][200];
|
||||
#ifdef HOST_NETWORK
|
||||
static char output[2048];
|
||||
#else
|
||||
static char output[1024];
|
||||
#endif
|
||||
static const char *request_body;
|
||||
static size_t body_offset;
|
||||
static unsigned password_calls, cookie_count, sends, upgrades;
|
||||
@@ -135,6 +140,9 @@ static void auth_reset(void) {
|
||||
#ifdef HOST_ACCOUNTS
|
||||
#include "account_settings_test.c"
|
||||
#endif
|
||||
#ifdef HOST_NETWORK
|
||||
#include "network_settings_test.c"
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
@@ -291,6 +299,9 @@ int main(void) {
|
||||
#endif
|
||||
#ifdef HOST_ACCOUNTS
|
||||
account_settings_tests();
|
||||
#endif
|
||||
#ifdef HOST_NETWORK
|
||||
network_settings_tests();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
# Network backend contract and host tests — 8D.12/8D.13
|
||||
|
||||
## Integration boundary
|
||||
|
||||
Compile `src/web_network_settings.c`. Register these three independently optional,
|
||||
exact method/path handlers using the existing settings registration pattern:
|
||||
|
||||
| Method | Path | Handler |
|
||||
|---|---|---|
|
||||
| GET | `/api/settings/network` | `web_network_snapshot_handler` |
|
||||
| GET | `/api/settings/network-operation` | `web_network_operation_handler` |
|
||||
| POST | `/api/settings/network-operation` | `web_network_operation_handler` |
|
||||
|
||||
This backend change does not edit `web_server`, `web_ui`, CMake, or `docs/`.
|
||||
The existing administration dispatcher now accepts a Network operation ID, not
|
||||
commands or credentials. Its item size, queue depth, task count and stack sizes
|
||||
are unchanged. Browser-shell Wi-Fi/mDNS restrictions are unchanged.
|
||||
|
||||
All three routes use existing cookie-auth policy: current admin, origin-bound
|
||||
session, duplicate/framing/Fetch-Metadata checks. POST requires matching Origin,
|
||||
CSRF and `application/json` or `application/json; charset=utf-8`. GET follows
|
||||
existing settings GET Origin policy (an absent Origin is permitted; a supplied
|
||||
mismatch is denied). GET is bodyless; query strings are rejected on every route.
|
||||
Responses use JSON, no-store, nosniff and no-referrer. No credential export route.
|
||||
|
||||
## Snapshot
|
||||
|
||||
A complete example (values are illustrative, never defaults to install):
|
||||
|
||||
```json
|
||||
{
|
||||
"wifi": {
|
||||
"generation": 7,
|
||||
"enabled_at_boot": true,
|
||||
"ap": {"policy": "fallback", "channel": 6, "ssid": "ESP32-SAK-example", "password_configured": true},
|
||||
"profiles": [
|
||||
{"index": 0, "enabled": true, "priority": 10, "security": "mixed", "ssid": "office", "password_configured": true},
|
||||
{"index": 1, "enabled": false, "priority": 20, "security": "wpa3", "ssid": "backup", "password_configured": false},
|
||||
{"index": 2, "enabled": false, "priority": 0, "security": "mixed", "ssid": "", "password_configured": false},
|
||||
{"index": 3, "enabled": false, "priority": 0, "security": "mixed", "ssid": "", "password_configured": false}
|
||||
]
|
||||
},
|
||||
"runtime": {"started": true, "state": "online", "active_profile": 0, "ip": "192.168.1.20", "ap_running": false, "ap_clients": 0, "last_error": 0},
|
||||
"mdns": {"generation": 3, "suffix": "example", "hostname": "sak-example", "announced": true, "last_error": 0}
|
||||
}
|
||||
```
|
||||
|
||||
Four stable indices 0..3 are always present. `active_profile:-1` means none.
|
||||
Runtime states are canonical `stopped`, `starting`, `connecting`, `waiting-ip`,
|
||||
`online`, `backoff`, `ap-only`, `error` (fallback `unknown`). `hostname` excludes
|
||||
`.local`. The existing responder is STA-only. `announced` is the service's
|
||||
expected-announcement status, not a client-observed DNS verification.
|
||||
|
||||
Wi-Fi working configuration and runtime are copied together under its mutex;
|
||||
mDNS is a separate consistent projection, not an atomic cross-domain snapshot.
|
||||
Both acquisitions use zero wait. Either unavailable/contended yields HTTP 503
|
||||
`{"error":"snapshot_unavailable"}`, not guessed partial values. No driver,
|
||||
NVS or secret-bearing config getter runs on HTTPD.
|
||||
|
||||
No PSKs or PSK lengths occur in projection structs/JSON. `password_configured`
|
||||
is only a boolean, justified by disabled-profile staging/enabling validation.
|
||||
|
||||
### SSID byte strings
|
||||
|
||||
SSID length is 0..32 decoded **bytes**, not UTF-8 characters or JSON bytes.
|
||||
A nonempty AP SSID and nonempty enabled-profile SSID are required. To clear a
|
||||
profile's SSID, its password must also be absent and the profile disabled.
|
||||
|
||||
The reversible wire codec accepts raw printable ASCII, JSON `\"`, `\\`, `\/`,
|
||||
`\b`, `\f`, `\n`, `\r`, `\t`, and `\u00HH` (hex case-insensitive). Every decoded
|
||||
codepoint maps to exactly one byte. It rejects raw non-ASCII, non-byte Unicode,
|
||||
surrogates and malformed escapes. Snapshot encoding emits other bytes, quote
|
||||
and backslash as `\u00hh`; embedded zero and arbitrary non-UTF-8 round-trip.
|
||||
For example `"A\u0000\u00ff"` means bytes `41 00 ff`.
|
||||
|
||||
UI must not pass ordinary JS UTF-16 strings straight through `JSON.stringify`
|
||||
for SSIDs. Encode user text as UTF-8 bytes first and encode each non-ASCII byte
|
||||
as `\u00HH`. Preserve an explicit reversible byte editing/display mode for
|
||||
existing arbitrary SSIDs; never silently replacement-decode and resubmit them.
|
||||
|
||||
## POST operations
|
||||
|
||||
A single flat JSON object, unknown/duplicate fields rejected. No nested config,
|
||||
arrays, nulls, signed/fractional/exponent integers or leading-zero numbers.
|
||||
Booleans are JSON `true`/`false`. Generation is a nonzero uint32 from the selected
|
||||
domain snapshot. All optional patch fields preserve current values when omitted;
|
||||
at least one patch field is required. Each POST changes only one domain/target.
|
||||
|
||||
| action | Required fields besides action | Optional fields |
|
||||
|---|---|---|
|
||||
| `wifi-patch` | `generation` (Wi-Fi) | `enabled_at_boot`, `ap_policy` (`off/fallback/always`), `channel` (1..11), `ssid`, `password`, `clear_password:true` |
|
||||
| `profile-patch` | `generation` (Wi-Fi), `profile` (0..3) | `enabled`, `priority` (0..255), `security` (`mixed/wpa3`), `ssid`, `password`, `clear_password:true` |
|
||||
| `wifi-save` | `generation` (Wi-Fi) | none |
|
||||
| `wifi-load` | `generation` (Wi-Fi) | none |
|
||||
| `start` | none | none |
|
||||
| `stop` | none | none |
|
||||
| `reconnect` | none | none |
|
||||
| `next-profile` | none | none |
|
||||
| `mdns-set` | `generation` (mDNS), `suffix` | none |
|
||||
| `mdns-save` | `generation` (mDNS) | none |
|
||||
| `mdns-load` | `generation` (mDNS) | none |
|
||||
| `mdns-defaults` | `generation` (mDNS) | none |
|
||||
|
||||
Examples:
|
||||
|
||||
```json
|
||||
{"action":"profile-patch","generation":7,"profile":0,"enabled":true,"priority":10,"security":"mixed","ssid":"office","password":"new-example-password"}
|
||||
{"action":"profile-patch","generation":8,"profile":0,"enabled":false,"clear_password":true}
|
||||
{"action":"wifi-patch","generation":9,"ap_policy":"always","channel":6}
|
||||
{"action":"wifi-save","generation":10}
|
||||
{"action":"wifi-load","generation":10}
|
||||
{"action":"next-profile"}
|
||||
{"action":"mdns-set","generation":3,"suffix":"lab-serial"}
|
||||
```
|
||||
|
||||
Password replacement is 8..63 printable ASCII bytes; `password:""` is rejected.
|
||||
Replacement and clear cannot coexist; `clear_password:false` is rejected.
|
||||
Clearing a disabled STA password is supported; a single patch can disable and
|
||||
clear. Enabled STA must retain a valid password. AP clear is rejected even with
|
||||
policy `off`: the canonical config never permits invalid/open AP credentials.
|
||||
`mixed` means WPA2-or-stronger, not an open network or WPA2-only guarantee.
|
||||
|
||||
Patching compares generation and merges into **current** secret bytes under the
|
||||
Wi-Fi mutex, validates the full canonical candidate, queues any required owner
|
||||
restart, then publishes. Queue failure leaves RAM unchanged. Stale browser edits
|
||||
cannot undo local start/stop or a newer CLI apply. Generations never wrap/reuse.
|
||||
|
||||
Edits are RAM-only. Disabled-profile-only edits do not restart the radio; enabling,
|
||||
disabling, enabled-profile changes and AP changes follow canonical asynchronous
|
||||
restart policy. `enabled_at_boot` alone is next-boot policy, not Start/Stop.
|
||||
Start/Stop intentionally also change RAM `enabled_at_boot`; explicit Save persists
|
||||
it. Reconnect and next-profile do nothing when the manager is stopped. Next means
|
||||
next enabled profile in canonical priority order, wrapping; no explicit-index
|
||||
connection-selection API was added.
|
||||
|
||||
Wi-Fi Save persists the selected generation under the config mutex. Wi-Fi Load
|
||||
reads only the existing canonical blob and conditionally installs it; missing,
|
||||
invalid/incompatible or failed storage never generates/installs a new AP secret
|
||||
or changes RAM. No Wi-Fi defaults/reset actions. mDNS suffix is 1..55 lowercase
|
||||
ASCII letters/digits/hyphens, no leading/trailing hyphen; hostname is `sak-` plus
|
||||
suffix. mDNS edits/default/load are RAM-only and queue owner reannouncement;
|
||||
Save persists. mDNS Load may select deterministic MAC defaults and reports that
|
||||
result. Offline suffix edits reach an already-initialized responder on the next
|
||||
STA IP. NVS remains unencrypted; clearing/replacing is not secure flash erasure.
|
||||
|
||||
## Admission/results, errors and uncertainty
|
||||
|
||||
POST admission: HTTP 202, e.g.
|
||||
|
||||
```json
|
||||
{"id":42,"action":"profile-patch","state":"pending","error":0}
|
||||
```
|
||||
|
||||
GET operation returns HTTP 200 with exactly the same four fields. Only the
|
||||
initiating login can retrieve its slot. A different admin/no retained result gets
|
||||
`{"id":0,"action":"none","state":"idle","error":0}`. No query ID: UI compares
|
||||
returned `id` to its acknowledged ID. A later admitted operation replaces the
|
||||
previous result. IDs never wrap; exhaustion denies admission until reboot.
|
||||
|
||||
| state | Meaning |
|
||||
|---|---|
|
||||
| `idle` | No retained result for this login |
|
||||
| `pending` | Waiting for dispatcher or executing |
|
||||
| `accepted` | RAM apply / owner queue request accepted; NOT association, DHCP, online, radio completion or verified DNS |
|
||||
| `ok` | Explicit Wi-Fi/mDNS save returned success |
|
||||
| `failed` | Canonical/owner/storage error; `error` is numeric `esp_err_t` |
|
||||
| `cancelled` | Queued expiry or session/currentness/dequeue deadline rejection; no canonical operation admitted |
|
||||
| `stale` | Selected config generation no longer matches |
|
||||
| `invalid` | Canonical config rejects the patch/load (e.g. enabled STA clear or AP clear) |
|
||||
| `loaded_defaults` | mDNS Load selected deterministic RAM defaults and reannouncement was queued |
|
||||
| `applied_not_queued` | mDNS RAM change succeeded but manager reannouncement queue failed; refresh, do not assume rollback |
|
||||
|
||||
`error` is diagnostic numeric status, not a state override: cancelled can have
|
||||
zero error (deadline/currentness false). No arbitrary error text or input echo.
|
||||
Known terminal results should trigger a fresh snapshot. Runtime failures after
|
||||
`accepted` appear in subsequent snapshots, not by rewriting the result.
|
||||
|
||||
HTTP errors: existing 400 invalid/framing/query/body/method, 401 authentication,
|
||||
403 Origin/CSRF/admin, 503 auth-unavailable; backend-specific 400
|
||||
`invalid_network_request`, 503 `timer_unavailable`, 503 `busy` (Retry-After: 1),
|
||||
503 `snapshot_unavailable`. Unread body/receive failures close rather than drain.
|
||||
Malformed input is never queued. Syntactically valid but canonically invalid
|
||||
patches may receive 202 and then terminal `invalid`.
|
||||
|
||||
One static session-bound pending/result slot, executing reservation under a short
|
||||
portMUX, no credentials in the dispatcher queue. One firmware-lifetime one-second
|
||||
ESP timer inspects the current ID/deadline and wipes/cancels non-executing input at
|
||||
30 seconds plus scheduler latency. Shared inputs wipe on dequeue before auth;
|
||||
dispatcher-local inputs wipe on every return. HTTP body/parser/operation inputs
|
||||
wipe, including rejection and before response IO. Already-admitted work can
|
||||
finish after logout/disconnect/deadline; this is not transactional session liveness
|
||||
or a hard wall-clock erasure guarantee. Expired IDs cannot execute replacements.
|
||||
|
||||
Network-changing controls need UI confirmation/recovery warnings. HTTPS/SSH and
|
||||
both browser WebSockets may disconnect before any ACK/result. A lost ACK, 401 or
|
||||
disconnect proves neither success nor cancellation. Never automatically replay.
|
||||
Reconnect to STA/AP and inspect configuration/runtime; UART0/native USB recovery
|
||||
remain independent. No terminal lease/transport changes are made by this module.
|
||||
|
||||
## Bounds and validation
|
||||
|
||||
- 768-byte POST, at most four receives, at most 13 distinct flat keys, 64-byte
|
||||
parser value scratch; enough for one fully escaped 32-byte SSID and 63-byte
|
||||
replacement plus the typed fields. No heap JSON tree/cJSON.
|
||||
- 2,048-byte snapshot buffer. Maximum escaped fixture: 1,877 payload bytes
|
||||
(five 32-byte SSIDs at six bytes/byte, four profiles, full-width numbers,
|
||||
55-byte mDNS suffix plus hostname, longest booleans/state/security/policy).
|
||||
- 128-byte operation response buffer; one static operation and one small timer.
|
||||
No new task, queue/depth/stack expansion or schema migration.
|
||||
- Target RAM/stack margins and hardware behavior are not measured by host tests.
|
||||
|
||||
Commands run successfully:
|
||||
|
||||
```sh
|
||||
python3 tests/web_network_settings/run.py
|
||||
python3 tests/web_cookie_auth/run.py --network
|
||||
python3 tests/web_cookie_auth/run.py --accounts
|
||||
python3 tests/web_cookie_auth/run.py --serial-settings
|
||||
python3 tests/web_cookie_auth/run.py --settings
|
||||
python3 tests/web_cookie_auth/run.py --admin
|
||||
python3 tests/admin_console_boundary/run.py
|
||||
python3 tests/admin_console_boundary/lifecycle.py
|
||||
python3 tests/admin_console_boundary/accounts.py
|
||||
```
|
||||
|
||||
The new manager harness compiles verbatim production mutation/queue functions
|
||||
with real `wifi_config.c`, `mdns_config.c`, `mdns_service.c`; deterministic RTOS,
|
||||
NVS, radio admission and mDNS component doubles. It does not simulate the whole
|
||||
Wi-Fi event loop, power loss or target scheduling. Cookie tests compile the real
|
||||
backend/auth/session/HTTPD adapter with owner doubles and actual installed IDF
|
||||
header getter/response-header functions.
|
||||
|
||||
Sanitizer attempt (`run.py --sanitize`) could not link: this host lacks
|
||||
`libasan.so.8.0.0` and `libubsan.so.1.0.0`. Normal suites reran successfully.
|
||||
No `pio run`, upload, erase, asset generation or commit. Integration and target
|
||||
validation remain with their owners.
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real config modules/service and verbatim manager mutation paths, host RTOS/NVS.
|
||||
|
||||
The full manager driver/event loop is NOT simulated. Extracted functions include
|
||||
both canonical legacy apply/lifecycle admission and new mutex-local APIs, so the
|
||||
regressions exercise the actual shared transaction rather than a second model.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import sys
|
||||
os.environ['CCACHE_DISABLE'] = '1'
|
||||
sys.dont_write_bytecode = True
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
HEADERS = {
|
||||
'esp_err.h': '''#pragma once
|
||||
typedef int esp_err_t;
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL -1
|
||||
#define ESP_ERR_NO_MEM 0x101
|
||||
#define ESP_ERR_INVALID_ARG 0x102
|
||||
#define ESP_ERR_INVALID_STATE 0x103
|
||||
#define ESP_ERR_INVALID_SIZE 0x104
|
||||
#define ESP_ERR_NOT_FOUND 0x105
|
||||
#define ESP_ERR_TIMEOUT 0x107
|
||||
#define ESP_ERR_NVS_NOT_FOUND 0x1102
|
||||
#define ESP_ERR_NVS_TYPE_MISMATCH 0x1103
|
||||
#define ESP_ERR_NVS_INVALID_LENGTH 0x110c
|
||||
''',
|
||||
'esp_wifi_types.h': '#pragma once\ntypedef int wifi_auth_mode_t;\n',
|
||||
'freertos/FreeRTOS.h': '''#pragma once
|
||||
#include <stdint.h>
|
||||
#define pdTRUE 1
|
||||
#define portMAX_DELAY UINT32_MAX
|
||||
''',
|
||||
'freertos/semphr.h': '''#pragma once
|
||||
#include <stdint.h>
|
||||
typedef int *SemaphoreHandle_t;
|
||||
SemaphoreHandle_t xSemaphoreCreateMutex(void);
|
||||
int xSemaphoreTake(SemaphoreHandle_t,uint32_t);
|
||||
int xSemaphoreGive(SemaphoreHandle_t);
|
||||
''',
|
||||
'nvs.h': '''#pragma once
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
typedef int nvs_handle_t;
|
||||
#define NVS_READONLY 0
|
||||
#define NVS_READWRITE 1
|
||||
esp_err_t nvs_open(const char *,int,nvs_handle_t *);
|
||||
esp_err_t nvs_get_blob(nvs_handle_t,const char *,void *,size_t *);
|
||||
esp_err_t nvs_set_blob(nvs_handle_t,const char *,const void *,size_t);
|
||||
esp_err_t nvs_commit(nvs_handle_t);
|
||||
void nvs_close(nvs_handle_t);
|
||||
''',
|
||||
'nvs_flash.h': '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n',
|
||||
'esp_mac.h': '''#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
#define ESP_MAC_WIFI_STA 0
|
||||
#define ESP_MAC_WIFI_SOFTAP 1
|
||||
esp_err_t esp_read_mac(uint8_t *,int);
|
||||
''',
|
||||
'mdns.h': '''#pragma once
|
||||
#include "esp_err.h"
|
||||
esp_err_t mdns_init(void);
|
||||
esp_err_t mdns_hostname_set(const char *);
|
||||
esp_err_t mdns_instance_name_set(const char *);
|
||||
void mdns_free(void);
|
||||
''',
|
||||
}
|
||||
def function(source, name):
|
||||
start = source.index(name + '(')
|
||||
start = source.rfind('\n', 0, start) + 1
|
||||
return source[start:source.index('\n}', start) + 2]
|
||||
source = (ROOT / 'src/wifi_manager.c').read_text()
|
||||
names = ['count_queue_drop', 'enqueue_message', 'wifi_manager_get_snapshot',
|
||||
'profiles_equal', 'config_requires_radio_restart', 'apply_config_locked',
|
||||
'wifi_manager_apply_working_config', 'wifi_manager_get_settings',
|
||||
'generation_matches', 'wifi_manager_patch_current', 'wifi_manager_save_current',
|
||||
'wifi_manager_load_current', 'enqueue_lifecycle_command', 'wifi_manager_start',
|
||||
'wifi_manager_stop', 'wifi_manager_reconnect', 'wifi_manager_next_profile',
|
||||
'wifi_manager_mdns_reannounce']
|
||||
# Guard both ownership and no expansion of the real policy owner.
|
||||
assert '#define WIFI_MANAGER_QUEUE_LENGTH 16U' in source
|
||||
assert '#define WIFI_MANAGER_TASK_STACK_SIZE 6144U' in source
|
||||
assert 'wifi_config_load(' not in function(source, 'wifi_manager_load_current')
|
||||
with tempfile.TemporaryDirectory(prefix='web-network-settings-') as directory:
|
||||
tmp = Path(directory)
|
||||
for name, contents in HEADERS.items():
|
||||
path = tmp / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(contents)
|
||||
(tmp / 'manager_production.h').write_text('\n'.join(function(source, name) for name in names))
|
||||
sanitizer = ['-fsanitize=address,undefined', '-fno-omit-frame-pointer'] if '--sanitize' in sys.argv else []
|
||||
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', '-g', *sanitizer,
|
||||
'-I' + str(tmp), '-I' + str(ROOT / 'src'), str(HERE / 'test.c'),
|
||||
*[str(ROOT / 'src' / name) for name in ('wifi_config.c', 'mdns_config.c', 'mdns_service.c')],
|
||||
'-o', str(tmp / 'test')], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / 'test')], check=True, timeout=20)
|
||||
@@ -0,0 +1,214 @@
|
||||
#include <assert.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "wifi_manager.h"
|
||||
#include "mdns_service.h"
|
||||
#include "secure_random.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "nvs.h"
|
||||
|
||||
static int wifi_mutex, mdns_mutex;
|
||||
static SemaphoreHandle_t s_mutex=&wifi_mutex;
|
||||
static struct { wifi_app_config_t config; wifi_manager_snapshot_t snapshot; } s_shared;
|
||||
typedef enum { MESSAGE_COMMAND_START, MESSAGE_COMMAND_STOP, MESSAGE_COMMAND_APPLY,
|
||||
MESSAGE_COMMAND_RECONNECT, MESSAGE_COMMAND_NEXT_PROFILE, MESSAGE_COMMAND_MDNS_REANNOUNCE } manager_message_type_t;
|
||||
typedef struct { manager_message_type_t type; } manager_message_t;
|
||||
static unsigned queued, random_calls, wiped_candidates, commits, hostname_calls;
|
||||
static bool queue_fail, snapshot_contention;
|
||||
static esp_err_t nvs_error, commit_error, hostname_error;
|
||||
static manager_message_type_t last_message;
|
||||
static void lock_shared(void) { assert(!wifi_mutex); wifi_mutex=1; }
|
||||
static void unlock_shared(void) { assert(wifi_mutex); wifi_mutex=0; }
|
||||
static int s_drop_mux, s_queue;
|
||||
static uint64_t s_queue_drops;
|
||||
#define portENTER_CRITICAL(mux) do { assert((mux)==&s_drop_mux && !s_drop_mux); s_drop_mux=1; } while (0)
|
||||
#define portEXIT_CRITICAL(mux) do { assert((mux)==&s_drop_mux && s_drop_mux); s_drop_mux=0; } while (0)
|
||||
static int xQueueSend(int queue,const manager_message_t *message,uint32_t wait) {
|
||||
assert(queue==s_queue && !wait && wifi_mutex && !s_drop_mux);
|
||||
if(queue_fail) return 0;
|
||||
++queued; last_message=message->type; return pdTRUE;
|
||||
}
|
||||
SemaphoreHandle_t xSemaphoreCreateMutex(void) { return &mdns_mutex; }
|
||||
int xSemaphoreTake(SemaphoreHandle_t mutex,uint32_t wait) {
|
||||
if(wait==0 && (snapshot_contention || *mutex)) return 0;
|
||||
assert(!*mutex); *mutex=1; return pdTRUE;
|
||||
}
|
||||
int xSemaphoreGive(SemaphoreHandle_t mutex) { assert(*mutex); *mutex=0; return pdTRUE; }
|
||||
void secure_wipe(void *data,size_t size) {
|
||||
if(size==sizeof(wifi_app_config_t)) ++wiped_candidates;
|
||||
volatile uint8_t *p=data; while(size--) *p++=0;
|
||||
}
|
||||
esp_err_t secure_random_fill(void *data,size_t size) { ++random_calls; memset(data,17,size); return ESP_OK; }
|
||||
esp_err_t esp_read_mac(uint8_t *mac,int interface) { (void)interface; memset(mac,0x12,6); return ESP_OK; }
|
||||
static struct { uint8_t bytes[528]; size_t size; bool present; } blobs[2];
|
||||
esp_err_t nvs_flash_init(void) { return nvs_error; }
|
||||
esp_err_t nvs_open(const char *name,int mode,nvs_handle_t *handle) {
|
||||
*handle=!strcmp(name,MDNS_CONFIG_NVS_NAMESPACE);
|
||||
if(mode==NVS_READONLY && !blobs[*handle].present) return ESP_ERR_NVS_NOT_FOUND;
|
||||
return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_get_blob(nvs_handle_t handle,const char *key,void *out,size_t *size) {
|
||||
assert(!strcmp(key,"config"));
|
||||
if(!blobs[handle].present) return ESP_ERR_NVS_NOT_FOUND;
|
||||
if(!out) { *size=blobs[handle].size; return ESP_OK; }
|
||||
if(*size<blobs[handle].size) return ESP_ERR_NVS_INVALID_LENGTH;
|
||||
*size=blobs[handle].size; memcpy(out,blobs[handle].bytes,*size); return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_set_blob(nvs_handle_t handle,const char *key,const void *data,size_t size) {
|
||||
assert(!strcmp(key,"config") && size<=528 && (wifi_mutex || mdns_mutex));
|
||||
if(commit_error) return ESP_OK;
|
||||
memcpy(blobs[handle].bytes,data,size); blobs[handle].size=size; blobs[handle].present=true; return ESP_OK;
|
||||
}
|
||||
esp_err_t nvs_commit(nvs_handle_t handle) { (void)handle; ++commits; return commit_error; }
|
||||
void nvs_close(nvs_handle_t handle) { (void)handle; }
|
||||
static char announced_hostname[60];
|
||||
esp_err_t mdns_init(void) { return ESP_OK; }
|
||||
esp_err_t mdns_hostname_set(const char *hostname) {
|
||||
assert(!mdns_mutex); ++hostname_calls; strcpy(announced_hostname,hostname); return hostname_error;
|
||||
}
|
||||
esp_err_t mdns_instance_name_set(const char *name) { assert(name); return ESP_OK; }
|
||||
void mdns_free(void) {}
|
||||
#include "manager_production.h"
|
||||
|
||||
static uint32_t generation(void) { return s_shared.snapshot.config_generation; }
|
||||
static esp_err_t patch(wifi_manager_patch_t *p) { return wifi_manager_patch_current(generation(),p); }
|
||||
static void same_secret(const uint8_t *secret,const char *expected) { assert(!memcmp(secret,expected,strlen(expected))); }
|
||||
int main(void) {
|
||||
assert(wifi_config_defaults(&s_shared.config)==ESP_OK); s_shared.snapshot.config_generation=1;
|
||||
s_shared.snapshot.active_profile=-1; s_shared.snapshot.state=WIFI_MANAGER_STATE_STOPPED;
|
||||
wifi_manager_settings_t projection;
|
||||
snapshot_contention=true; memset(&projection,0xff,sizeof(projection));
|
||||
assert(wifi_manager_get_settings(&projection)==ESP_ERR_TIMEOUT);
|
||||
for(unsigned i=0;i<sizeof(projection);++i) assert(((uint8_t *)&projection)[i]==0);
|
||||
snapshot_contention=false;
|
||||
assert(wifi_manager_get_settings(&projection)==ESP_OK && projection.ap_password_configured);
|
||||
assert(!projection.profiles[0].password_configured && projection.runtime.active_profile==-1);
|
||||
wifi_manager_patch_t p={.profile=0,.fields=WIFI_PATCH_SSID,.ssid_len=32};
|
||||
for(unsigned i=0;i<32;++i) p.ssid[i]=(uint8_t)(i*8);
|
||||
assert(patch(&p)==ESP_OK && queued==0 && !s_shared.config.profiles[0].enabled);
|
||||
assert(wifi_manager_get_settings(&projection)==ESP_OK && projection.profiles[0].ssid_len==32);
|
||||
assert(!memcmp(projection.profiles[0].ssid,p.ssid,32));
|
||||
p.fields=WIFI_PATCH_PASSWORD; p.password_len=11; memcpy(p.password,"supersecret",11);
|
||||
assert(patch(&p)==ESP_OK && queued==0); same_secret(s_shared.config.profiles[0].psk,"supersecret");
|
||||
uint32_t stale=generation();
|
||||
p.password_len=12; memcpy(p.password,"replacement!",12); assert(patch(&p)==ESP_OK);
|
||||
wifi_app_config_t before=s_shared.config;
|
||||
p.fields=WIFI_PATCH_PRIORITY; p.priority=255;
|
||||
assert(wifi_manager_patch_current(stale,&p)==ESP_ERR_NOT_FOUND && !memcmp(&before,&s_shared.config,sizeof(before)));
|
||||
assert(patch(&p)==ESP_OK); same_secret(s_shared.config.profiles[0].psk,"replacement!");
|
||||
/* Omitted password in a fresh patch cannot restore the caller's old copy. */
|
||||
p.fields=WIFI_PATCH_ENABLED; p.enabled=1;
|
||||
queue_fail=true; before=s_shared.config; stale=generation();
|
||||
assert(patch(&p)==ESP_ERR_TIMEOUT && generation()==stale && !memcmp(&before,&s_shared.config,sizeof(before)));
|
||||
assert(s_queue_drops==1 && s_shared.snapshot.counters.queue_drops==0);
|
||||
wifi_manager_snapshot_t runtime;
|
||||
assert(wifi_manager_get_snapshot(&runtime)==ESP_OK);
|
||||
assert(wifi_manager_get_settings(&projection)==ESP_OK);
|
||||
assert(projection.runtime.counters.queue_drops==1 &&
|
||||
projection.runtime.counters.queue_drops==runtime.counters.queue_drops && !s_drop_mux);
|
||||
puts("PASS real manager: failed owner enqueue updates settings queue_drops consistently with runtime snapshot");
|
||||
queue_fail=false; assert(patch(&p)==ESP_OK && queued==1 && last_message==MESSAGE_COMMAND_APPLY);
|
||||
assert(s_shared.snapshot.state==WIFI_MANAGER_STATE_STOPPED && !s_shared.snapshot.started);
|
||||
p.fields=WIFI_PATCH_PASSWORD; p.password_len=0;
|
||||
before=s_shared.config; assert(patch(&p)==ESP_ERR_INVALID_ARG && !memcmp(&before,&s_shared.config,sizeof(before)));
|
||||
p.fields=WIFI_PATCH_PASSWORD|WIFI_PATCH_ENABLED; p.enabled=0;
|
||||
assert(patch(&p)==ESP_OK && s_shared.config.profiles[0].psk_len==0 && queued==2);
|
||||
for(unsigned i=0;i<63;++i) assert(s_shared.config.profiles[0].psk[i]==0);
|
||||
p.fields=WIFI_PATCH_ENABLED; p.enabled=1; assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
p.profile=-1; p.fields=WIFI_PATCH_PASSWORD; p.password_len=0;
|
||||
assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
p.fields=WIFI_PATCH_POLICY; p.ap_policy=WIFI_CONFIG_AP_POLICY_OFF; assert(patch(&p)==ESP_OK);
|
||||
p.fields=WIFI_PATCH_PASSWORD; assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
p.fields=WIFI_PATCH_CHANNEL; p.ap_channel=12; assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
p.profile=4; assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
p.profile=-1; p.fields=WIFI_PATCH_ENABLED; assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
p.fields=UINT32_MAX; assert(patch(&p)==ESP_ERR_INVALID_ARG);
|
||||
puts("PASS real manager: zero-wait secret-free projection, byte SSIDs, stale generation, omission preserves CURRENT PSK, disabled clear and canonical AP/enabled constraints");
|
||||
|
||||
unsigned staged_queue=queued;
|
||||
for(unsigned i=0;i<4;++i) {
|
||||
p=(wifi_manager_patch_t){.profile=(int8_t)i,.fields=WIFI_PATCH_SSID|WIFI_PATCH_PRIORITY|WIFI_PATCH_SECURITY,
|
||||
.ssid_len=1,.ssid={(uint8_t)('a'+i)},.priority=(uint8_t)(255-i),.security=WIFI_CONFIG_SECURITY_WPA3};
|
||||
assert(patch(&p)==ESP_OK && queued==staged_queue);
|
||||
}
|
||||
assert(wifi_manager_get_settings(&projection)==ESP_OK);
|
||||
for(unsigned i=0;i<4;++i) {
|
||||
assert(projection.profiles[i].priority==255-i && projection.profiles[i].security==WIFI_CONFIG_SECURITY_WPA3);
|
||||
assert(projection.profiles[i].ssid_len==1 && projection.profiles[i].ssid[0]=='a'+i);
|
||||
}
|
||||
p=(wifi_manager_patch_t){.profile=-1,.fields=WIFI_PATCH_PASSWORD,.password_len=12,.password="AP-replaced!"};
|
||||
assert(patch(&p)==ESP_OK); same_secret(s_shared.config.ap_psk,"AP-replaced!");
|
||||
stale=generation();
|
||||
p.fields=WIFI_PATCH_SSID; p.ssid_len=3; memcpy(p.ssid,"AP!",3); assert(patch(&p)==ESP_OK);
|
||||
same_secret(s_shared.config.ap_psk,"AP-replaced!");
|
||||
assert(wifi_manager_load_current(stale)==ESP_ERR_NOT_FOUND);
|
||||
puts("PASS real manager: four stable profiles, disabled security/priority staging, AP replacement and omission preserve");
|
||||
|
||||
/* Legacy/local apply and start/stop participate in the same generation. */
|
||||
before=s_shared.config; stale=generation();
|
||||
before.ap_channel=3; assert(wifi_manager_apply_working_config(&before)==ESP_OK && generation()!=stale);
|
||||
p.profile=-1; p.fields=WIFI_PATCH_BOOT; p.enabled_at_boot=0;
|
||||
assert(wifi_manager_patch_current(stale,&p)==ESP_ERR_NOT_FOUND);
|
||||
stale=generation(); assert(wifi_manager_stop()==ESP_OK && generation()!=stale && !s_shared.config.enabled_at_boot);
|
||||
assert(wifi_manager_save_current(stale)==ESP_ERR_NOT_FOUND);
|
||||
queue_fail=true; stale=generation(); assert(wifi_manager_start()==ESP_ERR_TIMEOUT && generation()==stale && !s_shared.config.enabled_at_boot); queue_fail=false;
|
||||
assert(wifi_manager_start()==ESP_OK && s_shared.config.enabled_at_boot);
|
||||
assert(wifi_manager_reconnect()==ESP_OK && last_message==MESSAGE_COMMAND_RECONNECT);
|
||||
assert(wifi_manager_next_profile()==ESP_OK && last_message==MESSAGE_COMMAND_NEXT_PROFILE);
|
||||
assert(wifi_manager_mdns_reannounce()==ESP_OK && last_message==MESSAGE_COMMAND_MDNS_REANNOUNCE);
|
||||
unsigned random_before=random_calls;
|
||||
before=s_shared.config; stale=generation();
|
||||
assert(wifi_manager_load_current(stale)==ESP_ERR_NVS_NOT_FOUND && generation()==stale && random_calls==random_before);
|
||||
assert(!memcmp(&before,&s_shared.config,sizeof(before)));
|
||||
assert(wifi_manager_save_current(generation())==ESP_OK && blobs[0].present);
|
||||
p.fields=WIFI_PATCH_CHANNEL; p.ap_channel=9; assert(patch(&p)==ESP_OK);
|
||||
stale=generation(); queue_fail=true;
|
||||
assert(wifi_manager_load_current(stale)==ESP_ERR_TIMEOUT && generation()==stale && s_shared.config.ap_channel==9); queue_fail=false;
|
||||
assert(wifi_manager_load_current(stale)==ESP_OK && s_shared.config.ap_channel==3 && random_calls==random_before);
|
||||
nvs_error=ESP_FAIL; assert(wifi_manager_save_current(generation())==ESP_FAIL);
|
||||
before=s_shared.config; assert(wifi_manager_load_current(generation())==ESP_FAIL && !memcmp(&before,&s_shared.config,sizeof(before))); nvs_error=ESP_OK;
|
||||
commit_error=ESP_FAIL; assert(wifi_manager_save_current(generation())==ESP_FAIL); commit_error=ESP_OK;
|
||||
blobs[0].bytes[0]=0; assert(wifi_manager_load_current(generation())==ESP_ERR_INVALID_ARG);
|
||||
blobs[0].size=527; assert(wifi_manager_load_current(generation())==ESP_ERR_INVALID_SIZE && random_calls==random_before);
|
||||
assert(wiped_candidates>10);
|
||||
s_shared.snapshot.config_generation=UINT32_MAX;
|
||||
assert(patch(&p)==ESP_ERR_INVALID_STATE);
|
||||
assert(wifi_manager_stop()==ESP_ERR_INVALID_STATE && s_shared.config.enabled_at_boot);
|
||||
assert(wifi_manager_apply_working_config(&before)==ESP_ERR_INVALID_STATE);
|
||||
puts("PASS real manager: legacy/local generations, atomic queue failures, asynchronous lifecycle, conditional NVS failures/load without RNG, candidate wipe and no generation wrap");
|
||||
|
||||
mdns_config_t config; mdns_config_defaults(&config); assert(mdns_service_init(&config)==ESP_OK);
|
||||
mdns_service_snapshot_t m;
|
||||
snapshot_contention=true; assert(mdns_service_get_settings(&m)==ESP_ERR_TIMEOUT); snapshot_contention=false;
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK && m.config_generation==1);
|
||||
bool stored;
|
||||
memset(config.suffix,0,sizeof(config.suffix)); strcpy(config.suffix,"first"); config.suffix_len=5;
|
||||
assert(mdns_service_update_current(1,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
|
||||
assert(mdns_service_update_current(1,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_ERR_NOT_FOUND);
|
||||
assert(mdns_service_start()==ESP_OK && !strcmp(announced_hostname,"sak-first"));
|
||||
mdns_service_stop();
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK && !m.announced);
|
||||
memset(config.suffix,0,sizeof(config.suffix)); strcpy(config.suffix,"offline"); config.suffix_len=7;
|
||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SET,&config,&stored)==ESP_OK);
|
||||
unsigned calls=hostname_calls;
|
||||
assert(mdns_service_start()==ESP_OK && hostname_calls==calls+1 && !strcmp(announced_hostname,"sak-offline"));
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK);
|
||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_OK);
|
||||
strcpy(config.suffix,"another"); assert(mdns_service_set_config(&config)==ESP_OK);
|
||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_LOAD,NULL,&stored)==ESP_ERR_NOT_FOUND);
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK);
|
||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_LOAD,NULL,&stored)==ESP_OK && stored);
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK && !strcmp(m.suffix,"offline"));
|
||||
assert(mdns_service_reannounce()==ESP_OK && !strcmp(announced_hostname,"sak-offline"));
|
||||
blobs[1].present=false;
|
||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_LOAD,NULL,&stored)==ESP_OK && !stored);
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK && !strcmp(m.suffix,"121212121212"));
|
||||
assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_DEFAULTS,NULL,&stored)==ESP_OK);
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK);
|
||||
commit_error=ESP_FAIL; assert(mdns_service_update_current(m.config_generation,MDNS_SETTINGS_SAVE,NULL,&stored)==ESP_FAIL); commit_error=ESP_OK;
|
||||
hostname_error=ESP_FAIL; assert(mdns_service_reannounce()==ESP_FAIL);
|
||||
assert(mdns_service_get_settings(&m)==ESP_OK && m.last_error==ESP_FAIL);
|
||||
puts("PASS real mDNS service: zero wait, conditional set/save/load/default, legacy races, NVS errors and offline suffix reannouncement regression");
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
const {script, loader} = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
|
||||
const {script, loader, html} = JSON.parse(require('node:fs').readFileSync(process.argv[2], 'utf8'));
|
||||
const token = 'a'.repeat(64);
|
||||
const json = value => new Response(JSON.stringify(value));
|
||||
const session = (extra = {}) => json({username: '<img>', role: 'user', csrf: token, expires_in: 3600, ...extra});
|
||||
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
|
||||
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
|
||||
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
|
||||
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': []};
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': []};
|
||||
const fits = [];
|
||||
let serial = 0, now = Date.now();
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
@@ -38,12 +38,22 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
|
||||
setInterval: (fn, ms) => timeout(fn, ms, true), clearInterval: id => timers.delete(id),
|
||||
requestAnimationFrame: fn => timeout(fn, -1), cancelAnimationFrame: id => timers.delete(id),
|
||||
location: {origin: 'https://sak.local', replace: path => redirects.push(path)}};
|
||||
const context = vm.createContext({window, document: {getElementById(id) {
|
||||
return nodes[id] ||= {textContent: '', value: '', checked: false, dataset: {}, classList: {toggle() {}},
|
||||
setAttribute(k, v) { this[k] = v; },
|
||||
getBoundingClientRect: () => ({width: 100, height: 100}),
|
||||
addEventListener(k, fn) { this[k] = fn; }};
|
||||
}}, Terminal, FitAddon: {FitAddon: class {
|
||||
class Element {
|
||||
constructor(tagName = 'div') {
|
||||
this.tagName = tagName.toUpperCase(); this.children = []; this._text = '';
|
||||
this.value = ''; this.checked = false; this.dataset = {}; this.classList = {toggle() {}};
|
||||
}
|
||||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||||
set textContent(value) { this.children.forEach(child => { child.parentNode = null; }); this.children = []; this._text = String(value); }
|
||||
appendChild(child) { this.children.push(child); child.parentNode = this; return child; }
|
||||
setAttribute(k, v) { this[k] = v; }
|
||||
getBoundingClientRect() { return {width: 100, height: 100}; }
|
||||
addEventListener(k, fn) { this[k] = fn; }
|
||||
}
|
||||
const context = vm.createContext({window, document: {
|
||||
createElement: tag => new Element(tag),
|
||||
getElementById(id) { return nodes[id] ||= new Element(); }
|
||||
}, Terminal, FitAddon: {FitAddon: class {
|
||||
constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
|
||||
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
|
||||
}},
|
||||
@@ -830,7 +840,8 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
});
|
||||
const slotKey = index => ({index,type:'ssh-ed25519',fingerprint:'SHA256:' + String.fromCharCode(97 + index).repeat(43)});
|
||||
function assertKeySlots(b, indices) {
|
||||
assert.equal(b.nodes['account-keys-list'].textContent, indices.map(index => `${index}: ssh-ed25519 ${slotKey(index).fingerprint}`).join('\n'));
|
||||
assert.deepEqual(b.nodes['account-keys-list'].children.map(node => [node.tagName, node.textContent]),
|
||||
indices.flatMap(index => [['DT', `${index}: `], ['DD', `ssh-ed25519 ${slotKey(index).fingerprint}`]]));
|
||||
assert.equal(b.nodes['account-key-index'].value, String(indices[0]));
|
||||
for(let index=0;index<3;++index) {
|
||||
const option=b.nodes['key-option-'+index], present=indices.includes(index);
|
||||
@@ -927,6 +938,20 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
const s=await accountsBrowser(); s.nodes['account-public-key'].value='ssh-ed25519 AAAA'; let warning; s.window.confirm=m=>{warning=m;return true;}; s.queues[accountPath].push(failure(401)); s.click('account-key-add'); await tick();
|
||||
assert.match(warning,/ALL.*web\/SSH.*401.*NOT proof/); assert.deepEqual(s.redirects,['/login']); assert.ok(s.sockets.every(s=>s.closed)); assert.doesNotMatch(s.nodes['account-operation-detail'].textContent,/completed/);
|
||||
});
|
||||
await test('Account definition rows and key rows clear as DOM children and fence late lists', async () => {
|
||||
const b = await keyBrowser(), list = b.nodes['accounts-list'], keys = b.nodes['account-keys-list'];
|
||||
assert.deepEqual(list.children.map(n => [n.tagName, n.textContent]),
|
||||
[['DT', 'alice'], ['DD', 'admin (you)'], ['DT', 'carol'], ['DD', 'user']]);
|
||||
assert.ok(keys.children.length > 0);
|
||||
assert.ok([...list.children, ...keys.children].every(n => n.children.length === 0));
|
||||
const old = [...list.children, ...keys.children], d = deferred();
|
||||
b.queues['/api/settings/accounts'].push(d.promise); b.click('refresh-accounts'); await tick();
|
||||
b.click('settings-serial'); await tick();
|
||||
assert.equal(list.children.length, 0); assert.equal(keys.children.length, 0);
|
||||
assert.ok(old.every(n => n.parentNode === null));
|
||||
d.resolve(json({users: [{username: 'late', role: 'user', user_id: 9, auth_generation: 1}]})); await tick();
|
||||
assert.equal(list.children.length, 0); assert.equal(keys.children.length, 0);
|
||||
});
|
||||
await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => {
|
||||
const u = await connected(); u.click('settings-accounts'); await tick();
|
||||
assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts'));
|
||||
@@ -1225,5 +1250,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
stale.resolve(json({users:[{username:'replaced',role:'user',user_id:90,auth_generation:99}]})); await tick();
|
||||
assert.match(b.nodes['accounts-list'].textContent,/alice/); assert.doesNotMatch(b.nodes['accounts-list'].textContent,/replaced/); assert.equal(b.nodes['account-generated'].value,secret);
|
||||
});
|
||||
await require('./network.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Structural checks of production HTML; not a browser layout/visual test."""
|
||||
from html.parser import HTMLParser
|
||||
|
||||
|
||||
class Document(HTMLParser):
|
||||
def __init__(self, html):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.root = {'tag': 'root', 'attrs': {}, 'children': [], 'text': ''}
|
||||
self.stack = [self.root]
|
||||
self.ids = {}
|
||||
self.feed(html)
|
||||
assert self.stack == [self.root]
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
node = {'tag': tag, 'attrs': dict(attrs), 'children': [], 'text': '', 'parent': self.stack[-1]}
|
||||
self.stack[-1]['children'].append(node)
|
||||
if 'id' in node['attrs']:
|
||||
ident = node['attrs']['id']
|
||||
assert ident not in self.ids, ident
|
||||
self.ids[ident] = node
|
||||
if tag not in {'meta', 'link', 'img', 'input', 'br', 'hr'}:
|
||||
self.stack.append(node)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
assert self.stack[-1]['tag'] == tag, (tag, self.stack[-1]['tag'])
|
||||
self.stack.pop()
|
||||
|
||||
def handle_data(self, data):
|
||||
for node in self.stack:
|
||||
node['text'] += data
|
||||
|
||||
|
||||
def check_layout(html):
|
||||
doc = Document(html)
|
||||
ids = doc.ids
|
||||
def classes(node):
|
||||
return node['attrs'].get('class', '').split()
|
||||
def descendants(node):
|
||||
for child in node['children']:
|
||||
yield child
|
||||
yield from descendants(child)
|
||||
def ancestor(node, cls):
|
||||
while 'parent' in node:
|
||||
node = node['parent']
|
||||
if cls in classes(node):
|
||||
return node
|
||||
raise AssertionError(cls)
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary'):
|
||||
assert ids[ident]['tag'] == 'dl'
|
||||
assert 'settings-values' in classes(ids[ident])
|
||||
for ident in ('serial-settings-content', 'account-settings', 'network-settings'):
|
||||
nodes = list(descendants(ids[ident]))
|
||||
assert not any(n['tag'] == 'pre' for n in nodes)
|
||||
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
|
||||
for n in nodes:
|
||||
if n['tag'] in ('input', 'select', 'textarea'):
|
||||
assert n['parent']['tag'] == 'label'
|
||||
try:
|
||||
ancestor(n, 'settings-edit')
|
||||
except AssertionError:
|
||||
ancestor(n, 'serial-edit')
|
||||
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh'):
|
||||
assert ids[ident]['text'] == 'Refresh'
|
||||
for ident in ('serial-result', 'account-result', 'network-result'):
|
||||
assert ids[ident]['text'] == 'Check Operation Result'
|
||||
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
|
||||
assert 'settings-check' in classes(ids[ident]['parent'])
|
||||
for ident in ('account-public-key', 'account-generated'):
|
||||
assert 'settings-wide' in classes(ids[ident]['parent'])
|
||||
assert 'readonly' in ids['account-generated']['attrs']
|
||||
assert 'hidden' in ids['account-generated-panel']['attrs']
|
||||
assert ids['network-apply']['parent'] is ids['network-wifi-save']['parent']
|
||||
assert ids['network-wifi-save']['parent'] is ids['network-wifi-load']['parent']
|
||||
assert ids['network-start']['parent'] is not ids['network-wifi-save']['parent']
|
||||
assert ids['network-start']['parent'] is ids['network-next-profile']['parent']
|
||||
assert html.index('id="account-delete"') < html.index('id="account-result"') < html.index('id="account-key-add"') < html.index('id="account-submit-password"')
|
||||
css = next(n['text'] for n in descendants(doc.root) if n['tag'] == 'style')
|
||||
for rule in (
|
||||
'.settings-values dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}',
|
||||
'.settings-values{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,2fr);gap:8px 16px;max-width:600px}',
|
||||
'.serial-edit,.settings-edit{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;max-width:600px}',
|
||||
'.settings-edit .settings-wide{grid-column:1/-1}',
|
||||
'.settings-edit input[type=checkbox]{width:auto;flex:none}',
|
||||
'.settings-edit textarea{min-height:96px;resize:vertical}',
|
||||
'.settings-edit input[readonly]{font-family:monospace}',
|
||||
'.settings-page h3{font-size:1.17em;margin:1em 0}',
|
||||
'.settings-page .connection-detail{max-width:600px;overflow-wrap:anywhere}',
|
||||
'@media(max-width:360px)',
|
||||
'.settings-edit{grid-template-columns:minmax(0,1fr)}',
|
||||
):
|
||||
assert rule in css, rule
|
||||
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
|
||||
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all three settings views')
|
||||
|
||||
|
||||
def check_browser_layout(html, tmp, executable):
|
||||
"""Optional real CSS layout check; fixture data, no application/network execution."""
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
# Keep production HTML/CSS, but do not run its authenticated loader or assets.
|
||||
fixture = re.sub(r'<script\b[^>]*>.*?</script>', '', html, flags=re.S)
|
||||
fixture = re.sub(r'<link\b[^>]*>|<img\b[^>]*>', '', fixture)
|
||||
probe = r'''
|
||||
const cases = [];
|
||||
for (const width of [320, 600, 1200]) for (const view of ['serial-settings-content', 'account-settings', 'network-settings']) {
|
||||
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = '900px';
|
||||
cases.push(new Promise(resolve => {
|
||||
frame.onload = () => {
|
||||
const d = frame.contentDocument, win = frame.contentWindow;
|
||||
d.getElementById('serial-settings').hidden = false;
|
||||
for (const id of ['serial-settings-content', 'account-settings', 'network-settings']) d.getElementById(id).hidden = id !== view;
|
||||
const section = d.getElementById(view);
|
||||
section.querySelectorAll('[hidden]').forEach(n => n.hidden = false);
|
||||
section.querySelectorAll('dl').forEach(dl => {
|
||||
dl.textContent = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const dt = d.createElement('dt'), dd = d.createElement('dd');
|
||||
dt.textContent = 'Password configured'; dd.textContent = 'SHA256:' + 'x'.repeat(96);
|
||||
dl.append(dt, dd);
|
||||
}
|
||||
});
|
||||
section.querySelectorAll('input:not([type=checkbox]),textarea').forEach(n => n.value = 'x'.repeat(96));
|
||||
const errors = [];
|
||||
for (const node of section.querySelectorAll('dl,input,select,textarea,.settings-edit,.serial-edit,.serial-actions')) {
|
||||
const rect = node.getBoundingClientRect();
|
||||
if (!rect.width) continue;
|
||||
if (rect.left < 0 || rect.right > width + 1) errors.push('overflow:' + (node.id || node.className));
|
||||
if (node.matches('dl') && node.scrollWidth > node.clientWidth + 1) errors.push('summary overflow');
|
||||
if (node.matches('input[type=checkbox]') && rect.width > 30) errors.push('checkbox width');
|
||||
if (node.matches('dl,.settings-edit,.serial-edit') && rect.width > 601) errors.push('max width');
|
||||
}
|
||||
// Measure glyph ranges, not the full-width grid cell or textContent.
|
||||
// The normal-whitespace control proves the pair detects collapsing.
|
||||
for (const dd of section.querySelectorAll('dl dd:first-of-type')) {
|
||||
const original = dd.textContent;
|
||||
const measureSSID = spaces => {
|
||||
dd.textContent = 'SSID: ' + JSON.stringify('office' + ' '.repeat(spaces) + 'wifi');
|
||||
const range = d.createRange();
|
||||
range.setStart(dd.firstChild, 7); range.setEnd(dd.firstChild, dd.textContent.length - 1);
|
||||
if (range.getClientRects().length !== 1) errors.push('SSID probe unexpectedly wrapped');
|
||||
return range.getBoundingClientRect().width;
|
||||
};
|
||||
const single = measureSSID(1), double = measureSSID(2);
|
||||
if (!(single > 0 && double > single + 1)) errors.push('consecutive SSID spaces collapsed');
|
||||
dd.style.whiteSpace = 'normal';
|
||||
if (Math.abs(measureSSID(2) - measureSSID(1)) > 0.1) errors.push('invalid whitespace control');
|
||||
dd.style.removeProperty('white-space'); dd.textContent = original;
|
||||
}
|
||||
resolve({width, view, errors});
|
||||
};
|
||||
}));
|
||||
frame.srcdoc = FIXTURE; document.body.append(frame);
|
||||
}
|
||||
Promise.all(cases).then(results => {
|
||||
const out = document.createElement('pre'); out.id = 'layout-results'; out.textContent = JSON.stringify(results); document.body.append(out);
|
||||
});
|
||||
'''.replace('FIXTURE', json.dumps(fixture))
|
||||
page = tmp / 'layout-probe.html'
|
||||
page.write_text('<!doctype html><html><body><script>' + probe + '</script></body></html>')
|
||||
result = subprocess.run([executable, '--headless', '--no-sandbox', '--disable-gpu',
|
||||
'--no-first-run', '--disable-background-networking',
|
||||
'--user-data-dir=' + str(tmp / 'chromium-profile'),
|
||||
'--virtual-time-budget=3000', '--dump-dom', page.as_uri()],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
assert result.returncode == 0, result.stderr
|
||||
parsed = Document(result.stdout)
|
||||
results = json.loads(parsed.ids['layout-results']['text'])
|
||||
assert len(results) == 9
|
||||
assert all(not case['errors'] for case in results), results
|
||||
print('PASS Chromium layout: all three settings views at 320/600/1200px; bounded controls, summaries, inline checkboxes and rendered consecutive-space distinction (fixture data, not live app)')
|
||||
@@ -0,0 +1,405 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
|
||||
const path = '/api/settings/network', operation = path + '-operation';
|
||||
const fixture = () => ({wifi: {generation: 7, enabled_at_boot: true,
|
||||
ap: {policy: 'fallback', channel: 6, ssid: 'access', password_configured: true},
|
||||
profiles: Array.from({length: 4}, (_, index) => ({index, enabled: index === 0, priority: index * 10,
|
||||
security: 'mixed', ssid: index === 0 ? 'office' : '', password_configured: index === 0}))},
|
||||
runtime: {started: true, state: 'connecting', active_profile: 0, ip: '0.0.0.0', ap_running: true, ap_clients: 1, last_error: 0},
|
||||
mdns: {generation: 3, suffix: 'example', hostname: 'sak-example', announced: false, last_error: 0}});
|
||||
const reply = (id = 42, state = 'pending', action = 'wifi-patch', status = 200, error = 0) => new Response(JSON.stringify({id, action, state, error}), {status});
|
||||
const ack = action => reply(42, 'pending', action, 202);
|
||||
const n = (b, id) => b.nodes['network-' + id];
|
||||
const input = (b, id, value, event = 'input') => { n(b, id).value = value; n(b, id)[event](); };
|
||||
const target = (b, value) => input(b, 'target', value, 'change');
|
||||
const secret = (b, value = 'a safe PSK') => { input(b, 'password-mode', 'replace', 'change'); input(b, 'password', value); };
|
||||
const clean = b => { assert.equal(n(b, 'password').value, ''); assert.equal(n(b, 'password-mode').value, 'keep'); assert.ok(n(b, 'password').disabled); assert.ok(![...b.timers.values()].some(t => t.ms === 60000)); };
|
||||
const posts = b => b.calls.filter(c => c.url === operation && c.method === 'POST');
|
||||
const gets = b => b.calls.filter(c => c.url === operation && c.method === 'GET');
|
||||
const reads = b => b.calls.filter(c => c.url === path);
|
||||
const safe = (b, text = 'SECRET') => { for (const node of Object.values(b.nodes)) assert.ok(!node.textContent.includes(text)); };
|
||||
async function open(value = fixture()) {
|
||||
const b = await adminBrowser(); b.click('select-settings'); await tick();
|
||||
b.queues[path].push(json(value)); b.click('settings-network'); await tick();
|
||||
return b;
|
||||
}
|
||||
async function complete(b, action, state = 'accepted', value = fixture(), error = 0) {
|
||||
b.queues[operation].push(reply(42, state, action, 200, error)); b.queues[path].push(json(value));
|
||||
b.fire(1000); await tick();
|
||||
}
|
||||
await test('Network authored HTML has actual AP/four profiles/mDNS controls and truthful persistence/recovery policy', async () => {
|
||||
for (const id of ['settings-network','network-target','network-ssid','network-ssid-mode','network-apply','network-result','network-wifi-save','network-wifi-load','network-start','network-stop','network-reconnect','network-next-profile','network-mdns-set','network-mdns-save','network-mdns-load','network-mdns-defaults']) assert.ok(html.includes('id="' + id + '"'), id);
|
||||
for (let i = 0; i < 4; i++) assert.ok(html.includes('value="' + i + '">STA profile ' + i));
|
||||
// Allow at least one excess character so native maxlength cannot silently turn an oversized paste into a valid credential/SSID.
|
||||
assert.match(html, /id="network-password" type="password" maxlength="64" autocomplete="new-password" disabled/);
|
||||
assert.match(html, /id="network-ssid" maxlength="256"/);
|
||||
for (const text of ['NOT browser drafts', 'No Wi-Fi defaults/reset', 'Accepted is NOT connected', 'UART0', 'native USB', 'STA-only responder', 'not secure flash erasure', 'blank never clears', '60 seconds']) assert.ok(html.toLowerCase().includes(text.toLowerCase()), text);
|
||||
assert.ok(!html.includes('id="network-wifi-defaults"') && !html.includes('id="network-wifi-reset"'));
|
||||
});
|
||||
await test('Network is admin-only; navigation retains terminals, writer lease, hidden binary drains and no routine confirmation', async () => {
|
||||
const u = browser(); u.start(); await tick(); u.click('settings-network'); await tick(); assert.equal(reads(u).length, 0);
|
||||
const b = await open(); const count = b.calls.length;
|
||||
b.window.confirm = () => { throw new Error('No fetch confirmations'); };
|
||||
b.click('settings-network'); b.click('select-settings'); await tick(); assert.equal(b.calls.length, count);
|
||||
assert.equal(n(b, 'edit').hidden, false); assert.equal(b.nodes['serial-settings-content'].hidden, true); assert.equal(b.nodes['account-settings'].hidden, true);
|
||||
assert.match(n(b, 'summary').textContent, /STA 3/); assert.match(n(b, 'summary').textContent, /Runtime: connecting/);
|
||||
assert.equal(n(b, 'password-clear').hidden, true); clean(b);
|
||||
for (let i = 0; i < 2; ++i) {
|
||||
b.sockets[i].emit('message', {data: Uint8Array.of(0, 255, i).buffer}); assert.deepEqual(b.terminals[i].writes.at(-1), [0, 255, i]);
|
||||
b.terminals[i].input('not to terminal'); assert.equal(b.sockets[i].sent.length, 0);
|
||||
}
|
||||
b.click('settings-accounts'); await tick(); b.queues[path].push(json(fixture())); b.click('settings-network'); await tick();
|
||||
assert.equal(b.sockets.length, 2); assert.ok(b.sockets.every(s => !s.closed && !s.sent.length));
|
||||
assert.match(b.nodes['input-state'].textContent, /writer lease retained/);
|
||||
});
|
||||
await test('Network summary shows readable quoted ASCII SSIDs and lossless hex fallback', async () => {
|
||||
const v = fixture(); v.wifi.ap.ssid = 'ESP32-SAK-FA7059';
|
||||
const names = ['Moppelkotze', 'Voltage-legacy', 'TKRZ', ''];
|
||||
v.wifi.profiles.forEach((p, i) => { p.ssid = names[i]; });
|
||||
const b = await open(v);
|
||||
for (const name of [v.wifi.ap.ssid, ...names]) assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify(name)));
|
||||
assert.ok(!n(b, 'summary').textContent.includes('SSID hex:'));
|
||||
for (const name of [' leading and trailing ', 'a"b\\c', '~'.repeat(32)]) {
|
||||
v.wifi.ap.ssid = name; b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
||||
assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify(name)));
|
||||
assert.equal(n(b, 'ssid').value, name);
|
||||
}
|
||||
for (const bytes of ['A\0\xff', '\r\n\t', '\x7f', '\xc3\xa9']) {
|
||||
v.wifi.ap.ssid = bytes; v.wifi.profiles[0].ssid = bytes;
|
||||
b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
||||
const hex = Array.from(bytes, c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' ');
|
||||
assert.equal(n(b, 'summary').textContent.split('SSID hex: ' + hex).length - 1, 2);
|
||||
}
|
||||
assert.equal(posts(b).length, 0);
|
||||
});
|
||||
await test('Structured network rows retain every field, escape markup and clear/fence navigation', async () => {
|
||||
const v = fixture(); v.wifi.ap.ssid = '<img onerror="x">';
|
||||
const b = await open(v), summary = n(b, 'summary');
|
||||
const rows = summary.children;
|
||||
assert.equal(rows.length, 58);
|
||||
rows.forEach((node, i) => { assert.equal(node.tagName, i % 2 ? 'DD' : 'DT'); assert.equal(node.children.length, 0); });
|
||||
const values = Object.fromEntries(rows.filter((_, i) => i % 2 === 0).map((node, i) => [node.textContent, rows[i * 2 + 1].textContent]));
|
||||
assert.equal(values['AP SSID'], 'SSID: ' + JSON.stringify(v.wifi.ap.ssid));
|
||||
assert.equal(values['AP password configured'], 'true');
|
||||
assert.equal(values['STA 3 password configured'], 'false');
|
||||
assert.equal(values['AP clients'], '1'); assert.equal(values['Wi-Fi last error'], '0');
|
||||
assert.equal(values['mDNS last error'], '0'); assert.equal(values['Expected announcement'], 'false');
|
||||
assert.equal(values['DNS verification'], 'Not client-verified DNS.');
|
||||
const d = deferred(); b.queues[path].push(d.promise); b.click('network-refresh'); await tick();
|
||||
b.click('settings-accounts'); await tick(); assert.equal(summary.children.length, 0);
|
||||
assert.ok(rows.every(node => node.parentNode === null));
|
||||
d.resolve(json(v)); await tick(); assert.equal(summary.children.length, 0);
|
||||
b.queues[path].push(json(fixture())); b.click('settings-network'); await tick();
|
||||
assert.equal(summary.children.length, 58); assert.ok(!summary.textContent.includes('<img'));
|
||||
});
|
||||
await test('Network strict nested snapshot shape rejects secret fields, types, ranges, duplicates and inconsistent canonical values', async () => {
|
||||
const edits = [v => v.password = 'SECRET', v => v.wifi.password = 'SECRET', v => v.wifi.ap.password = 'SECRET', v => v.wifi.profiles[1].password = 'SECRET',
|
||||
v => delete v.runtime.ip, v => v.wifi.generation = 0, v => v.mdns.generation = 4294967296, v => v.wifi.enabled_at_boot = 1,
|
||||
v => v.wifi.ap.policy = 'open', v => v.wifi.ap.channel = 12, v => v.wifi.ap.ssid = '', v => v.wifi.ap.password_configured = false,
|
||||
v => v.wifi.ap.ssid = '\u0100', v => v.wifi.ap.ssid = '\ud800', v => v.wifi.ap.ssid = 'x'.repeat(33),
|
||||
v => v.wifi.profiles.pop(), v => v.wifi.profiles.push(v.wifi.profiles[0]), v => v.wifi.profiles[1].index = 0,
|
||||
v => v.wifi.profiles[1].enabled = 'false', v => v.wifi.profiles[1].priority = 256, v => v.wifi.profiles[1].priority = 0.5,
|
||||
v => v.wifi.profiles[1].security = 'open', v => v.wifi.profiles[1].enabled = true, v => v.wifi.profiles[0].password_configured = false,
|
||||
v => v.wifi.profiles[1].password_configured = true, v => v.wifi.profiles[0].password_configured = 'yes',
|
||||
v => v.runtime.state = '<img>', v => v.runtime.started = 1, v => v.runtime.active_profile = 4, v => v.runtime.active_profile = -2,
|
||||
v => v.runtime.ip = '256.1.1.1', v => v.runtime.ip = '<img>', v => v.runtime.ap_running = 1, v => v.runtime.ap_clients = 256, v => v.runtime.last_error = 2147483648,
|
||||
v => v.mdns.suffix = 'A', v => v.mdns.suffix = '-x', v => v.mdns.suffix = 'x-', v => v.mdns.hostname = 'not-matching', v => v.mdns.announced = 0, v => v.mdns.last_error = null];
|
||||
const b = await open(), before = n(b, 'summary').textContent;
|
||||
for (const change of edits) {
|
||||
const v = fixture(); change(v); b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
||||
assert.match(n(b, 'detail').textContent, /stale.*invalid/); assert.equal(n(b, 'summary').textContent, before); assert.ok(n(b, 'apply').disabled); safe(b);
|
||||
}
|
||||
for (const value of [null, [], {}, {wifi: null}, {wifi: [], runtime: {}, mdns: {}}]) {
|
||||
b.queues[path].push(json(value)); b.click('network-refresh'); await tick(); assert.ok(n(b, 'apply').disabled);
|
||||
}
|
||||
});
|
||||
await test('Network snapshot 2048-byte/UTF-8/HTTP bounds and maximal escaped SSIDs remain safe text', async () => {
|
||||
const b = await open();
|
||||
for (const response of [new Response(' '.repeat(2049)), new Response(Uint8Array.of(255)), new Response('{'), failure(503), new Response(JSON.stringify(fixture()), {status: 202})]) {
|
||||
b.queues[path].push(response); b.click('network-refresh'); await tick(); assert.match(n(b, 'detail').textContent, /stale/); assert.ok(n(b, 'apply').disabled); safe(b);
|
||||
}
|
||||
const v = fixture(); v.wifi.ap.ssid = '\xff'.repeat(32); v.wifi.generation = v.mdns.generation = 4294967295;
|
||||
for (const p of v.wifi.profiles) p.ssid = '\xff'.repeat(32);
|
||||
v.mdns.suffix = 'a'.repeat(55); v.mdns.hostname = 'sak-' + v.mdns.suffix;
|
||||
const encoded = JSON.stringify(v).replace(/[\x7f-\uffff]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
|
||||
assert.ok(Buffer.byteLength(encoded) < 2048); b.queues[path].push(new Response(encoded)); b.click('network-refresh'); await tick();
|
||||
assert.equal(n(b, 'edit').hidden, false); assert.equal(n(b, 'apply').disabled, false); assert.equal(n(b, 'ssid-mode').value, 'hex');
|
||||
v.wifi.ap.ssid = '<img onerror="x">'; b.queues[path].push(json(v)); b.click('network-refresh'); await tick();
|
||||
assert.equal(n(b, 'ssid').value, '<img onerror="x">'); assert.ok(n(b, 'summary').textContent.includes('SSID: ' + JSON.stringify('<img onerror="x">')));
|
||||
console.log('Network escaped snapshot fixture bytes:', Buffer.byteLength(encoded));
|
||||
});
|
||||
await test('Network ordinary Unicode text is UTF-8 once, escaped as byte codepoints, while unchanged existing UTF-8 is omitted', async () => {
|
||||
for (const text of ['café', '東京📡', 'é'.repeat(16), 'a"b\\c', '\ufeffoffice']) {
|
||||
const b = await open(); input(b, 'ssid', text); b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
|
||||
const post = posts(b)[0]; assert.ok(post); assert.ok(!/[^\x00-\x7f]/.test(post.body));
|
||||
assert.deepEqual(Buffer.from(JSON.parse(post.body).ssid, 'latin1'), Buffer.from(text, 'utf8'));
|
||||
assert.equal(post.headers['X-CSRF-Token'], token); assert.equal(post.headers['Content-Type'], 'application/json');
|
||||
}
|
||||
const v = fixture(); v.wifi.ap.ssid = Buffer.from('café📡', 'utf8').toString('latin1'); const b = await open(v);
|
||||
assert.equal(n(b, 'ssid').value, 'café📡'); n(b, 'channel').value = '7'; b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), {action: 'wifi-patch', generation: 7, channel: 7});
|
||||
});
|
||||
await test('Network arbitrary bytes, embedded zero, invalid UTF-8 and BOM have lossless mode conversion or explicit refusal', async () => {
|
||||
for (const bytes of ['A\0\xff', '\xc0\xaf', '\xed\xa0\x80', '\xff\xfe', '\r\n\t', '\xef\xbb\xbfhello']) {
|
||||
const v = fixture(); v.wifi.ap.ssid = bytes; const b = await open(v);
|
||||
if (bytes === '\xef\xbb\xbfhello') { assert.equal(n(b, 'ssid').value, '\ufeffhello'); input(b, 'ssid-mode', 'hex', 'change'); }
|
||||
else {
|
||||
assert.equal(n(b, 'ssid-mode').value, 'hex'); const before = n(b, 'ssid').value;
|
||||
input(b, 'ssid-mode', 'text', 'change'); assert.equal(n(b, 'ssid-mode').value, 'hex'); assert.equal(n(b, 'ssid').value, before);
|
||||
}
|
||||
input(b, 'ssid', n(b, 'ssid').value + ' 42'); b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
|
||||
assert.equal(JSON.parse(posts(b)[0].body).ssid, bytes + 'B');
|
||||
}
|
||||
const b = await open(); input(b, 'ssid', 'café'); input(b, 'ssid-mode', 'hex', 'change'); assert.equal(n(b, 'ssid').value, '63 61 66 c3 a9');
|
||||
input(b, 'ssid-mode', 'text', 'change'); assert.equal(n(b, 'ssid').value, 'café');
|
||||
});
|
||||
await test('Network SSID max32 decoded bytes and invalid hex/surrogates never submit or truncate', async () => {
|
||||
for (const text of ['x'.repeat(33), 'é'.repeat(17), '📡'.repeat(9), '\ud800', '\udc00', 'x'.repeat(10000)]) {
|
||||
const b = await open(); input(b, 'ssid', text); b.click('network-apply'); await tick(); assert.equal(posts(b).length, 0); assert.match(n(b, 'operation-detail').textContent, /Not submitted/);
|
||||
}
|
||||
for (const hex of ['f', 'gg', '0x41', '41 42', '41\t42', 'ff'.repeat(33), 'ff '.repeat(100)]) {
|
||||
const b = await open(); input(b, 'ssid-mode', 'hex', 'change'); input(b, 'ssid', hex); b.click('network-apply'); await tick(); assert.equal(posts(b).length, 0);
|
||||
}
|
||||
const b = await open(); input(b, 'ssid-mode', 'hex', 'change'); input(b, 'ssid', '00'.repeat(32)); b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
|
||||
assert.equal(JSON.parse(posts(b)[0].body).ssid, '\0'.repeat(32)); assert.ok(Buffer.byteLength(posts(b)[0].body) <= 768);
|
||||
});
|
||||
await test('Network every byte00..FF round-trips without secret export or UTF-8 reinterpretation', async () => {
|
||||
for (let start = 0; start < 256; start += 32) {
|
||||
const bytes = String.fromCharCode(...Array.from({length:32}, (_, i) => start + i));
|
||||
const v = fixture(); v.wifi.ap.ssid = bytes; const b = await open(v);
|
||||
if (n(b, 'ssid-mode').value !== 'hex') input(b, 'ssid-mode', 'hex', 'change');
|
||||
const expected = [...bytes].reverse().join(''); input(b, 'ssid', [...expected].map(c => c.charCodeAt(0).toString(16).padStart(2,'0')).join(' '));
|
||||
b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
|
||||
const wire = posts(b)[0].body; assert.ok(!/[^\x00-\x7f]/.test(wire)); assert.equal(JSON.parse(wire).ssid, expected);
|
||||
}
|
||||
});
|
||||
await test('Network four stable profile targets and canonical input number/enum limits are typed and bounded', async () => {
|
||||
for (let i = 0; i < 4; ++i) {
|
||||
const b = await open(); target(b, String(i)); input(b, 'ssid', 'profile-' + i); input(b, 'priority', '255'); input(b, 'security', 'wpa3', 'change');
|
||||
n(b, 'enabled').checked = true; n(b, 'enabled').change(); secret(b, 'p'.repeat(63)); b.queues[operation].push(ack('profile-patch')); b.click('network-apply'); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'profile-patch',generation:7,profile:i,...(i === 0 ? {} : {enabled:true}),priority:255,security:'wpa3',ssid:'profile-' + i,password:'p'.repeat(63)});
|
||||
assert.ok(Buffer.byteLength(posts(b)[0].body) <= 768);
|
||||
}
|
||||
for (const [id, values] of [['channel',['0','12','1.0','01','1e1','-1','9999']], ['priority',['-1','256','1.5','1e2','00']], ['security',['open','wpa2','<img>']], ['policy',['open','<img>']]]) {
|
||||
for (const value of values) {
|
||||
const b = await open(); if (['priority','security'].includes(id)) target(b,'1');
|
||||
input(b,id,value,['policy','security'].includes(id) ? 'change' : 'input'); b.click('network-apply'); await tick(); assert.equal(posts(b).length,0);
|
||||
}
|
||||
}
|
||||
const b = await open(); input(b,'ssid','é'.repeat(16)); assert.match(n(b,'ssid-detail').textContent,/32 \/ 32 bytes.*UTF-8/);
|
||||
input(b,'ssid','é'.repeat(17)); assert.match(n(b,'ssid-detail').textContent,/exceeds 32 bytes/);
|
||||
});
|
||||
await test('Network all canonical runtime states and signed diagnostic endpoints remain truthful', async () => {
|
||||
for (const state of ['stopped','starting','connecting','waiting-ip','online','backoff','ap-only','error','unknown']) {
|
||||
const v=fixture(); v.runtime.state=state; v.runtime.active_profile=-1; v.runtime.last_error=-2147483648; v.mdns.last_error=2147483647;
|
||||
const b=await open(v); assert.match(n(b,'summary').textContent,new RegExp('Runtime: '+state)); assert.equal(n(b,'apply').disabled,false);
|
||||
assert.match(n(b,'summary').textContent,/-2147483648/); assert.match(n(b,'summary').textContent,/2147483647/);
|
||||
}
|
||||
});
|
||||
await test('Network Keep defaults and omission of unchanged fields prevent accidental secret mutations', async () => {
|
||||
const b = await open(); n(b, 'password').value = 'autofill must not replace'; input(b, 'channel', '7');
|
||||
b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'wifi-patch', generation:7, channel:7}); clean(b);
|
||||
const c = await open(); c.click('network-apply'); await tick(); assert.equal(posts(c).length, 0); assert.match(n(c, 'operation-detail').textContent, /No selected-target changes/);
|
||||
target(c, '2'); input(c, 'priority', '255'); c.queues[operation].push(ack('profile-patch')); c.click('network-apply'); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(c)[0].body), {action:'profile-patch', generation:7, profile:2, priority:255});
|
||||
});
|
||||
await test('Network replacement ASCII bounds, escaped password and full escaped byte request remain below768 and wipe immediately', async () => {
|
||||
for (const password of [' '.repeat(8), 'valid"\\pass', '\\'.repeat(63)]) {
|
||||
const b = await open(); input(b, 'ssid-mode', 'hex', 'change'); input(b, 'ssid', 'ff '.repeat(31) + 'ff'); input(b, 'policy', 'always', 'change'); input(b, 'channel', '11'); n(b, 'boot').checked = false; n(b, 'boot').change();
|
||||
secret(b, password); b.queues[operation].push(ack('wifi-patch')); b.click('network-apply'); clean(b); await tick();
|
||||
const post = posts(b)[0]; assert.equal(JSON.parse(post.body).password, password); assert.equal(JSON.parse(post.body).ssid, '\xff'.repeat(32)); assert.ok(Buffer.byteLength(post.body) <= 768); safe(b, password);
|
||||
if (password.length === 63) console.log('Network fully escaped AP patch fixture bytes:', Buffer.byteLength(post.body));
|
||||
}
|
||||
for (const password of ['', 'a'.repeat(7), 'a'.repeat(64), 'é'.repeat(8), 'test\npass', 'test\x7fpass']) {
|
||||
const b = await open(); secret(b, password); b.click('network-apply'); await tick(); clean(b); assert.equal(posts(b).length, 0);
|
||||
}
|
||||
});
|
||||
await test('Network explicit STA disable+clear, empty SSID constraints, AP no-clear and enabled STA PSK constraints', async () => {
|
||||
for (const which of ['ap', '0']) {
|
||||
const b = await open(); target(b, which); n(b, 'password-mode').value = 'clear'; b.click('network-apply'); await tick(); clean(b); assert.equal(posts(b).length, 0);
|
||||
}
|
||||
const b = await open(); target(b, '0'); n(b, 'enabled').checked = false; n(b, 'enabled').change(); input(b, 'ssid', ''); input(b, 'password-mode', 'clear', 'change');
|
||||
b.queues[operation].push(ack('profile-patch')); b.click('network-apply'); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'profile-patch',generation:7,profile:0,enabled:false,ssid:'',clear_password:true});
|
||||
const c = await open(); target(c, '1'); n(c, 'enabled').checked = true; n(c, 'enabled').change(); input(c, 'ssid', 'new office'); c.click('network-apply'); await tick(); assert.equal(posts(c).length, 0);
|
||||
secret(c); c.queues[operation].push(ack('profile-patch')); c.click('network-apply'); await tick(); assert.equal(JSON.parse(posts(c)[0].body).enabled, true);
|
||||
const d = await open(); input(d, 'ssid', ''); d.click('network-apply'); await tick(); assert.equal(posts(d).length, 0);
|
||||
});
|
||||
await test('Network transient PSK cleanup covers target, draft context, domain/view, refresh, logout, pagehide, session and timeout', async () => {
|
||||
for (const mode of ['target','ssid','ssid-mode','priority','channel','boot','policy','enabled','security','suffix','password-mode','domain','view','refresh','pagehide','logout','identity','401','timeout']) {
|
||||
const b = await open(); secret(b); assert.equal(n(b, 'password').value, 'a safe PSK');
|
||||
if (mode === 'target') target(b, '1');
|
||||
else if (['ssid','priority','channel','suffix'].includes(mode)) n(b, mode).input();
|
||||
else if (mode === 'password-mode') input(b, 'password-mode', 'keep', 'change');
|
||||
else if (['ssid-mode','boot','policy','enabled','security'].includes(mode)) n(b, mode).change();
|
||||
else if (mode === 'domain') b.click('settings-accounts');
|
||||
else if (mode === 'view') b.click('select-serial');
|
||||
else if (mode === 'refresh') { b.queues[path].push(json(fixture())); b.click('network-refresh'); }
|
||||
else if (mode === 'pagehide') b.emit('pagehide');
|
||||
else if (mode === 'logout') b.click('sign-out');
|
||||
else if (mode === 'identity' || mode === '401') { b.queues['/api/session'].push(mode === 'identity' ? session({role:'admin',username:'replacement'}) : failure(401)); b.click('network-refresh'); }
|
||||
else { b.elapse(60000); b.fire(60000); }
|
||||
await tick(); clean(b); safe(b, 'a safe PSK');
|
||||
}
|
||||
});
|
||||
await test('Network delayed secret expiry/context mismatch rejects replacement, confirmation cancellation wipes without a request', async () => {
|
||||
for (const mode of ['time','target','draft']) {
|
||||
const b = await open(); secret(b);
|
||||
if (mode === 'time') b.elapse(60000); else if (mode === 'target') n(b, 'target').value = '0'; else n(b, 'channel').value = '7';
|
||||
b.click('network-apply'); await tick(); clean(b); assert.equal(posts(b).length, 0);
|
||||
}
|
||||
const b = await open(); secret(b); b.window.confirm = () => false; const count = b.calls.length; b.click('network-apply'); await tick(); clean(b); assert.equal(b.calls.length, count);
|
||||
});
|
||||
await test('Network exact generation/action fields, Save device-working-not-draft and RAM-only mDNS semantics', async () => {
|
||||
for (const action of ['wifi-save','wifi-load','start','stop','reconnect','next-profile','mdns-set','mdns-save','mdns-load','mdns-defaults']) {
|
||||
const b = await open(); input(b, 'ssid', 'UNAPPLIED DRAFT'); input(b, 'suffix', 'new-suffix'); secret(b);
|
||||
const confirms = []; b.window.confirm = text => { confirms.push(text); return true; };
|
||||
b.queues[operation].push(ack(action)); b.click('network-' + action); clean(b); await tick();
|
||||
assert.deepEqual(JSON.parse(posts(b)[0].body), {action, ...(action.startsWith('wifi-') ? {generation:7} : action.startsWith('mdns-') ? {generation:3} : {}), ...(action === 'mdns-set' ? {suffix:'new-suffix'} : {})});
|
||||
assert.equal(confirms.length, ['wifi-load','start','stop','reconnect','next-profile','mdns-load','mdns-defaults'].includes(action) ? 1 : 0);
|
||||
if (['wifi-load','start','stop','reconnect','next-profile'].includes(action)) assert.match(confirms[0], /HTTPS.*BOTH.*NOT online.*UART0.*USB/);
|
||||
const state = action.endsWith('-save') ? 'ok' : 'accepted'; await complete(b, action, state);
|
||||
assert.equal(reads(b).length, 2); assert.ok(b.sockets.every(s => !s.closed));
|
||||
assert.equal(n(b, 'ssid').value, 'access');
|
||||
}
|
||||
for (const suffix of ['', 'Upper', '-bad', 'bad-', 'a'.repeat(56), 'a.b', 'é', 'bad suffix']) {
|
||||
const b = await open(); input(b, 'suffix', suffix); b.click('network-mdns-set'); await tick(); assert.equal(posts(b).length, 0);
|
||||
}
|
||||
});
|
||||
await test('Network confirms only disruptive selected patch: disabled STA staging and boot-only do not prompt', async () => {
|
||||
for (const mode of ['boot','disabled','enabled','ap']) {
|
||||
const b = await open(), confirmations = []; b.window.confirm = s => { confirmations.push(s); return true; };
|
||||
if (mode === 'boot') { n(b, 'boot').checked = false; n(b, 'boot').change(); }
|
||||
else if (mode === 'ap') input(b, 'channel', '7');
|
||||
else { target(b, mode === 'disabled' ? '1' : '0'); input(b, 'priority', '42'); }
|
||||
const action = ['disabled','enabled'].includes(mode) ? 'profile-patch' : 'wifi-patch'; b.queues[operation].push(ack(action)); b.click('network-apply'); await tick();
|
||||
assert.equal(confirmations.length, ['ap','enabled'].includes(mode) ? 1 : 0); assert.equal(posts(b).length, 1);
|
||||
}
|
||||
});
|
||||
await test('Network pending leaves visible stale settings, single flight and accepted does not claim online', async () => {
|
||||
const b = await open(), old = n(b, 'summary').textContent; input(b, 'channel', '7'); b.queues[operation].push(ack('wifi-patch'));
|
||||
b.click('network-apply'); await tick(); const count = b.calls.length; b.click('network-apply'); b.click('network-refresh'); b.click('network-result'); await tick(); assert.equal(b.calls.length, count);
|
||||
assert.equal(n(b, 'summary').textContent, old); assert.equal(n(b, 'edit').hidden, false); assert.ok(n(b, 'ssid').disabled); assert.match(n(b, 'detail').textContent, /stale/);
|
||||
await complete(b, 'wifi-patch'); assert.match(n(b, 'operation-detail').textContent, /Accepted:.*NOT association, DHCP, online/); assert.match(n(b, 'summary').textContent, /Runtime: connecting/);
|
||||
assert.equal(posts(b).length, 1); assert.equal(reads(b).length, 2); assert.equal(n(b, 'apply').disabled, false);
|
||||
});
|
||||
await test('Network all known terminal results refresh once; stale and applied_not_queued never auto-retry or imply rollback', async () => {
|
||||
for (const [state, action] of [['failed','wifi-load'],['cancelled','stop'],['stale','wifi-patch'],['invalid','profile-patch'],['loaded_defaults','mdns-load'],['applied_not_queued','mdns-set'],['ok','mdns-save'],['accepted','reconnect']]) {
|
||||
const b = await open(); b.queues[operation].push(reply(42,state,action,200,state === 'cancelled' ? 0 : 259)); const v = fixture(); v.mdns.last_error = 259; v.runtime.state = 'error'; v.runtime.last_error = 259;
|
||||
b.queues[path].push(json(v)); b.click('network-result'); await tick(); assert.equal(reads(b).length, 2); assert.equal(posts(b).length, 0); assert.match(n(b, 'operation-detail').textContent, new RegExp('Error: ' + (state === 'cancelled' ? 0 : 259)));
|
||||
if (state === 'stale') assert.match(n(b, 'operation-detail').textContent, /Generation stale.*No automatic retry/);
|
||||
if (state === 'applied_not_queued') assert.match(n(b, 'operation-detail').textContent, /RAM changed.*queue failed.*NOT rolled back/);
|
||||
if (state === 'loaded_defaults') assert.match(n(b, 'operation-detail').textContent, /mDNS Load.*defaults in RAM.*NVS unchanged/);
|
||||
assert.match(n(b, 'summary').textContent, /Runtime: error/); for (const label of ['Wi-Fi last error', 'mDNS last error']) {
|
||||
const rows = n(b, 'summary').children, index = rows.findIndex(node => node.tagName === 'DT' && node.textContent === label);
|
||||
assert.ok(index >= 0); assert.equal(rows[index + 1].tagName, 'DD'); assert.equal(rows[index + 1].textContent, '259');
|
||||
}
|
||||
}
|
||||
const b = await open(); b.queues[operation].push(reply(42,'accepted','stop')); b.queues[path].push(failure(503)); b.click('network-result'); await tick();
|
||||
assert.match(n(b, 'operation-detail').textContent, /Accepted/); assert.match(n(b, 'detail').textContent, /stale/); assert.equal(n(b, 'edit').hidden, false); assert.ok(n(b, 'apply').disabled);
|
||||
});
|
||||
await test('Network operation strict status/four-field128-byte shape/action/state/id/error validation rejects malformed ACKs and results', async () => {
|
||||
const invalid = [null, [], {}, {id:42,action:'stop',state:'pending'}, {id:42,action:'stop',state:'pending',error:'SECRET'}, {id:42,action:'stop',state:'pending',error:0,password:'SECRET'},
|
||||
{id:0,action:'stop',state:'idle',error:0}, {id:42,action:'none',state:'pending',error:0}, {id:42,action:'stop',state:'online',error:0}, {id:4294967296,action:'stop',state:'accepted',error:0},
|
||||
{id:42,action:'stop',state:'ok',error:0}, {id:42,action:'wifi-save',state:'accepted',error:0}, {id:42,action:'stop',state:'loaded_defaults',error:0}, {id:42,action:'wifi-patch',state:'applied_not_queued',error:0},
|
||||
{id:42,action:'stop',state:'pending',error:1}, {id:42,action:'stop',state:'failed',error:2147483648}];
|
||||
const b = await open();
|
||||
for (const value of invalid) { b.queues[operation].push(json(value)); b.click('network-result'); await tick(); assert.match(n(b, 'operation-detail').textContent, /unknown/); safe(b); }
|
||||
for (const response of [new Response(' '.repeat(129)), new Response(Uint8Array.of(255)), reply(42,'pending','stop',202)]) { b.queues[operation].push(response); b.click('network-result'); await tick(); assert.match(n(b, 'operation-detail').textContent, /unknown/); }
|
||||
for (const response of [reply(42,'pending','stop',200), reply(42,'accepted','stop',202), reply(42,'pending','start',202)]) {
|
||||
const c = await open(); c.queues[operation].push(response); c.click('network-stop'); await tick(); assert.match(n(c, 'operation-detail').textContent, /unknown/); assert.equal(gets(c).length, 0); assert.equal(posts(c).length, 1);
|
||||
}
|
||||
});
|
||||
await test('Network auto-check has ten GET maximum then manual-only recovery without mutation replay', async () => {
|
||||
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick();
|
||||
for (let i = 0; i < 10; ++i) { b.queues[operation].push(reply(42,'pending','stop')); b.elapse(1000); b.fire(1000); await tick(); }
|
||||
assert.equal(gets(b).length, 10); assert.equal(posts(b).length, 1); assert.match(n(b, 'operation-detail').textContent, /Automatic checking stopped/); assert.equal(n(b, 'result').disabled, false); assert.ok(n(b, 'stop').disabled);
|
||||
b.queues[operation].push(reply(42,'accepted','stop')); b.queues[path].push(json(fixture())); b.click('network-result'); await tick(); assert.equal(reads(b).length, 2); assert.equal(posts(b).length, 1);
|
||||
});
|
||||
await test('Network fifteen-second auto deadline bounds delayed session and result reads and rejects late completion', async () => {
|
||||
for (const where of ['session','result']) {
|
||||
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); const d = deferred();
|
||||
b.queues[where === 'session' ? '/api/session' : operation].push(d.promise); b.fire(1000); await tick();
|
||||
b.elapse(15000); b.fire(15000); await tick(); d.resolve(where === 'session' ? session({role:'admin'}) : reply(42,'accepted','stop')); await tick();
|
||||
assert.match(n(b, 'operation-detail').textContent, /unknown/); assert.equal(n(b, 'result').disabled, false); assert.equal(reads(b).length, 1); assert.equal(posts(b).length, 1);
|
||||
}
|
||||
});
|
||||
await test('Network lost ACK, rejected POST and failed GET stop checking; manual latest result carries persistent uncertainty', async () => {
|
||||
for (const response of [() => { throw new Error('SECRET lost ACK'); }, failure(400), failure(403), failure(503)]) {
|
||||
const b = await open(); secret(b); b.queues[operation].push(response); b.click('network-apply'); await tick(); clean(b); safe(b);
|
||||
assert.equal(posts(b).length, 1); assert.equal(gets(b).length, 0); assert.ok(n(b, 'apply').disabled); assert.match(n(b, 'operation-detail').textContent, /No automatic mutation retry/);
|
||||
b.queues[operation].push(reply(41,'accepted','start')); b.queues[path].push(json(fixture())); b.click('network-result'); await tick();
|
||||
assert.match(n(b, 'operation-detail').textContent, /Acknowledgement lost.*earlier request/); assert.equal(posts(b).length, 1);
|
||||
b.queues[operation].push(reply(41,'accepted','start')); b.queues[path].push(json(fixture())); b.click('network-result'); await tick(); assert.match(n(b, 'operation-detail').textContent, /Acknowledgement lost/);
|
||||
}
|
||||
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); b.queues[operation].push(failure(503)); b.fire(1000); await tick();
|
||||
assert.equal(posts(b).length, 1); assert.equal(gets(b).length, 1); assert.equal(n(b, 'result').disabled, false); assert.match(n(b, 'operation-detail').textContent, /manually/);
|
||||
});
|
||||
await test('Network replaced operation ID stops auto-following; same ID action mismatch is invalid; idle never proves cancellation', async () => {
|
||||
for (const replacement of [reply(43,'pending','start'), reply(43,'accepted','start'), reply(0,'idle','none')]) {
|
||||
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); b.queues[operation].push(replacement); b.queues[path].push(json(fixture())); b.fire(1000); await tick();
|
||||
assert.match(n(b, 'operation-detail').textContent, /Previous result replaced.*unknown/); assert.equal(gets(b).length, 1); assert.equal(posts(b).length, 1);
|
||||
assert.ok(![...b.timers.values()].some(t => t.ms === 1000));
|
||||
}
|
||||
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); b.queues[operation].push(reply(42,'accepted','start')); b.fire(1000); await tick();
|
||||
assert.match(n(b, 'operation-detail').textContent, /unknown/); assert.equal(reads(b).length, 1); assert.ok(n(b, 'stop').disabled);
|
||||
});
|
||||
await test('Network navigation fences pending snapshot, POST and GET headers/bodies; never resumes or replays on return', async () => {
|
||||
for (const phase of ['snapshot','post','get']) for (const streamed of [false,true]) for (const exit of ['domain','view','pagehide']) {
|
||||
const b = await open(); let stream; const d = deferred(); const response = streamed ? new Response(new ReadableStream({start(c) { stream = c; }}), {status: phase === 'post' ? 202 : 200}) : d.promise;
|
||||
if (phase === 'snapshot') { b.queues[path].push(response); b.click('network-refresh'); }
|
||||
else { b.queues[operation].push(phase === 'post' ? response : ack('stop')); b.click('network-stop'); await tick(); if (phase === 'get') { b.queues[operation].push(response); b.fire(1000); } }
|
||||
await tick(); const request = b.calls.filter(c => c.url === (phase === 'snapshot' ? path : operation)).at(-1);
|
||||
if (exit === 'domain') b.click('settings-accounts'); else if (exit === 'view') b.click('select-serial'); else b.emit('pagehide');
|
||||
assert.ok(request.signal.aborted); const detail = n(b, 'operation-detail')?.textContent;
|
||||
const result = phase === 'snapshot' ? fixture() : {id:42,action:'stop',state:phase === 'post' ? 'pending' : 'accepted',error:0};
|
||||
if (streamed) { stream.enqueue(new TextEncoder().encode(JSON.stringify(result))); stream.close(); } else d.resolve(new Response(JSON.stringify(result), {status:phase === 'post' ? 202 : 200}));
|
||||
await tick(); assert.equal(n(b, 'summary').textContent, ''); assert.equal(n(b, 'operation-detail')?.textContent, detail); clean(b);
|
||||
const mutations = posts(b).length, resultReads = gets(b).length;
|
||||
if (exit !== 'pagehide') {
|
||||
b.queues[path].push(json(fixture())); b.click(exit === 'domain' ? 'settings-network' : 'select-settings'); await tick();
|
||||
assert.equal(posts(b).length, mutations); assert.equal(gets(b).length, resultReads); assert.ok(b.sockets.every(s => !s.closed));
|
||||
}
|
||||
}
|
||||
});
|
||||
await test('Network pre-submit session cancellation wipes secrets and cannot submit after target or session identity changes', async () => {
|
||||
for (const mode of ['target','context','domain','pagehide','identity','401']) {
|
||||
const b = await open(); secret(b); const d = deferred(); b.queues['/api/session'].push(d.promise); b.click('network-apply'); clean(b); await tick();
|
||||
if (mode === 'target') target(b, '1'); else if (mode === 'context') input(b, 'suffix', 'new-context'); else if (mode === 'domain') b.click('settings-accounts'); else if (mode === 'pagehide') b.emit('pagehide');
|
||||
d.resolve(mode === '401' ? failure(401) : session({role:'admin', ...(mode === 'identity' ? {username:'newadmin'} : {})})); await tick();
|
||||
assert.equal(posts(b).length, 0); clean(b); safe(b, 'a safe PSK');
|
||||
if (mode === 'identity' || mode === '401') { assert.deepEqual(b.redirects,[mode === 'identity' ? '/' : '/login']); assert.ok(b.sockets.every(s => s.closed)); }
|
||||
}
|
||||
});
|
||||
await test('Network endpoint401/session replacement wipe and close both routes without a success claim', async () => {
|
||||
for (const where of ['snapshot','post','get','identity']) {
|
||||
const b = await open(); secret(b);
|
||||
if (where === 'snapshot') { b.queues[path].push(failure(401)); b.click('network-refresh'); }
|
||||
else if (where === 'identity') { b.queues['/api/session'].push(session({role:'admin',csrf:'b'.repeat(64)})); b.click('network-apply'); }
|
||||
else { b.queues[operation].push(where === 'post' ? failure(401) : ack('wifi-patch')); b.click('network-apply'); await tick(); if (where === 'get') { b.queues[operation].push(failure(401)); b.fire(1000); } }
|
||||
await tick(); clean(b); assert.ok(b.sockets.every(s => s.closed)); assert.deepEqual(b.redirects, [where === 'identity' ? '/' : '/login']); assert.equal(n(b, 'summary').textContent, '');
|
||||
assert.doesNotMatch(n(b, 'operation-detail')?.textContent || '', /Accepted:|completed successfully/); assert.equal(b.timers.size, 0);
|
||||
}
|
||||
});
|
||||
await test('Network request timeouts and stale errors release single-flight state without retry or late login navigation', async () => {
|
||||
for (const kind of ['snapshot','post','get']) {
|
||||
const b = await open(); const response = o => new Promise((_, reject) => o.signal.addEventListener('abort', () => reject(new Error('SECRET timeout'))));
|
||||
if (kind === 'snapshot') { b.queues[path].push(response); b.click('network-refresh'); }
|
||||
else if (kind === 'post') { b.queues[operation].push(response); b.click('network-stop'); }
|
||||
else { b.queues[operation].push(response); b.click('network-result'); }
|
||||
await tick(); b.fire(15000); await tick(); assert.equal(n(b, 'result').disabled, false); assert.ok(n(b, 'stop').disabled); safe(b);
|
||||
assert.ok(![...b.timers.values()].some(t => t.ms === 1000)); assert.equal(posts(b).length, kind === 'post' ? 1 : 0);
|
||||
}
|
||||
const b = await open(), d = deferred(); b.queues[path].push(d.promise); b.click('network-refresh'); await tick(); b.click('settings-accounts'); await tick(); d.resolve(failure(401)); await tick(); assert.deepEqual(b.redirects, []); assert.ok(b.sockets.every(s => !s.closed));
|
||||
});
|
||||
await test('Network request ownership remains independent from account/serial outcomes and reconnect session validation', async () => {
|
||||
const b = await open(); b.queues[operation].push(ack('stop')); b.click('network-stop'); await tick(); b.click('settings-accounts'); await tick();
|
||||
const account = '/api/settings/account-operation'; b.queues[account].push(json({id:99,action:'role',state:'pending'})); b.click('account-change-role'); await tick(); b.click('settings-network'); await tick();
|
||||
b.queues[operation].push(reply(42,'accepted','stop')); b.queues[path].push(json(fixture())); b.click('network-result'); await tick(); assert.match(n(b, 'operation-detail').textContent, /stop: Accepted/);
|
||||
assert.match(b.nodes['account-operation-detail'].textContent, /pending or unknown/); assert.equal(posts(b).length, 1);
|
||||
const c = await open(), d = deferred(); c.queues['/api/session'].push(d.promise); c.click('network-refresh'); await tick(); c.click('connection-toggle'); c.click('connection-toggle'); await tick();
|
||||
d.resolve(session({role:'admin'})); await tick(); assert.equal(c.sockets.length, 3); assert.ok(!c.sockets[1].closed); assert.equal(posts(c).length, 0);
|
||||
});
|
||||
};
|
||||
@@ -10,6 +10,10 @@ import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import sys
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
from layout import check_layout, check_browser_layout
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
@@ -90,9 +94,13 @@ esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
|
||||
assert 'no private-key upload, export or SSH host management' in rendered['html']
|
||||
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization', 'clipboard', 'pushState', 'replaceState'):
|
||||
assert forbidden not in rendered['script'] + rendered['loader'], forbidden
|
||||
check_layout(rendered['html'])
|
||||
if os.environ.get('WEB_UI_CHROMIUM'):
|
||||
check_browser_layout(rendered['html'], tmp, os.environ['WEB_UI_CHROMIUM'])
|
||||
(tmp / 'rendered.json').write_text(json.dumps(rendered))
|
||||
subprocess.run(['node', str(HERE / 'browser.cjs'), str(tmp / 'rendered.json')], check=True, timeout=30)
|
||||
print('PASS C/HTML: all resource headers/failures, no-store app/document, exact loader CSP, safe fallback')
|
||||
print(f'Rendered response bytes: HTML={len(rendered["html"].encode())}, app.js={len(rendered["script"].encode())}, inline loader={len(rendered["loader"].encode())}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Reference in New Issue
Block a user