Author SHA1 Message Date
Commander1024 ece4ba77e3 Remove BLE and filesystem-backed feature proposals
Update the roadmap and session state to reflect user decisions,
retaining
only the existing transport scope and leaving advanced network work
under
evaluation.
2026-09-18 23:14:21 +02:00
Commander1024 31a22eba06 Add admin firmware upload support
Implement authenticated HTTPS OTA uploads with bounded streaming, image
validation, reboot coordination, and lifecycle exclusion. Add the admin
UI,
regression tests, and Phase 10 acceptance documentation.
2026-09-18 22:22:11 +02:00
Commander1024 4f628a4098 Define Simplified Phase 10 Firmware Upload Plan 2026-09-18 21:16:19 +02:00
Commander1024 b9653426e4 Document the Phase 9 security baseline
Add source-reviewed operational guidance, update acceptance and roadmap
references, consolidate agent state, and disable core dumps with silent
panic reboot defaults.
2026-09-18 20:37:39 +02:00
Commander1024 f40c09c11a Refine web quick panels and Wi-Fi controls 2026-09-14 00:05:27 +02:00
Commander1024 4a53a21f31 Move console payloads to lazy PSRAM storage 2026-09-13 23:06:42 +02:00
55 changed files with 2167 additions and 326 deletions
+12 -6
View File
@@ -14,7 +14,9 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
## Development status
Hardware characterization, serial/USB/Wi-Fi/HTTPS/SSH and local display/control are implemented and hardware-validated. **Phase 8 role-based users and administration is complete:** 8A8C were target-hardware validated and the user explicitly signed off tested firmware at **8D.22 (2026-09-13)**. See the [roadmap](docs/roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance evidence](docs/web_administration_acceptance.md). Very low internal/DMA lifetime minima remain a nonblocking headroom follow-up, not an approved reserve. Phase 9 hardening begins only on a separate request.
Hardware characterization, serial/USB/Wi-Fi/HTTPS/SSH and local display/control are implemented and hardware-validated. **Phase 8 role-based users and administration is complete:** 8A8C were target-hardware validated and the user explicitly signed off tested firmware at **8D.22 (2026-09-13)**. See the [roadmap](docs/roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and its [consolidated acceptance evidence](docs/roadmap.md#phase8-acceptance-evidence). Very low internal/DMA lifetime minima remain a nonblocking headroom follow-up, not an approved reserve. The reduced Phase 9 focuses on intermittent trusted-network use, standard no-dump/silent-panic defaults and a short [operational checklist](docs/security_operations.md), without dependency patches or upgrades. **The reduced Phase 9 is complete by user sign-off on 2026-09-18.** The user waived a new device check based on prior Phase 8 validation; application code is unchanged, but crash-diagnostic defaults changed. No new hardware or panic-test pass is claimed.
**Phase 10 application firmware upload is complete by explicit user acceptance on 2026-09-18:** upload works and normal operation is verified. This does not claim specific fault-injection, NVS before/after comparison, power-loss or wired-recovery checks. Administrators can upload a locally built application from **Settings → HTTPS / Reboot → Firmware update**. Install this OTA-enabled firmware by wire first; future uploads use `.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin`. See [firmware update and recovery](docs/roadmap.md#phase10-update-and-recovery) for compatibility, interruption and validation limits.
### Browser administration
@@ -31,7 +33,9 @@ Keep UART0 ready for administrative recovery and native USB for network-independ
- [Hardware wiring](docs/wiring.md): hardware profile, GPIO assignments, connector guidance, and safety notes.
- [Electrical tests](docs/electrical_tests.md): OLED/buttons, MAX3243, UART loopback, and session-broker verification procedures.
- [Role-based user database and UART0 administration](docs/user_administration_tests.md): user provisioning and administration, HTTPS/SSH authentication, session revocation, and reusable integrated web-administration regression procedures (not execution evidence).
- [Web administration](docs/web_administration.md): current bounded API/owner and recovery contracts; [acceptance evidence](docs/web_administration_acceptance.md) records sign-off and telemetry limits.
- [Web administration](docs/web_administration.md): current bounded API/owner and recovery contracts; [roadmap acceptance evidence](docs/roadmap.md#phase8-acceptance-evidence) records sign-off and telemetry limits.
- [Firmware update](docs/roadmap.md#phase-10--simple-admin-web-firmware-upload): admin-only application upload, NVS preservation, uncertain outcomes, wired recovery, acceptance evidence and reusable regression checks.
- [Security operations](docs/security_operations.md): trusted-network use, credentials, identity verification, shutdown and recovery; physical-extraction limits and lightweight upstream maintenance.
- [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands.
## Flash partition layout
@@ -51,7 +55,7 @@ The N16R8 target has 16 MiB flash and 8 MiB octal PSRAM. PlatformIO uses the cus
Application offsets are aligned to the ESP32-S3's required 64 KiB boundary. The final storage partition ends at `0x1000000`, exactly the end of the 16 MiB flash chip.
The table reserves OTA and storage space; it does not implement OTA downloads, rollback confirmation, core-dump handling, NVS encryption, or filesystem mounting.
Admin HTTPS application upload uses the inactive OTA slot and `otadata`; NVS and other data partitions are untouched by the updater. It does not provide remote downloads, automatic rollback/health confirmation, core-dump handling, NVS encryption or filesystem mounting. Image validation does not guarantee a working application; compatible storage schemas and wired recovery remain the owner's responsibility.
### One-time migration from the default partition table
@@ -63,7 +67,7 @@ pio run --target upload
pio device monitor -b 115200
```
This removes saved serial configuration and all other flash contents. The firmware recreates NVS with safe defaults. Subsequent ordinary uploads do not need a full erase.
This removes saved serial configuration and all other flash contents. The firmware recreates NVS with safe defaults. **Do not erase for subsequent ordinary wired or web application updates.** This destructive migration is only for the former default/factory layout, not for installing the updater on an existing custom-layout device.
## Build
@@ -71,6 +75,8 @@ This removes saved serial configuration and all other flash contents. The firmwa
pio run
```
The small Phase 9 sets `CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y` and `CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT=y` in `sdkconfig.defaults`. An existing generated `sdkconfig.esp32-s3-devkitc-1-n16r8` can retain earlier selections: check its resolved values after configuration, or change them using `pio run --target menuconfig`. Defaults alone do not enforce existing configurations. Silent panic reboot removes panic register/backtrace output; ordinary logs remain. No existing flash contents are erased, and stored secrets remain unencrypted.
## Upload and monitor
Connect the board's **USB-to-UART** port for firmware upload and the UART0 development console, then run:
@@ -88,7 +94,7 @@ Serial, Wi-Fi, and mDNS hostname edits remain in RAM until explicitly saved with
## Security notes
The HTTPS interface uses a device-specific self-signed certificate and a same-origin login page with bounded server-side cookie sessions; HTTP Basic is no longer accepted. Open `/` or `/login`, sign in with a user-database password, and use **Sign out** before switching accounts. Four sessions have a one-hour absolute lifetime, including active serial connections; logout closes only that session's serial access. Login is globally limited to five credential verifications per 60 seconds, with explicit capacity/backoff errors. Direct-IP and mDNS access use separate host-only Secure/HttpOnly/SameSite=Strict cookies. Non-browser clients also require cookies, strict Origin and CSRF for mutations rather than Basic credentials. There is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete.
The HTTPS interface uses a device-specific self-signed certificate and a same-origin login page with bounded server-side cookie sessions; HTTP Basic is no longer accepted. Open `/` or `/login`, sign in with a user-database password, and use **Sign out** before switching accounts. Four sessions have a one-hour absolute lifetime, including active serial connections; logout closes only that session's serial access. Login is globally limited to five credential verifications per 60 seconds, with explicit capacity/backoff errors. Direct-IP and mDNS access use separate host-only Secure/HttpOnly/SameSite=Strict cookies. Non-browser clients also require cookies, strict Origin and CSRF for mutations rather than Basic credentials. There is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. This firmware is not resistant to physical flash or RAM extraction; the completed reduced Phase 9 deliberately does not add that protection.
## License
@@ -98,4 +104,4 @@ This project is licensed under the [GNU General Public License version 3 only](L
Missing user storage is persisted as an empty database; no shared credential is imported or synchronized. Existing valid v1 user records retain their accounts, roles, IDs, verifiers and keys without a schema change. HTTPS `web_sec/material` upgrades valid 1,392-byte v1 storage to 1,340-byte TLS-only v2, retaining exact certificate/key DER, fingerprint and generation, and committing before publication. Invalid records or migration failures fail closed rather than triggering fallback replacement. `web certificate rotate --force` remains available; `web reset --force` replaces TLS identity only, not users.
**Downgrade warning:** older v1-only firmware cannot read v2 HTTPS material. Logical NVS replacement is not a secure flash wipe; historical plaintext credentials can remain in flash. This cleanup requires no factory/partition erase. See [implementation and evidence limits](docs/legacy_credential_removal.md); final integration build evidence is pending.
**Downgrade warning:** older v1-only firmware cannot read v2 HTTPS material. Logical NVS replacement is not a secure flash wipe; historical plaintext credentials can remain in flash. This cleanup requires no factory/partition erase. See [consolidated compatibility and evidence limits](docs/roadmap.md#phase8-legacy-credential-compatibility); Phase 8 acceptance is recorded there.
+9 -2
View File
@@ -104,7 +104,7 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li
### HTTPS, WebSocket, and web serial
`web_server` owns HTTPS on port 443 with a persisted self-signed P-256 identity. `web_serial_transport` mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 39. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails.
`web_server` owns HTTPS on port 443 with a persisted self-signed P-256 identity. `web_serial_transport` mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 40. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails.
Cookie login/logout replaces Basic/cache. Digest-only records carry copied principals, CSRF state, absolute expiry and nonreused originating-session IDs. Strict same-origin/CSRF mutations and session/principal checks gate admission; logout invalidates its session before transport cleanup, account mutations invalidate only the affected account, and ongoing currentness is authoritative. Authentication initialization failure gates HTTPS; failed start/accepted stop wipes records. RNG/SHA/database calls run outside short spinlocks with post-call epoch/identity revalidation. [Authentication contract](../web_administration.md#authentication-and-admission).
@@ -122,6 +122,12 @@ Cookie login/logout replaces Basic/cache. Digest-only records carry copied princ
`web_server_replace_identity` reserves service before identity and retains both through commit → reserved stop/start. Direct security and canonical CLI/browser-shell paths share task-bound nonreused identity reservations. Crypto/NVS run outside short security/service locks; commit precedes publication/wipe. Precommit failure leaves identity/HTTPD/logins unchanged; postcommit lifecycle failure never rolls back identity and can leave served/stored fingerprints different. Failed stop skips start and retains canonical recovery. Public service/security projections are separate observations, not authorization. [HTTPS ownership, generation and recovery contract](../web_administration.md#https-and-reboot).
### Admin firmware upload
`web_firmware_update` streams a raw admin-only application image through HTTPD into the inactive OTA slot using standard ESP-IDF APIs. Existing Origin/CSRF/session checks precede body IO; raw length is bounded before HTTPD's narrowed length is trusted, SDK image validation and final principal currentness precede boot selection. One 4KiB buffer and a transient reboot task bound application storage; NVS and partition layout are untouched. HTTPD is occupied during upload, so normal web traffic can stall; deadlines bound receive progress, not flash-operation latency.
The server transition and identity reservations exclude competing lifecycle work. A separate atomic gate excludes ordinary reboot paths without depending on HTTPS initialization, preserving UART0 recovery. Successful response schedules delayed reset while retaining reservations; failed response after selection retains the selected-image latch but releases resources for manual reboot. A subsequent upload is refused until reset. No automatic retry, rollback, signing infrastructure or dependency patching. [Contract and validation limits](../roadmap.md#phase-10--simple-admin-web-firmware-upload).
### SSH
Typed SSH settings use the existing ID dispatcher and original-login result slot, never HTTPD wolfSSH calls or owner waits. Conditional lifecycle/session controls compare a saturated service generation and exact nonreused session ID under canonical locks. `ssh_transport_replace_identity` reserves service then identity before stop, retaining the command mutex across stop → commit → conditional restart. Failed stop skips mutation/start; failed persistence may follow disconnection; committed identity is never rolled back after restart failure. Only the SSH owner frees context after all slots retire, and start rejects orphan handles. Direct security/CLI/deferred SSH callers share task-bound identity reservations; crypto/NVS run outside security locks. HTTPS remains available, so no self-cutting HTTP ACK gate is needed. [SSH contracts](../web_administration.md#ssh).
@@ -224,10 +230,11 @@ Hardware diagnostics are synchronous console commands. RS-232 tests own the phys
- Password authentication performs PBKDF2 outside the user-database mutex and revalidates afterward. Some password mutation paths currently derive verifiers while holding the mutation lock; do not generalize the authentication locking pattern without checking the exact path.
- Avoid holding service/database/broker locks across I2C, network sends, or other potentially long operations unless the existing contract explicitly requires it. Preserve the existing broker-before-serial lock order.
- Serial RX/TX stream payloads, broker per-client payloads, the transactional user-database candidate, and selected cryptographic allocations prefer PSRAM with internal fallback. The live user database, FreeRTOS control structures, UART driver buffers, and task stacks remain internal where deterministic/cache-disable access matters.
- Ping's 21-event payload (4,200 bytes) and the public user-console snapshot (2,156 bytes) are lazy PSRAM-only allocations retained for firmware lifetime, with no internal fallback. Queue control and synchronization stay internal; only ping or user status/list/show fails on allocation failure, never registration, mutations or UART0 recovery. Commands retain dispatcher/gate serialization; ping callbacks are task-context producers. The entire user snapshot is wiped after each display attempt. Never introduce ISR/cache-off access or free payloads while callbacks can retain them. Regression coverage: `tests/admin_console_boundary/psram_ping.py` and `accounts.py`.
- The build disables wolfSSL ESP32 AES/SHA acceleration, and the HTTPS path uses software AES for PSRAM-backed records. This preserves the validated workaround for uncoordinated mbedTLS/wolfSSL hardware-crypto locks and a prior mbedTLS external-RAM DMA watchdog stall.
## Legacy credential removal storage boundary
`user_database_init(load_result)` has no credential input. Missing storage is persisted empty; `user_database_recover_empty()` is the unavailable-only destructive recovery API. Valid v1 user bytes load without rewriting or account changes. The private `v1_admin_marker` retains its byte position and is derived from administrator count during mutations; it is not a public bootstrap state, new role or schema change. No user migration/bootstrap/synchronization API remains.
`web_security` owns TLS only. A private reader validates 1,392-byte v1 `web_sec/material`, copies exact key/certificate DER, fingerprint and generation into 1,340-byte v2, commits, then publishes. Temporary v1 credential-bearing input is wiped; no public legacy credential type/getter/rotation remains. Malformed/unknown records and read/validation/commit failures fail closed, with no fallback regeneration or overwrite of rejected records. Missing material may be generated; explicit reset replaces TLS only. Downgrade to v1-only firmware is incompatible. Logical NVS replacement is not secure flash erasure. Contracts/evidence: [legacy compatibility](../legacy_credential_removal.md).
`web_security` owns TLS only. A private reader validates 1,392-byte v1 `web_sec/material`, copies exact key/certificate DER, fingerprint and generation into 1,340-byte v2, commits, then publishes. Temporary v1 credential-bearing input is wiped; no public legacy credential type/getter/rotation remains. Malformed/unknown records and read/validation/commit failures fail closed, with no fallback regeneration or overwrite of rejected records. Missing material may be generated; explicit reset replaces TLS only. Downgrade to v1-only firmware is incompatible. Logical NVS replacement is not secure flash erasure. Contracts/evidence: [legacy compatibility](../roadmap.md#phase8-legacy-credential-compatibility).
+14 -2
View File
@@ -12,6 +12,12 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Dependencies: every subsystem initializer
- Lifecycle constraint: optional display/network failures should not remove UART0 administrative recovery or USB UART1 access; the custom administration frontend starts only after command registration.
## Small Phase 9 operating baseline
- `sdkconfig.defaults` explicitly selects no core dumps and silent panic reboot; existing generated configs can override defaults. README documents verification. No dependency overlays, custom crypto policy or version changes are part of this phase.
- User accepted the reduced Phase 9 on 2026-09-18 and waived a new hardware check; this is not a claim of a new hardware-test pass.
- [Operational checklist](../security_operations.md) and [scope/acceptance](../roadmap.md#phase-9--security-and-production-hardening): intermittent trusted-network use, unchanged authentication/recovery, no physical-extraction protection. Keep upgrades separate and do not restore the abandoned extensive patchset.
## Secure randomness
**Responsibility:** provide the sole project-owned, mutex-serialized application DRBG, seeded before Wi-Fi/radio use.
@@ -64,7 +70,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}`
- Ordinary HTTPS idle cleanup: `src/web_httpd_idle.{c,h}`, owner sweep in `web_httpd_adapter.{c,h}`, lifecycle/TLS composition in `web_server.c`; `tests/web_httpd_idle/run.py`. Independent of diagnostics/optional transports: 15-second observed idle, one-second timer/one queued probe, six rows, actual WS/async/pending exemptions, safe current-owner shutdown and stop/restart fencing. No LRU/socket/timeout/stack increase. SDK queue/owner-delay and regression contract: `docs/https_idle_cleanup.md`.
- Independent throughput diagnostics: `web_serial_transport.{c,h}` owns two fixed per-slot binary-TX aggregates and epoch fences; `web_console.c` exposes default-disabled `web performance enable|disable|show|clear`. Queue-entry/callback-entry, synchronous-send and completion/drain-return estimates, not peer receipt or scheduler-only latency. `tests/web_serial_performance/run.py`; resource/evidence limits and UART0 paired capture: `docs/web_throughput_diagnostics.md`.
- Storage compatibility: `user_database` persists missing storage empty and preserves valid v1 user bytes; private derived `v1_admin_marker`, no public bootstrap/migration/sync APIs. `web_security` privately migrates v1 1392-byte material to TLS-only v2 1340-byte material, exact identity/generation retained, commit before publish, fail closed without fallback overwrite. Credential commands removed; user generated passwords and TLS rotation remain. Contracts, downgrade and evidence limits: `docs/legacy_credential_removal.md`.
- Storage compatibility: `user_database` persists missing storage empty and preserves valid v1 user bytes; private derived `v1_admin_marker`, no public bootstrap/migration/sync APIs. `web_security` privately migrates v1 1392-byte material to TLS-only v2 1340-byte material, exact identity/generation retained, commit before publish, fail closed without fallback overwrite. Credential commands removed; user generated passwords and TLS rotation remain. Contracts, downgrade and evidence limits: [legacy compatibility](../roadmap.md#phase8-legacy-credential-compatibility).
- Security files: `src/web_security.{h,c}`, `src/web_cookie_auth.{h,c}`, `src/web_session_store.{h,c}`, `src/web_auth_parse.{h,c}`. Private IDF boundary: `src/web_httpd_adapter.{h,c}`.
- HTTP policy/UI: `web_cookie_auth` + `web_auth_parse` enforce bounded cookie/Origin/CSRF/admin admission; `web_login_ui.{c,h}` serves login, `web_ui.c` owns session-fenced Serial/Admin/Settings and shared quick controllers. Tests: `tests/web_cookie_auth/run.py` (domain variants), `tests/web_auth_parse/run.py`, `tests/web_login_ui/run.py`, `tests/web_ui_session/run.py`.
- Admission diagnostics: `web_diagnostics.{c,h}`, `tests/web_diagnostics/run.py`; six post-TLS records/32-event opt-in ring, no HTTPD off-owner inspection. [Contract](../web_admission_diagnostics.md).
@@ -82,10 +88,16 @@ This is a semantic map, not a complete file inventory. Start here, then read the
### Browser admin backend
- Files: `src/web_admin_transport.{c,h}`, `src/web_admin_tickets.{c,h}`, protected registration/lifecycle in `web_server.c`, revocation through `web_serial_transport_revoke_*`, diagnostics in `web_console.c`.
- Routes: CSRF-protected admin-only `POST /api/admin/ws-ticket`; ordinary `GET /ws/admin` with cookie/Origin/ticket/shared-console admission before explicit 101. Admin UI entry is explicit; no admin broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled; current overall capacity is 39 URI handlers.
- Routes: CSRF-protected admin-only `POST /api/admin/ws-ticket`; ordinary `GET /ws/admin` with cookie/Origin/ticket/shared-console admission before explicit 101. Admin UI entry is explicit; no admin broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled; current overall capacity is 40 URI handlers.
- Currentness/policy: `admin_ssh_console_open_available()` shares two slots with runtime SSH; transport-qualified tokens and owner adapters revalidate outside console locks before commands/prompts. Parsed browser policy remains narrower than typed Settings; [shell contract](../web_administration.md#browser-shell-policy). Tests: `tests/admin_console_boundary/{run,accounts,lifecycle}.py`, `tests/admin_ssh_policy/run.py`, `tests/web_admin_transport/run.py --tickets`, `tests/web_cookie_auth/run.py --admin`.
- Ownership: 20 ms ESP timer queues at most one HTTPD poll, no new task; HTTPD owns 1,552 B PSRAM-only payload and IO. Closure uses HTTPD-owned `shutdown`, not IDF's reusable-pointer queued close. Detach fences submitters; only successful HTTPD stop retires queued state before restart. Session/principal currentness and generation checks protect all sensitive boundaries.
## Firmware upload
- `src/web_firmware_update.{c,h}`: admin raw `POST /api/firmware`, cookie/Origin/CSRF admission via `web_cookie_auth`, registration in `web_server`, UI in `web_ui`. Standard SDK OTA into inactive app only; bounded4KiB buffer, validated raw length/header/final image and principal before boot selection. No NVS/layout writes or vendor patches.
- HTTPD handles streaming synchronously; upload is deliberately disruptive to web traffic. Server/identity reservations fence lifecycle changes; atomic ordinary-reboot gate also covers console/SSH/browser/button reset paths. Success-response schedules delayed reset; response failure after selection latches uploads until deliberate manual reboot. No blind retries.
- Tests: `tests/web_firmware_update/run.py` (active-build SDK5.5.0 headers and actual begin/abort contract), `tests/web_ui_session/run.py`, existing auth/lifecycle tests. [Usage, acceptance limits and reusable regression checks](../roadmap.md#phase-10--simple-admin-web-firmware-upload). Phase 10 complete by explicit user acceptance on 2026-09-18: upload works and normal operation verified; no specific fault/NVS-comparison/power-loss/recovery pass implied. Initial install by wire; subsequent upload is application `firmware.bin`, not full-flash image.
## Typed settings source and regression map
HTTPD reads zero-wait projections and queues only IDs to the existing dispatcher. One original-login slot per domain; canonical owners compare/reserve at execution. [API/lifetime and failure contracts](../web_administration.md#typed-settings-api-and-operation-lifetime).
+29 -19
View File
@@ -1,30 +1,40 @@
# Current project state
Working memory, not an implementation timeline. Source is authoritative; begin with [code map](code-map.md), then [architecture](architecture.md) and [decisions](design-decisions.md).
Working memory, not an implementation timeline. Source is authoritative; begin with [code map](code-map.md), [architecture](architecture.md) and [decisions](design-decisions.md).
## Accepted state — 2026-09-13
## Phase 10 COMPLETE — explicit user acceptance, 2026-09-18
- **8D.22 explicitly signed off by the user:** “Yep, I tested the firmware thats a 8d.22 signoff.” The retained Phase 8D scope is complete; earlier per-slice pending target/review/integration gates are superseded. Roadmap already records 8A/B/C as complete and target-hardware validated, so **Phase 8 is complete**. Acceptance does not manufacture individual unreported test passes.
- [Roadmap](../roadmap.md#phase-8--role-based-users-and-administrative-access--complete) holds the completed-phase gist; [web administration](../web_administration.md) holds current API/owner/recovery contracts; [acceptance evidence](../web_administration_acceptance.md) holds the latest report and limits. The old plan/baseline/per-slice histories are consolidated, not archived as another timeline. Test READMEs now link directly to current contracts and evidence; obsolete forwarding notes were removed too.
- Final prior production build **PASS: 100,556 B linked RAM / 1,828,573 B flash, CPU 160 MHz**. No build/test/device execution is implied by this documentation update. The prior combined binary WebSocket-send fix was separately user-accepted at **160 MHz / 230400 baud with full mix including browser admin**; preserve combined send and bounded failed-send isolation.
- Latest loaded capture: two serial WS, USB, two SSH roles with SSH serial writer; browser admin used then closed, not active in the capture. Internal/DMA/PSRAM free **31,508 / 23,752 / 8,136,624 B**, minima **2,052 / 460 / 8,065,972 B**, largest **18,432 / 18,432 / 7,995,392 B**; SSH minimum-free stack **15,028 B**. Full boot/loaded table is in acceptance evidence. Web send/queue/protocol and SSH IO errors zero; one SSH handshake failure/session revocation. Missing latest broker/serial counters prohibit an exact zero-drop inference.
- User confirmed after firmware upload implementation and the concise-UI fix: “That works perfectly. And the usual operation is also verified.” Acceptance establishes that upload works and normal operation is verified. Do not infer specific fault-injection, NVS before/after comparisons, power-loss or recovery passes. The roadmap's compact regression guidance is reusable, not an acceptance blocker.
- Latest concise-UI change was copy-only; reported UI regression **169 groups PASS**. No rebuild after that text change. The integration build below is historical, not validation of a newly rebuilt UI or this documentation update.
## Follow-ups, not acceptance blockers
- User authorized simple OTA implementation; initial Git clean. New web_firmware_update module with standard SDK APIs, raw POST /api/firmware, admin cookie/Origin/CSRF and final principal currentness. Settings → HTTPS / Reboot has File/XHR upload progress, confirmation, session fencing and no automatic retries. Only inactive app/otadata writes; NVS/layout untouched. No signatures/antirollback/automaticrollback, dependencies or generated assets changed. Consolidated procedure, contract and regression guidance: [Phase 10](../roadmap.md#phase-10--simple-admin-web-firmware-upload); standalone guide removed.
- 4KiB internal buffer + transient2048B-stack reboot owner allocated before erase; 10s stall/120s receive-loop budget, not totalflashdeadline. HTTPD synchronously blocks other web work during upload; networkserial maystall/drop, reboot disruptsall. No task/request/socket lifetime capture after handler. Service/identity reservation and atomic ordinary-reboot gate cover UI/UART0/SSH/browser/localbutton paths. Failed response after bootselect schedules noautomaticreset; selected latch rejects further uploads409, manual reboot available. Successful response schedules500ms reboot retaining reservations.
- Review fixed two actualSDK5.5.0 edge cases: failed esp_ota_begin maypublishlivehandle beforeeraseerror (abortthat handle); rawContentLength64 canwrapHTTPDsize_t32 (overflow-safe actualslotbound/strictdecimal/equality check beforebody/erase). End consumes handle evenerror. SDKvalidation followed by exactparsedimage length includingSHA; basic header requiresS3appdescriptor/hash. Unrelated old/privateSDK code unpatched.
- Parent final pio PASS **94,220 B RAM / 1,847,645 B flash**, +24RAM/+19,080flash vsPhase9, not runtimeheadroom. Parent newbackend88cases+actualSDKbeginfailurecontract, UI169groups+CSP, serverlifecycle44, admin25, consolelifecycle, SSHruntime, cookielifecyclePASS. Additionalbase/admin/display/lifecyclecookie, idle18, SSHmanagement/runtime/security agentPASS after adding missing rebootfake to adminfixture (no productionchange). Independent review final noactionablefindings; realbuilt firmware parsed with SDKmetadata bothOTAoffsets (notdeviceflashproof).
- **Scope decision (2026-09-18):** User removed the BLE transport/provisioning proposal entirely because it no longer fits the project concept. BLE is not planned; retain the existing USB, HTTPS/WebSocket and SSH transport scope. Remaining roadmap candidates are under evaluation, not authorized implementation work. Do not resurrect Phase 9 patches. This update changed documentation only; no build, test, upload, erase, device operation or commit was performed.
- Extremely low internal/DMA lifetime minima warrant correlated transient-headroom investigation; overlapping capability pools and conservative/non-simultaneous region minima do not prove allocation failure. Numeric reserves, HTTPD/dispatcher stack margins, peak correlation and detailed soak/fault evidence remain unapproved/unreported. Do not reopen functional sign-off or invent a reserve threshold.
- SDK TLS `-0x004C` is generic NET_RECV_FAILED, not OOM. Two boot auth failures plausibly involve stale cookies, but causation is unconfirmed. Prior intermittent web admission issues and accepted idle cleanup do not justify claiming every admission failure fixed.
- Real DNS/reannouncement, NVS power-loss, browser geometry/accessibility, individual fault cases and exact duration claims require explicit evidence if investigated; retained [regression procedures](../user_administration_tests.md) are not execution records.
- **Next: Phase 9 security/production hardening only if requested.** No source, configuration, test implementation, build, upload, erase, branch or commit is authorized by documentation consolidation.
## Session logging scope decision
## Scope and safety to retain
- User removed the filesystem-backed session recording proposal entirely. On-device session logging/transcripts are out of scope: users capture relevant output in their SSH or web terminal instead. Avoid adding RAM pressure, flash-write interference or potentially incomplete recordings to the serial path. The reserved `storage` partition remains unchanged and unmounted; this decision does not authorize partition migration.
- 8D.15 dedicated typed network diagnostics was removed; shell diagnostics remain subject to frontend policy. Unimplemented 8D.19 ordinary browser-session/native-USB controls were removed; SSH settings remain. No implicit full browser-shell parity or browser identity reset/recovery/export.
- One UART1 broker writer, isolated observers and binary transparency. UART0 is administrative recovery; native USB is network-independent UART1, not administration or uninterrupted reboot.
- Typed operations carry original-login IDs to the existing dispatcher. Owner-reserved generations fence stale/ABA changes; later revocation/timeout does not cancel admitted work. HTTPS commits before stop/restart; SSH stops before commit/restart; committed identity never rolls back on lifecycle failure. Lost ACK/result means uncertainty, never automatic replay.
- Preserve private IDF HTTPD version guards, at-most-one owner-work reservations through failed destruction, retained SSH context until all slots retire, bounded queues/buffers and secret-free metadata. Canonical recovery survives conditional-token exhaustion.
## Accepted state — 2026-09-18
## Documentation handoff
- **Reduced Phase 9 complete by explicit user sign-off.** User waived a new whole-phase device check based on prior Phase 8 validation. Application code was unchanged, but no-core-dump/silent-panic defaults changed. Do not record the waived check as executed or claim a new panic/hardware pass.
- Small scope: standard `sdkconfig.defaults` options, source-reviewed [operational checklist](../security_operations.md), README/roadmap guidance. No dependency patches, crypto policy replacement, allocator hooks, SDK migration, encryption, eFuse or partition changes. User abandoned the extensive earlier Phase 9 and restored baseline `f40c09c`; do not resurrect it.
- Historical Phase 9 build: normal `pio run` PASS on PlatformIO6.12.0 / IDF5.5.0 / original20241119 toolchains, **94,196 B linked RAM / 1,828,565 B flash**. Generated configuration confirmed no dumps/silent panic; compilation inputs had no abandoned overlays/crypto guard. Existing generated config already selected these options. Defaults do not override saved sdkconfig; README explains verification. No new build is implied by this documentation consolidation.
- **Phase 8 complete:** 8AC target validated; explicit 8D.22 user acceptance on 2026-09-13. Completion/telemetry and legacy compatibility now live in [roadmap acceptance](../roadmap.md#phase8-acceptance-evidence) and [storage compatibility](../roadmap.md#phase8-legacy-credential-compatibility). Separate acceptance/legacy history documents removed; active [web contracts](../web_administration.md) and [regression procedures](../user_administration_tests.md) retained.
- Post-acceptance baseline includes PSRAM-only ping payload/user snapshot allocations and refined web quick panels. Prior focused host/geometry/build checks passed; those are not additional hardware claims. Preserve lazy-allocation failure isolation and unchanged serial hot path.
Initial Git status was clean. This task changes root `README.md`, `docs/` and five test-directory READMEs; executable source/tests/config/generated assets remain untouched. Independent documentation review checked acceptance scope, owner contracts and local links. It restored explicit pointer-backed HTTPD response-header lifetime and same-version SDK-patch audit warnings, updated test README links, and removed obsolete forwarding notes without reopening sign-off.
## Evidence limits and follow-ups
Validation completed: independent Python local-link/anchor audit PASS across 29 authored Markdown files (122 local links, 53 Markdown fragments); 213 authored source/test/document files checked with zero obsolete Phase 8D filename references. Earlier path audit resolved127 expanded navigation references. GPT logs, vendored/generated trees and remote URL fetching excluded. Twenty obsolete phase documents removed, with no forwarding stubs/archive dump. Git diff/scope checks confirm documentation-only changes. No firmware tests/build/device commands run.
- Previously accepted combined binary WS send: CPU160MHz / 230400 baud full mix including browser admin. Latest recorded telemetry has very low internal/DMA lifetime minima (2,052/460 B); these are nonblocking headroom follow-ups, not approved reserves or proof of simultaneous allocation failure. Full table, capture workload and counter limits are preserved in the roadmap.
- TLS `-0x004C` means generic NET_RECV_FAILED, not OOM. Historical authentication/admission symptoms do not establish a cause. Do not invent fault, soak, timing or power-loss passes.
- Credentials remain unencrypted; old flash contents are not erased. Intermittent trusted-network operation reduces exposure, not physical-extraction risk. Upstream upgrades are separate deliberate tasks, not an endless local backport programme.
- Phase 10 is complete by the explicit acceptance above; detailed unreported regression scenarios remain unevidenced, not completion blockers. Device operations, branch/reset, commits and dependency upgrades remain outside this documentation task.
## Contracts to preserve
- One UART1 writer, isolated observers and binary transparency. UART0 is administrative recovery; native USB is network-independent UART1, not administration or uninterrupted reboot.
- Typed operations carry original-login IDs to the existing dispatcher. Owner-reserved generations fence stale/ABA changes; revocation/timeout does not cancel already admitted work. HTTPS commits before stop/restart; SSH stops before commit/restart; committed identity is not rolled back on lifecycle failure. Lost acknowledgement means uncertainty, not automatic replay.
- Preserve private IDF HTTPD version guards, one outstanding owner-work reservation through failed destruction, SSH context lifetime until slots retire, bounded queues/buffers and secret-free metadata. Canonical recovery survives conditional-token exhaustion.
- Removed Phase 8 scope stays removed: dedicated typed network diagnostics and ordinary browser-session/USB controls. No implicit browser-shell parity or browser identity reset/recovery/export.
+1 -1
View File
@@ -128,7 +128,7 @@ Only constraints supported by implementation or current project documentation be
**Rationale/evidence:** Serial, Wi-Fi, local UI, web security, users, and SSH security each validate schema/size and own their namespace. User/security mutations build and validate candidate state before committing it; security modules avoid silently replacing an established identity. The live user database remains internal while its 5,360-byte candidate is a persistent PSRAM-preferred allocation with internal fallback and is wiped after every transaction.
**Consequence for future changes:** Add schema versions and transactional candidate validation. Do not overwrite unknown records automatically; provide explicit migration/reset behavior. Preserve the distinct persistence contracts: explicit save/load/default/reset for working configuration and per-blob commit-before-live-install for user and identity mutation. Keep candidate ownership mutex-local and wipe/free it on initialization or recovery failure. Recheck external-buffer staging in the flash/NVS implementation when upgrading from the pinned ESP-IDF 5.5 baseline. Legacy credential synchronization and reconciliation are removed. Missing user storage commits empty; valid user v1 bytes remain compatible, with private `v1_admin_marker` derived from admin count, not a public bootstrap contract. HTTPS v1 (1,392 bytes) migrates through a private validated reader to TLS-only v2 (1,340 bytes), preserving exact DER/fingerprint/generation and committing before publication. Failures fail closed without fallback regeneration or overwriting rejected records. See [legacy compatibility](../legacy_credential_removal.md).
**Consequence for future changes:** Add schema versions and transactional candidate validation. Do not overwrite unknown records automatically; provide explicit migration/reset behavior. Preserve the distinct persistence contracts: explicit save/load/default/reset for working configuration and per-blob commit-before-live-install for user and identity mutation. Keep candidate ownership mutex-local and wipe/free it on initialization or recovery failure. Recheck external-buffer staging in the flash/NVS implementation when upgrading from the pinned ESP-IDF 5.5 baseline. Legacy credential synchronization and reconciliation are removed. Missing user storage commits empty; valid user v1 bytes remain compatible, with private `v1_admin_marker` derived from admin count, not a public bootstrap contract. HTTPS v1 (1,392 bytes) migrates through a private validated reader to TLS-only v2 (1,340 bytes), preserving exact DER/fingerprint/generation and committing before publication. Failures fail closed without fallback regeneration or overwriting rejected records. See [legacy compatibility](../roadmap.md#phase8-legacy-credential-compatibility).
**Relevant files:** `src/serial_config.c`, `src/wifi_config.c`, `src/mdns_config.c`, `src/mdns_service.c`, `src/local_ui_config.c`, `src/web_security.c`, `src/user_database.c`, `src/ssh_security.c`
+4 -2
View File
@@ -11,9 +11,11 @@ Browser `web` allows only status/stop/exact forced certificate rotation; `wifi`/
| Command | Description |
|---|---|
| `memory` | Show free memory, minimum free memory, and largest blocks for internal RAM, DMA-capable RAM, and PSRAM. |
| `reboot` | Drain console output briefly and restart the ESP32. |
| `reboot` | Drain console output briefly and restart the ESP32; refused while a firmware upload or another reboot owns exclusion. |
| `exit` | Close the current administrative SSH or browser session after its acknowledgement drains; unavailable on UART0. Browser `exit` leaves serial connected. Ctrl+D on an empty administrative command line does the same. |
Firmware upload is an admin **Settings → HTTPS / Reboot** action, not a shell command. Ordinary UART0/admin-SSH/browser-shell/local-display and typed reboot paths are gated against an active upload. If upload boot selection succeeded but its response failed, no automatic restart is scheduled: the selected image remains, another admissible upload returns 409 until reset, and a deliberate manual `reboot` is permitted after reservations release. Inspect first; a lost response is not cancellation. See [firmware update and wired recovery](roadmap.md#phase10-update-and-recovery). Do not erase for an ordinary update; native USB is UART1 access, not recovery administration.
## Role-based users
| Command | Description |
@@ -189,4 +191,4 @@ UART0 and admin SSH use shared whole-line Tab completion. A unique/common prefix
Follow the exact wiring in [Electrical tests](electrical_tests.md) before invoking diagnostics. The OLED must be powered from 3.3 V because module I²C pull-ups may connect to `VCC`; verify that all external pull-ups also terminate at 3.3 V. Display diagnostics probe the standard SSD1315-compatible 7-bit `0x3c`/`0x3d` addresses. The currently tested module acknowledges at `0x3c`, whose 8-bit write/read forms are `0x78`/`0x79`; an explicit `scan --force` is available only for the dedicated local-UI bus. Diagnostics initially run at 100 kHz and treat an absent display as nonfatal. RS-232 diagnostics that require UART1 refuse to use it until `serial stop` releases it. The RGB LED shows test state: blue idle, yellow/orange running, green passed, red failed.
HTTPS storage migration preserves the exact TLS identity and commits TLS-only v2 before publication. Older v1-only firmware cannot read v2. Logical NVS replacement is not secure flash erasure; no factory erase is required. See [legacy removal](legacy_credential_removal.md).
HTTPS storage migration preserves the exact TLS identity and commits TLS-only v2 before publication. Older v1-only firmware cannot read v2. Logical NVS replacement is not secure flash erasure; no factory erase is required. See [legacy removal](roadmap.md#phase8-legacy-credential-compatibility).
+2 -2
View File
@@ -1,6 +1,6 @@
# Bounded ordinary HTTPS idle retention
Current owner/SDK contract. The user accepted idle cleanup as working; [overall acceptance](web_administration_acceptance.md) records current status and evidence limits. No individual unreported soak, fault or reserve result is inferred.
Current owner/SDK contract. The user accepted idle cleanup as working; [overall acceptance](roadmap.md#phase8-acceptance-evidence) records current status and evidence limits. No individual unreported soak, fault or reserve result is inferred.
## Evidence and scope
@@ -53,7 +53,7 @@ Detach first prevents submissions, then waits at most one second for any submitt
`tests/web_httpd_idle/run.py` exercises production lifecycle/sweep paths, installed SDK request cleanup, host socketpair/readiness/shutdown/fd reuse and deterministic TLS/timer/queue doubles. `tests/web_admin_transport/server_lifecycle.py` checks server composition. Historical host validation passed; these references do not claim a new run or real target scheduler/TLS timing.
Bounded storage: six observation rows (144 bytes), one persistent timer (installed non-profiled layout 32 bytes before allocator overhead), lifecycle/generation flags and one reserved probe. SDK control UDP/mailbox allocation is transient and separate. No per-connection payload/task/stack/socket growth. Actual HTTPD/timer margins and runtime overhead/reserve floors require target measurement, not host sizes. See [latest firmware resources](web_administration_acceptance.md#latest-firmware-evidence).
Bounded storage: six observation rows (144 bytes), one persistent timer (installed non-profiled layout 32 bytes before allocator overhead), lifecycle/generation flags and one reserved probe. SDK control UDP/mailbox allocation is transient and separate. No per-connection payload/task/stack/socket growth. Actual HTTPD/timer margins and runtime overhead/reserve floors require target measurement, not host sizes. See [latest firmware resources](roadmap.md#phase8-acceptance-evidence).
## Regression procedure — not execution evidence
-29
View File
@@ -1,29 +0,0 @@
# Legacy credential removal and storage compatibility
Current storage/recovery contract. The user accepted cleanup on 2026-09-08, confirming the certificate fingerprint was unchanged and preexisting test users remained usable. [Overall acceptance and evidence limits](web_administration_acceptance.md) supersede the old per-slice handoff; no unreported provisioning, recovery, power-loss or all-key tests are implied.
## User database
- `user_database_init(load_result)` takes no legacy credential. Missing `user_db/database` storage is committed as an empty v1 database; no account/password is imported.
- Create the first administrator on physical UART0 with `user add <username> admin`, optionally `--generate`. Entered passwords use hidden confirmation; generated passwords are shown once. There is no reserved bootstrap account or public bootstrap state.
- `user recover --force` is UART0-only, calls `user_database_recover_empty()`, and destructively rebuilds only an unavailable database empty. It refuses a healthy initialized database, including an empty one. Follow with normal `user add`; unrelated configuration and TLS/SSH identities are untouched.
- Valid existing v1 database bytes load without rewriting or changing accounts, roles, IDs, authentication generations, verifiers or authorized keys. Previously migrated role-`user` accounts are not automatically promoted. The private `v1_admin_marker` preserves the old wire byte and is derived from administrator count during mutations; it is not a new role, public bootstrap field or schema change.
- No public bootstrap, legacy user migration or synchronization API remains. Final-admin protection, conditional mutations, copied principal currentness and target-only revocation remain the canonical account contracts.
## HTTPS identity storage
`web_security` owns only TLS material. At the unchanged `web_sec/material` key, a private byte-oriented reader validates the shipped **1,392-byte v1** layout and builds **1,340-byte TLS-only v2**. It retains the **exact private-key DER, certificate DER, SHA-256 fingerprint and material generation**: this migration is not certificate rotation.
The v2 candidate is validated and committed before live publication. Temporary credential-bearing v1 input is wiped. No public legacy credential reader/type, display, rotation or authentication path remains. Unknown/malformed material, read failures, invalid cryptographic identity and migration write/commit failures fail closed; they do not trigger fallback regeneration or overwrite of rejected material. Truly missing material may be generated and saved normally.
`web credentials show`, `web credentials rotate --force` and `user bootstrap` (including its generated form) are removed. `web certificate info` and `web certificate rotate --force` remain subject to existing frontend policy. `web reset --force` explicitly replaces TLS certificate/private key only; it does not reset passwords, import accounts or synchronize verifiers. TLS replacement/restart can close browser sessions and requires renewed certificate trust/login, without changing user credentials or revoking unrelated SSH sessions.
## Compatibility and physical-security limits
**Older v1-only firmware cannot read v2 HTTPS storage.** Do not assume a downgrade preserves usable HTTPS or restores removed credentials. Existing user database v1 compatibility is separate from this HTTPS downgrade incompatibility.
Replacing an NVS blob logically removes credential fields from the current record; it is **not secure flash wiping**. Append-oriented historical copies may retain plaintext legacy credentials, and current Wi-Fi secrets/TLS/SSH private keys remain unencrypted. PBKDF2 verifiers remain subject to offline guessing. No factory/partition erase is required or authorized by this cleanup.
## Regression references
Use `tests/web_security/run.py`, `tests/admin_console_boundary/accounts.py`, `tests/admin_ssh_policy/run.py` and [current legacy-removal procedures](user_administration_tests.md#current-legacy-removal-regression-procedure). Prior host tests used NVS fault doubles and a generated identity wrapped in the historical TLS layout, not an independently captured old-device fixture; they do not prove flash durability or power-loss behavior. Procedures are not claims of execution.
+90 -68
View File
@@ -1,10 +1,10 @@
# Implementation roadmap
This document tracks the implementation and hardware-validation plan for the ESP32 Serial Swiss Army Knife. It describes intended sequencing rather than a release schedule. A phase is complete only when its implementation, documentation, build validation, and relevant hardware tests have passed.
This document tracks the implementation and hardware-validation plan for the ESP32 Serial Swiss Army Knife. It describes intended sequencing rather than a release schedule. Completion requires implementation, documentation and build validation, plus relevant hardware tests or an explicitly recorded user waiver. A waiver is not a hardware-test pass.
## Status legend
- **Complete** — implemented and validated on the target hardware.
- **Complete** — implemented and accepted, with target-hardware validation or an explicit phase-specific user waiver recorded below.
- **In progress** — implementation or validation is actively underway, but the overall phase is not complete.
- **Implemented; validation pending** — code is present and builds, but the current implementation still needs the listed hardware checks.
- **Planned** — accepted project direction, not yet implemented.
@@ -38,12 +38,12 @@ These constraints apply across all phases:
| 5B | Offline xterm.js WebSocket serial terminal | **Complete** |
| 6 | Authenticated SSH serial transport | **Complete** |
| 7 | Local display and button interface | **Complete** |
| 8 | Role-based users and administrative access | **In progress (8A8C complete; integrated web administration 8D planned)** |
| 9 | Security and production hardening | **Planned** |
| 10 | Authenticated, rollback-capable OTA | **Planned** |
| 11 | BLE serial transport and provisioning evaluation | **Planned** |
| 8 | Role-based users and administrative access | **Complete** |
| 9 | Small intermittent-use security baseline | **Complete (user signoff 2026-09-18; new hardware check waived)** |
| 10 | Simple admin web firmware upload | **Complete (explicit user acceptance 2026-09-18; upload and normal operation verified)** |
| 12 | Advanced network integration | **Under evaluation** |
| 13 | Optional filesystem-backed features | **Under evaluation** |
## Completed phases
@@ -200,93 +200,115 @@ Completed implementation (8A8C target-hardware validated; 8D explicitly accep
- Typed Serial, Accounts/password/authorized-key, Network/Wi-Fi/mDNS, Display, Broker and SSH settings; confirmed HTTPS stop/restart/reboot and HTTPS/SSH identity rotation. Canonical owners compare reserved service/identity/target generations, preserve commit/failure semantics and expose no stored secrets. Admitted work is not cancelled by later logout/timeout; uncertain mutations are never automatically replayed.
- Accessible Serial/Wi-Fi/client/writer contextual controls reuse existing controllers. Writer transfer is explicit and generation-safe; one writer, isolated observers, binary transparency, UART0 administrative recovery and network-independent native USB UART1 remain invariants.
- Scope removed by user: **8D.15** dedicated typed network diagnostics (shell diagnostics retained) and the unimplemented **8D.19** ordinary browser-session/native-USB controls (SSH controls retained). Browser identity reset/recovery/export is excluded; canonical recovery remains available.
- Prior final build PASS: **100,556 B linked RAM / 1,828,573 B flash, CPU 160 MHz**. Earlier combined binary WebSocket-send fix was explicitly accepted at **160 MHz / 230400 baud with full client mix including browser admin**. Latest overall sign-off does not invent individual fault/soak/duration results or a latest exact zero-drop comparison.
- Latest loaded internal/DMA minima **2,052 / 460 B** remain a conservative transient-headroom follow-up, not a blocker reopening acceptance or an approved reserve. See [acceptance and telemetry](web_administration_acceptance.md), [current contracts](web_administration.md), [regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) and [legacy compatibility](legacy_credential_removal.md).
- Acceptance evidence and legacy storage compatibility are condensed below. [Web administration](web_administration.md) retains current API/owner/recovery contracts; [regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) retain practical checks, not claims of execution.
## Current and planned phases
#### Phase8 acceptance evidence
Phase 8 is complete for its accepted scope. Phase 9 is next **only on a separate user request**; later work remains planned or under evaluation. Optional features must not weaken completed serial and recovery paths. General release gates below remain guidance for future work, not claims that every listed fault, soak or reserve measurement was individually performed for 8D.
**8D.22 signoff, 2026-09-13:** the user explicitly accepted the tested firmware, superseding earlier per-slice target, review and integration gates for the retained scope above. Earlier scoped acceptance covered browser login/logout, shared administration, Serial/account/Network presentation and legacy cleanup. Prior host regression suites and independent reviews were reported passing; this consolidation asserts no new build or test execution.
Prior final Phase 8 build: **PASS, 100,556 B linked RAM / 1,828,573 B flash, CPU 160 MHz**. The combined binary WebSocket-send fix was separately accepted at **160 MHz / 230400 baud with the full client mix including browser admin**; retain combined send and bounded failed-send isolation, not the earlier frequency-only experiment. Ordinary HTTPS idle cleanup was accepted, not every possible admission failure or owner delay.
Latest reported Phase 8 telemetry (bytes):
| Sample / capability | Free | Minimum free | Largest block |
|---|---:|---:|---:|
| Boot internal 8-bit | 59,808 | 58,840 | 31,744 |
| Boot internal DMA | 52,052 | 51,084 | 31,744 |
| Boot PSRAM | 8,196,968 | 8,183,972 | 8,126,464 |
| Loaded after burst, internal 8-bit | 31,508 | 2,052 | 18,432 |
| Loaded after burst, internal DMA | 23,752 | 460 | 18,432 |
| Loaded after burst, PSRAM | 8,136,624 | 8,065,972 | 7,995,392 |
Loaded SSH minimum-free stack: **15,028 B**. This sample had two SSH sessions (serial/admin), two serial WebSockets and USB, with SSH as writer. Browser admin had been used then closed; it was **not active in this capture**. Web send/queue/protocol and SSH IO errors were zero; one SSH handshake failure and one session revocation were reported without an established cause.
**Limits, not reopened acceptance gates:** missing latest broker/serial counters prevent an exact zero-drop or byte-integrity inference. No latest browser-admin-active full-mix, individual fault-injection, exact duration, soak, cleanup-cycle or reserve-floor result is inferred. Internal/DMA lifetime minima remain a nonblocking transient-headroom follow-up, not approved reserves or proof of allocation failure: capability pools overlap and per-region minima may be non-simultaneous, so do not sum them. HTTPD/dispatcher stack margins, correlated peaks and allocation-reserve policy remain unmeasured/unapproved here. SDK TLS `-0x004C` means NET_RECV_FAILED, not an OOM diagnosis; stale cookies as the cause of two boot authentication failures remain unconfirmed.
#### Phase8 legacy credential compatibility
Cleanup was accepted **2026-09-08**, with unchanged certificate fingerprint and usable preexisting test users. This is not an unreported provisioning, recovery, power-loss or all-key pass.
- **Users:** missing `user_db/database` is committed as an empty v1 database, with no legacy account/password import. First-admin provisioning is physical UART0 `user add <username> admin` (optional `--generate`); entered passwords use hidden confirmation and generated passwords appear once. UART0-only `user recover --force` rebuilds only an unavailable database empty, refuses healthy databases (including empty ones), and leaves other configuration and TLS/SSH identities untouched; follow with normal `user add`.
- Valid existing v1 user bytes load without rewrite or changes to accounts, roles, IDs, authentication generations, verifiers or authorized keys; migrated `user` accounts are not promoted. Private `v1_admin_marker` preserves the old wire byte and is derived from admin count on mutation, not a new role/schema/public bootstrap state. Public bootstrap and legacy migration/synchronization APIs are removed; final-admin protection, conditional mutations, principal currentness and targeted revocation remain.
- **TLS:** at unchanged `web_sec/material`, a private reader validates the **1,392-byte v1** layout and migrates to **1,340-byte TLS-only v2**, preserving exact private-key DER, certificate DER, SHA-256 fingerprint and material generation—not rotating identity. The candidate is validated and committed before publication; temporary credential-bearing input is wiped. Malformed/unknown material, read/crypto failures and migration write/commit failures fail closed without regeneration or overwrite; truly missing material may be generated normally.
- `web credentials show`, `web credentials rotate --force` and `user bootstrap` are removed. `web certificate info` / `web certificate rotate --force` retain frontend policy. `web reset --force` replaces TLS identity only, not accounts/passwords/verifiers; replacement/restart may close browser sessions and require renewed certificate trust/login, without revoking unrelated SSH sessions. No public legacy credential reader, display, rotation or authentication path remains.
- **Downgrade/security:** older v1-only firmware cannot read v2 HTTPS storage; user-database v1 compatibility does not make HTTPS downgrade safe or restore removed credentials. Logical NVS replacement is **not secure flash wiping**: historical plaintext legacy credentials may remain, Wi-Fi secrets and TLS/SSH private keys remain unencrypted, and PBKDF2 verifiers permit offline guessing. No erase is required or authorized by cleanup.
- Prior host tests used NVS fault doubles and a generated identity wrapped in the historical layout, **not an independently captured old-device fixture**; they do not prove flash durability or power-loss behavior. Focused runners: `tests/web_security/run.py`, `tests/admin_console_boundary/accounts.py`, `tests/admin_ssh_policy/run.py`. See [legacy-removal regressions](user_administration_tests.md#current-legacy-removal-regression-procedure).
### Phase 9 — Security and production hardening
Harden stored credentials, boot integrity, update trust, failure diagnostics, and dependency maintenance. This phase must be staged carefully because some ESP32 eFuse operations are irreversible.
**Complete — user signoff 2026-09-18; new hardware check explicitly waived.** Reduced scope: an intermittently powered wireless serial cable on a trusted network, not a permanently exposed Internet service. The earlier extensive Phase 9 implementation was abandoned in favor of the accepted pre-Phase-9 baseline. Short exposure reduces risk; it does not make upstream defects harmless or certify this firmware secure.
Planned work:
Small implementation:
- Define development, test, and production security profiles.
- Enable and validate NVS encryption for Wi-Fi, HTTPS, and SSH material.
- Evaluate flash encryption and PSRAM encryption for the target hardware and threat model.
- Enable secure boot with a documented signing-key and recovery process.
- Define secret-safe core-dump handling; do not expose credentials or session material in diagnostics.
- Audit key and session-material zeroization across application, mbedTLS, wolfSSL, and wolfSSH allocations.
- Add authentication throttling, lockout/backoff, and security-event counters where appropriate.
- Review TLS/SSH algorithms, certificate policy, password policy, and browser security headers.
- Review current wolfSSL/wolfSSH and ESP-IDF security releases instead of treating integration versions as permanent pins.
- Recheck all third-party licenses and preserve source/license notices.
- Document provisioning, key rotation, factory reset, backup, recovery, and device decommissioning.
- Keep the baseline PlatformIO/ESP-IDF and managed-library versions and upstream sources. No generated dependency patches, custom SSH state machine, crypto-policy replacement, allocator hooks, or SDK migration in this phase.
- Explicitly disable core dumps and select silent panic reboot through standard `sdkconfig.defaults` options. Keep ordinary logging; reduced crash diagnostics are a deliberate trade-off. Existing generated configurations must be checked because defaults do not override saved selections.
- Follow the [operational checklist](security_operations.md): unique credentials, trusted identity verification, no Internet forwarding, networking off/power off after use, and existing rotation/recovery procedures.
- Review upstream maintenance releases occasionally and before planned upgrades. Evaluate reachable issues against actual use; any stock dependency upgrade is a separate build/device-tested task, not a reason to accumulate local backports. Preserve existing license notices.
Security features must be validated first on expendable hardware before any irreversible production configuration is recommended.
**Excluded:** secure boot, NVS/flash/PSRAM encryption, eFuse changes, partition changes, exhaustive CVE/zeroization audits, new authentication throttling, and production certification. Existing authentication, session isolation and recovery behavior remain unchanged. There is no protection against physical extraction or replacement of firmware. Phase 10 uses administrator-selected firmware, without a separate image-signing system.
### Phase 10 — Authenticated OTA and rollback
**Host/build evidence (2026-09-18):** normal `pio run` passed on PlatformIO 6.12.0 / ESP-IDF 5.5.0 with the original 20241119 toolchains: **94,196 B linked RAM / 1,828,565 B flash**. Generated configuration confirms no dumps and silent panic reboot; compilation inputs contain none of the abandoned Phase 9 source overlays or wolf crypto guards. No device operation or target validation was performed.
Use the existing dual 4 MiB application slots and `otadata` partition to add safe firmware updates.
**Acceptance (2026-09-18):** the user signed off this small Phase 9 and explicitly waived a new whole-phase hardware check of boot, UART0 recovery, native USB serial, HTTPS/SSH login and normal serial sharing. Application code is unchanged, **but crash-diagnostic defaults changed**; this is a waiver, not evidence of target or panic-path validation. The prior build/configuration evidence above remains the validation record; no new hardware test was performed. A separately controlled panic check with synthetic secrets remains optional, not a completion gate; do not add a production panic endpoint or erase flash. Disabling dumps does not erase old contents.
Planned work:
### Phase 10 — Simple admin web firmware upload
- Accept only authenticated, integrity-checked firmware images compatible with the selected secure-boot policy.
- Support an update path that does not expose a plaintext management endpoint.
- Stream downloads with bounded RAM use and explicit progress/error reporting.
- Preserve serial, Wi-Fi, HTTPS, and SSH configuration across successful updates.
- Mark new firmware pending until a post-boot health check succeeds.
- Roll back automatically after failed boot or health confirmation.
- Define power-loss behavior for every update stage.
- Keep UART0 recovery and wired re-flashing documented and functional.
- Add version/compatibility checks and reject accidental downgrade unless an explicit policy allows it.
**Complete — explicit user acceptance on 2026-09-18: firmware upload works and normal operation is verified.** The admin **Settings → HTTPS / Reboot** firmware card uploads the locally built `.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin`. Install this OTA-enabled firmware by wire first, then use web application uploads. There is no release server, automatic download or update service. [Firmware update and recovery](#phase10-update-and-recovery) below covers operation; [regression guidance](#phase10-regression-guidance) covers future checks.
OTA is complete only after successful update, interrupted-update, invalid-image, rollback, and configuration-persistence tests on hardware.
Implemented scope:
### Phase 11 — BLE
- Raw `POST /api/firmware` requires exact `Content-Type: application/octet-stream`, known exact `Content-Length`, cookie, same-origin Origin and `X-CSRF-Token`; no multipart/JSON, Basic authentication or ordinary `user` access. Reject queries, ambiguous headers, chunked transfer and `Expect`; UI code leaves browser-managed headers alone. Admission precedes body/flash work; session/principal currentness is rechecked before boot selection. The card confirms upload/reboot and shows progress/results without automatic retries. HTTPD capacity is 40 method/path handlers.
- Standard ESP-IDF OTA APIs (`esp_ota_begin`, `esp_ota_write`, `esp_ota_end`, `esp_ota_set_boot_partition`) stream one upload through a 4 KiB buffer into the inactive slot (two 4 MiB app slots). A reboot task with a 2 KiB stack is preallocated before erase. Lifecycle/identity reservations and ordinary software-reboot gating exclude competing work; no custom flash protocol or whole-image RAM buffer.
- Accepts an ESP32-S3 application binary for this board/layout, not an ELF, ZIP, merged full-flash image, bootloader or partition-table image. Browser hints require a nonempty `.bin` of at most 4 MiB; the server bounds raw length against actual destination capacity before trusting HTTPD's narrowed length. Checks completeness, target/header and exact SDK image length/integrity, including mandatory appended SHA-256, before selection. The digest is not publisher authentication; neither it nor filename checks prove board compatibility or a working application.
- Before selection, rejected/incomplete uploads and receive/write/validation failures abort any live OTA handle without selecting the candidate; the inactive slot may be erased/partially written. Boot-metadata failure (`firmware_commit_failed`) needs inspection and carries SDK transactional uncertainty. `200 {"ok":true,"rebooting":true}` means validation/selection succeeded; successful synchronous send schedules restart after 500 ms, retaining reservations, but proves neither browser receipt nor boot success. Response failure after selection schedules no restart, releases reservations for manual reboot and latches further admissible uploads to 409 `firmware_selected_reboot_required`; the latch survives HTTPS stop/start until device reset.
- Synchronous HTTPD receive/flash blocks other HTTPD work: browser sessions can stall/drop. The 120-second total receive-loop and ten-second stall checks use the existing one-second socket timeout, not an absolute deadline: synchronous SDK erase/write/validation and scheduling are not preempted. The browser's 180-second timeout cannot cancel committed work. UART0/native USB remain independent paths, not guarantees of uninterrupted serial timing during flash; reboot interrupts all transports. Software exclusion cannot prevent physical reset, power loss or panic.
- **Preserve NVS:** write only the inactive application slot and the OTA selection metadata. Do not erase the chip, rewrite the partition table/bootloader, or touch `nvs`, `nvs_key`, PHY, storage or other data partitions. Existing users, passwords, Wi-Fi/serial settings and HTTPS/SSH identities remain stored, as with an application-only wired update without erase. This preserves stored bytes; the uploaded firmware must still understand the existing schemas and must not itself erase/migrate them incompatibly.
- Keep wired USB-to-UART flashing documented as recovery if the uploaded application does not boot or no longer serves the web UI. Basic image validation cannot prevent a valid but broken application from requiring wired recovery.
Evaluate and, if resource limits permit, add BLE as another bounded transport or provisioning path.
**Not in scope:** secure boot, image signatures/signing-key management, anti-rollback/version-downgrade enforcement, automatic rollback/post-boot health-confirmation machinery, remote release discovery, partition migration or NVS backup/restore. Use upstream OTA support without dependency patches. The administrator is responsible for selecting trusted, compatible firmware, including when deliberately installing an older build.
Planned work:
**Historical integration build and host evidence (parent-reported):** `pio run` **PASS**, **94,220 B linked RAM / 1,847,645 B flash**. Against Phase 9's 94,196 B / 1,828,565 B, this is **+24 B RAM / +19,080 B flash**. Final backend 88 cases plus the pinned SDK begin/abort failure-contract test, UI 169 groups, server lifecycle 44, admin transport 25, console lifecycle, SSH runtime and cookie lifecycle checks passed. Additional cookie base/admin/display/lifecycle, HTTPD idle cleanup 18, and SSH management/runtime/security checks passed; the extra cookie `--admin` run initially lacked a reboot symbol in its fixture, corrected in test-only code before passing. Host doubles are not target evidence. The later concise-UI fix was copy-only; its latest reported UI regression passed all **169 groups**, with no rebuild after the text change. The build figures above remain historical; this documentation update ran no build or tests.
- Decide whether BLE is primarily a serial transport, a provisioning interface, or both.
- Require authenticated pairing/bonding appropriate to the device threat model.
- Integrate serial access through the existing broker rather than bypassing writer ownership.
- Preserve binary transparency through explicit framing and MTU-aware partial transfers.
- Bound connections, queues, retries, and advertising behavior.
- Measure coexistence with 2.4 GHz Wi-Fi, HTTPS, WebSocket, SSH, USB CDC, and UART service.
- Store bond/provisioning data under the hardened secret-storage policy.
- Provide UART0 controls and a physical recovery path for clearing BLE state.
**Acceptance (2026-09-18):** after the firmware upload implementation and concise-UI fix, the user confirmed: “That works perfectly. And the usual operation is also verified.” Phase 10 is complete by this explicit acceptance of working upload and verified normal operation. Do not infer specific fault-injection, NVS before/after comparisons, power-loss or wired-recovery passes. The [regression guidance](#phase10-regression-guidance) below is reusable future guidance, not an acceptance blocker.
BLE remains subordinate to stable Wi-Fi and serial operation; it should be omitted if coexistence or memory costs cannot be bounded acceptably.
#### Phase10 update and recovery
1. Keep **USB-to-UART** available for UART0 administration/flashing; native USB CDC is network-independent UART1 access, not administration. Install the updater by wire first: older firmware without the route cannot install its own first web updater. The custom OTA layout is required; [one-time old-layout migration](../README.md#one-time-migration-from-the-default-partition-table) is separate and destructive.
2. Build trusted, schema-compatible `esp32-s3-devkitc-1-n16r8` firmware with `pio run`; choose **`.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin`**, never a merged image or a renamed non-application file. Observe the [downgrade warning](../README.md#legacy-credential-removal).
3. Save desired RAM-only settings and record nonsecret configuration/public HTTPS/SSH fingerprints. Arrange a maintenance window and stable power/network; sign in as `admin` over trusted HTTPS, open **Settings → HTTPS / Reboot → Firmware update**, choose the file and confirm **Upload and reboot…**.
4. Wait for validation/reboot, not just 100% transmitted bytes. Restore network reachability, reload and explicitly sign in; verify the running application, saved configuration and identities before another upload.
5. A lost response, timeout, page close, sign-out or browser abort proves neither cancellation nor failure. The UI locks uncertain outcomes without replay; reload clears only browser locks, not the device latch. Inspect through UART0 and deliberately reboot if appropriate: any later reset can boot an already selected image.
6. For initial wired installation or recovery when boot/HTTPS fails, connect USB-to-UART, select a known-good compatible checkout, run `pio run --target upload`, then `pio device monitor -b 115200`. **Do not erase for ordinary updates or recovery**; there is no automatic recovery flashing/rollback.
Replies: **400** malformed/incompatible/incomplete input; **401/403** authentication/Origin/CSRF or `admin_required`; **408** `firmware_timeout`; **409** selected-image latch; **413/415** destination capacity/content type; **500** `firmware_write_failed`/`firmware_commit_failed`; **503** busy/unavailable/resources. Unread rejected bodies close rather than drain; bounded secret-free JSON may never reach a disconnected browser.
#### Phase10 regression guidance
These are reusable checks, **not recorded passes or outstanding acceptance gates**. Record only performed outcomes; host flash/network/scheduling doubles are not hardware evidence. Focused host suites: `python3 tests/web_firmware_update/run.py` and `python3 tests/web_ui_session/run.py` (the SDK begin/abort case covers a live handle published on erase failure).
- Exercise wired-first install, known-good upload/reboot/new build and explicit reconnect; compare saved users/roles, password/key login, Wi-Fi/serial/display/hostname settings and public HTTPS/SSH fingerprints without recording secrets.
- Interrupt/cancel receive; reject corrupt, truncated, wrong-target, non-application and oversized inputs without unintended selection/restart, then deliberately perform a valid update. Reject unauthenticated/ordinary-user, wrong-Origin and missing/wrong-CSRF requests before flash. Never attempt power interruption at commit without wired recovery ready.
- Exercise competing upload, HTTPS lifecycle/identity and ordinary reboot exclusion; HTTPD requests may wait rather than promptly return busy. With controlled post-selection response-failure injection, verify no automatic restart, subsequent 409 (also after HTTPS restart), then manual reboot; an arbitrary disconnect does not establish this fault case.
- Observe web stalls/drops and serial/network recovery, single-writer isolation, UART0 recovery and native USB UART1 access without networking; demonstrate known-good wired recovery without erase and recheck configuration/identities. Do not claim uninterrupted traffic.
## Current and planned phases
**Phases 8, 9 and 10 are complete** for their accepted scopes. Phase 9 includes the explicit new-hardware-check waiver above; Phase 10 includes explicit user acceptance of upload and normal operation. Remaining future work is under evaluation. Optional features must not weaken completed serial and recovery paths. General release gates below guide future work, not claims that every fault, soak, recovery or reserve measurement was performed for completed phases.
### Phase 12 — Advanced network integration
These features are candidates, not current commitments:
Agreed planning baseline; implementation is not yet authorized:
- Additional DNS-SD service advertisement and any certificate-name integration beyond the implemented configurable STA hostname.
- Enterprise Wi-Fi support, subject to credential-storage and certificate-validation design.
- IPv6 behavior and diagnostics beyond the current basic support.
- WireGuard feasibility evaluation, including RAM, CPU, licensing, key storage, routing, and recovery impact.
- Dual-stack local access and DNS-SD discovery: advertise available HTTPS and SSH services using the shared `sak-<suffix>.local` hostname, with appropriate IPv4 `A` and IPv6 `AAAA` records. Preserve IPv4 access and verify IPv6 support throughout HTTPS, WebSocket and SSH. Clients choose address-family preference and fallback; DNS-SD cannot mandate IPv6 preference. Certificate-name integration and trust remain separate concerns.
- Send the configured device hostname (`sak-<suffix>`, without `.local`) through DHCPv4 Host Name option 12 so a suitably configured DHCP/DNS server can publish the lease address in its own DNS zone. Set the STA netif hostname before DHCP starts, define how hostname changes reach subsequent DHCP exchanges, and verify the transmitted option and server-side DNS registration. DNS publication and the DNS domain remain server policy; mDNS does not provide this integration.
- IPv6 baseline: link-local addressing and SLAAC-provided ULA/global addresses, with mDNS/DNS-SD for local discovery; no DHCPv6 requirement. Preserve link-local interface scope and keep advertisements synchronized with usable addresses and service availability. Registration of SLAAC addresses in infrastructure DNS is a separate optional feature, outside this baseline; SLAAC does not provide automatic hostname registration.
- Validate IPv4-only, IPv6-only and dual-stack operation, including reconnects, address changes and service lifecycle changes. Discovery remains local-link by default; preserve UART0 and native USB recovery regardless of network state.
The device is not intended to become a general-purpose router. Captive-portal interception, unauthenticated DNS redirection, NAPT, and a plaintext serial listener remain out of scope unless the project requirements are explicitly revised.
### Phase 13 — Optional filesystem-backed features
The `storage` partition is reserved but not currently mounted. Possible uses must be justified individually:
- LittleFS-backed web assets to decouple large static files from the application image.
- Bounded diagnostic logs with explicit retention and secret-redaction rules.
- Import/export of non-secret configuration.
- Optional user files needed by later administration features.
Before enabling storage, define corruption recovery, wear limits, quotas, atomic update behavior, compatibility across OTA slots, and whether encryption is required. Serial operation and recovery must not depend on a mountable filesystem.
## Cross-phase release gates
Every phase should satisfy the following before being marked complete:
Every phase should satisfy the following before being marked complete, with any user-waived check explicitly recorded in that phase rather than reported as passed:
1. A clean release build for the ESP32-S3 N16R8 target.
2. No new compiler or project diagnostics attributable to the change.
@@ -305,7 +327,7 @@ The following are not implemented merely because flash partitions or library sup
- NVS, flash, or PSRAM encryption.
- Secure boot or production eFuse provisioning.
- OTA download, image confirmation, or rollback policy.
- Automatic OTA downloads, image signing, post-boot health confirmation and automatic rollback. The simple admin upload is complete in Phase 10; these advanced update features remain deferred.
- Core-dump collection or secret-safe core-dump processing.
- Filesystem mounting.
+100
View File
@@ -0,0 +1,100 @@
# Security operations — intermittent hobby use
This checklist is for a wireless serial cable used occasionally on a trusted network,
not an Internet-facing appliance or a production-security certification.
It covers existing firmware operations only: no dependency patches, crypto upgrades,
secure-boot, encrypted-NVS, or eFuse changes are part of this scope.
## Provision once, locally
- Use the physical **UART0 administration console** on a trusted computer.
Native USB CDC is a separate UART1 serial-data path, not the admin console.
- Create the first administrator with `user add <username> admin`.
Use a unique password; the prompt accepts 1264 printable ASCII characters.
Add a separate `user` account for ordinary serial use, especially over SSH:
SSH `admin` opens administration, whereas SSH `user` opens UART1.
- Use unique Wi-Fi credentials, not passwords reused from other services.
Configure station profiles using the [Wi-Fi command reference](command_reference.md#wi-fi);
enter their passwords with `wifi profile secret <slot>`.
If using the device AP, set its password with `wifi ap secret`.
If the AP is unnecessary, select `wifi ap policy off`.
Run `wifi save` to persist working Wi-Fi settings.
- Before entering network credentials into a client, check device identity locally:
run `web certificate info` and `ssh host-key info` on trusted UART0.
Compare the SHA-256 certificate fingerprint in the browser certificate viewer
and the SSH host-key fingerprint in the SSH client with those local values.
Record the verified fingerprints; do not blindly dismiss certificate or changed-key warnings.
- Keep passwords in a password manager. Generated account passwords (`--generate`)
are displayed once; avoid terminal recording or sharing that output.
## Each use
- Use only a trusted LAN or the device's password-protected AP.
Do not configure router port forwarding or otherwise expose HTTPS/SSH to the Internet.
A trusted network reduces exposure; it does not make every attached client trustworthy.
- Check unexpected identity warnings against UART0 before logging in.
Keep UART0, native USB, and the connected RS-232 equipment physically controlled:
local serial access is not protected by network account authentication.
- When finished, sign out of the browser and close SSH clients.
Browser serial **Disconnect** alone is not sign-out.
- Prefer powering the device off when unused. If it must remain powered for USB
serial use, run `wifi stop` from UART0 and check `wifi status` after the queued
operation completes. This stops network access, not native USB UART1 access.
To keep Wi-Fi disabled at the next boot, follow with `wifi save`.
`wifi start` enables it again in RAM; save again only if that boot policy is wanted.
Do not rely on a temporary stop surviving reboot without saving.
## Change credentials or identity when needed
Use trusted UART0 for these operations to avoid losing a remote session mid-change.
Rotate after suspected disclosure, a lost client key, or a change of owner/access;
there is no need for a calendar-driven rotation ritual for this use case.
| What changed | Built-in operation |
|---|---|
| Account password | `user password <username>` (no-echo prompt). |
| Authorized SSH client key | `user show <username>` to identify its index; `user key delete <username> <0..2> --force`, then `user key add <username>` to enter the replacement public key. |
| Account no longer needed | `user delete <username> --force`; the final administrator is protected. |
| Wi-Fi password | Change the router credential as appropriate, then `wifi profile secret <slot>`; for the device AP use `wifi ap secret`. Finish with `wifi save`. |
| HTTPS device identity | `web certificate rotate --force`. |
| SSH device identity | `ssh host-key rotate --force`. |
Account changes invalidate that account's network sessions; identity replacement
interrupts the affected service. Check command results, then verify new fingerprints
through UART0 before updating browser trust or SSH known-host entries and reconnecting.
Changing a password does not remove authorized SSH keys; review both after compromise.
Identity rotation is not an account-password change. If an acknowledgement is lost,
inspect current state on UART0 rather than blindly repeating the operation.
## Recovery and backup limits
- Keep UART0 available for forgotten passwords, Wi-Fi mistakes, and network-service
recovery. Native USB can still carry UART1 data when networking fails, but cannot
repair accounts or identities; neither path promises uninterrupted service through reboot.
- For a healthy database, use normal UART0 account commands to repair access.
`user recover --force` is **only** for an unavailable user database: it destroys
that database's contents and rebuilds it empty, refuses a healthy database, and
requires creating a new administrator afterward. It is not a forgotten-password reset.
- `web reset --force` replaces HTTPS certificate/private-key material only;
`ssh reset --force` replaces SSH host-key material. Neither resets accounts or Wi-Fi.
These are scoped recovery operations, not a whole-device factory reset.
- No supported configuration/credential/identity backup-export command was found
in the application console. Keep a private reprovisioning record of settings,
account names/roles, client public keys, and verified fingerprints; store passwords
separately in a password manager. Do not expect status output to recover secrets
or private keys. Reprovisioning may require accepting a newly verified device identity.
- Physical extraction or firmware replacement is **not protected by this checklist**.
Treat a lost device or a raw flash copy as potentially exposing stored Wi-Fi secrets,
private identities, and password verifiers. Rotation is not a secure flash-erasure claim.
## Lightweight maintenance
Occasionally, and before a planned firmware update, review relevant ESP-IDF,
wolfSSL/wolfSSH, and bundled web-library upstream release/security notes.
If a relevant issue appears, stop network use until a separate upgrade decision is made;
USB serial remains an option. Dependency upgrades and their validation are separate work,
not an endless CVE audit or an implied promise that this firmware is vulnerability-free.
Commands and limits were checked against application sources and the [command reference](command_reference.md) at `f40c09c`.
This is source-reviewed operating guidance, not a build, hardware test, security audit,
or verification of any individual device's flash/eFuse configuration.
+4 -4
View File
@@ -1,6 +1,6 @@
# User administration and authentication tests
Reusable procedures, **not execution evidence**. [Phase 8 is accepted](web_administration_acceptance.md); overall 8D.22 user sign-off does not assert that every checklist item below ran. Historical 8A/B sections describe their original baselines only; current cookie authentication, removed bootstrap/credentials and UART0-only recovery override them. Use current contracts and the integrated regression section for present behavior. Never log generated/entered passwords, cookies/tickets or private/verifier material.
Reusable procedures, **not execution evidence**. [Phase 8 is accepted](roadmap.md#phase8-acceptance-evidence); overall 8D.22 user sign-off does not assert that every checklist item below ran. Historical 8A/B sections describe their original baselines only; current cookie authentication, removed bootstrap/credentials and UART0-only recovery override them. Use current contracts and the integrated regression section for present behavior. Never log generated/entered passwords, cookies/tickets or private/verifier material.
## Current Broker management regression procedure — 8D.16
@@ -26,7 +26,7 @@ The [Network contract](web_administration.md#network) defines the current byte c
## 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.
The legacy bootstrap/credential/reconciliation instructions in the phase baselines below are **historical only**, superseded by [legacy credential removal](roadmap.md#phase8-legacy-credential-compatibility) 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.
1. On a disposable controlled NVS image with only `user_db/database` missing, boot and confirm an empty database is persisted with zero accounts/admins and no imported credential. Reboot and confirm it remains empty. Keep physical UART0 attached; do not factory-erase the device for this test.
2. Run `user add maint admin` on UART0, check hidden password confirmation and cancellation, then confirm account/password persistence after reboot. Separately exercise `user add operator user --generate` and secure one-time display. Final-administrator delete/demotion must still fail. Existing SSH own-password generation restrictions and typed browser generated-password support remain unchanged.
@@ -36,7 +36,7 @@ The legacy bootstrap/credential/reconciliation instructions in the phase baselin
6. Confirm help/completion/status expose no legacy credential/bootstrap operation or secret. Removed commands must reject without mutation. Exercise retained user generation and `web certificate rotate --force` through supported frontends. `web reset --force` must change TLS only, require new certificate trust/login after HTTPS restart, leave user credentials/generations unchanged, and not revoke unrelated SSH sessions.
7. Recheck UART0/native USB availability and broker one-writer/isolated-observer behavior with network authentication unavailable. Record only nonsecret counters and telemetry. Older v1-only firmware cannot read HTTPS v2; logical NVS replacement is not secure flash erasure and no factory erase is required.
Acceptance and prior evidence limits are recorded in [legacy compatibility](legacy_credential_removal.md) and [overall acceptance](web_administration_acceptance.md); the preceding procedure is not an execution log.
Acceptance and prior evidence limits are recorded in [legacy compatibility](roadmap.md#phase8-legacy-credential-compatibility) and [overall acceptance](roadmap.md#phase8-acceptance-evidence); the preceding procedure is not an execution log.
## Historical Phase 8A baseline — role-based database and UART0 administration
@@ -204,7 +204,7 @@ Finally, issue commands concurrently from UART0 and admin SSH, including `user l
## Integrated web administration regression procedure
Current retained-scope checks for future changes. [8D.22 sign-off](web_administration_acceptance.md) closes the phase; these are **not additional acceptance conditions or claims of individual execution**. Record exact revision, workload/client mix, duration, counters and heap/stack evidence for any new run. Do not treat host doubles as target timing/power-loss proof or reserve approval. Prepare UART0 recovery/native USB before disruptive tests and do not erase persisted data without explicit approval.
Current retained-scope checks for future changes. [8D.22 sign-off](roadmap.md#phase8-acceptance-evidence) closes the phase; these are **not additional acceptance conditions or claims of individual execution**. Record exact revision, workload/client mix, duration, counters and heap/stack evidence for any new run. Do not treat host doubles as target timing/power-loss proof or reserve approval. Prepare UART0 recovery/native USB before disruptive tests and do not erase persisted data without explicit approval.
### 1. Integrated login and authorization
+12 -4
View File
@@ -1,6 +1,6 @@
# Web administration contracts
Current, accepted firmware behavior. Phase status and executed-evidence limits belong in the [roadmap](roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance record](web_administration_acceptance.md), not in implementation timelines. [Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) describe checks, not results. Source is authoritative; start with the [code map](agent/code-map.md).
Current implementation contracts; [Phase 10 firmware upload is complete by explicit user acceptance on 2026-09-18](roadmap.md#phase-10--simple-admin-web-firmware-upload), confirming upload and normal operation, not specific fault or recovery checks. Phase status and executed-evidence limits belong in the [roadmap](roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance record](roadmap.md#phase8-acceptance-evidence), not in implementation timelines. [Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) describe checks, not results. Source is authoritative; start with the [code map](agent/code-map.md).
## Authentication and admission
@@ -9,7 +9,7 @@ Current, accepted firmware behavior. Phase status and executed-evidence limits b
- Mutation admission requires current cookie/principal, strict Origin and CSRF validation; administration additionally requires current `admin`. Normal users retain serial/status but cannot invoke administration directly. Authentication POST fetches use CORS mode with fixed same-origin URLs and same-origin credentials: do not accept Origin `null` to compensate for browser no-referrer behavior.
- Session-store initialization is part of admitted HTTPS start; authentication failure gates HTTPS. Failed start/accepted stop disables and wipes session state. Logout invalidates only the originating session before socket cleanup; account mutation invalidates that account's sessions/tickets, including deletion/recreation, without revoking unrelated accounts. Currentness checks remain authoritative if best-effort notifications fail.
- Four serial tickets and two admin tickets are digest-only, single-use, 30-second, session/principal-bound records. Cookie/Origin/ticket/currentness and transport admission precede explicit WebSocket 101. Store RNG/SHA/database calls run outside short spinlocks; IDs/expiry/epochs fence stale publication without nested store/transport locks.
- HTTPD remains bounded to six sockets, two serial WebSockets, one admin WebSocket and 39 method/path handlers; LRU eviction is disabled. Sessions, sockets, tickets and the two shared remote-console slots are separate capacity limits. Optional settings/admin failures preserve unrelated routes where their initialization contract permits; UART0 and native USB remain independent of web readiness.
- HTTPD remains bounded to six sockets, two serial WebSockets, one admin WebSocket and 40 method/path handlers; LRU eviction is disabled. Sessions, sockets, tickets and the two shared remote-console slots are separate capacity limits. Optional settings/admin failures preserve unrelated routes where their initialization contract permits; UART0 and native USB remain independent of web readiness.
`web_httpd_adapter` alone accesses private IDF 5.5.0 HTTPD state. It rejects duplicate/ambiguous headers, postpones 101 until admission, and wipes consumed header scratch while preserving right-aligned unread bytes. Optional Settings registration stages descriptor/name allocations before publishing either, avoiding the pinned public registration failure path. Re-audit these private boundaries on SDK upgrades and same-version SDK patches: the version guard does not detect patches that retain the same version number. HTTPD response headers are pointer-backed, not copied; both `Set-Cookie` value buffers must remain valid and distinct through response send. Do not reuse or wipe those buffers before sending completes. Do not enable header/ticket debug logging. Auth documents, scripts and sensitive responses are no-store with CSP/no-referrer/frame-denial protections; authored loader changes require matching CSP hashes. Generated assets are not a normal documentation/build output.
@@ -111,6 +111,14 @@ HTTPS ordering is **commit → stop → restart**. Precommit generation/RNG/stor
Save drafts; rotation/restart invalidates all web logins and closes both browser routes. Inspect `web certificate info` through trusted UART0, verify fingerprint before renewing trust, then reload/sign in freshly. Accepting a warning alone is not trusted verification. Use canonical UART0/admin SSH `web stop` / `web start` for retained-server recovery. Network/SSH/USB are not stopped by HTTPS-only operations; whole-device reboot interrupts all transports and loses unsaved RAM.
### Application firmware upload
The admin-only firmware card is in **Settings → HTTPS / Reboot**. It sends a raw `POST /api/firmware` with `application/octet-stream`, known length, session cookie, same-origin Origin and `X-CSRF-Token`; it is not a JSON Settings operation or dispatcher/result-slot workflow. Standard SDK OTA APIs stream through a 4 KiB buffer to the inactive application slot and select it only after validation/currentness checks. Only that slot and `otadata` are written; NVS/data partitions are untouched.
One upload reserves HTTPS lifecycle/identity and excludes ordinary software reboot. The synchronous HTTPD handler can stall/drop browser serial/admin sessions. Its 120-second total receive-loop and ten-second stall checks are not preemptive flash deadlines or uninterrupted-traffic guarantees. Successful response send schedules a delayed reboot, not proof of peer receipt. Failed response after commit leaves the image selected, schedules no automatic reboot, releases reservations for manual reboot, and latches subsequent admissible uploads to 409 `firmware_selected_reboot_required` until reset. Never automatically retry an uncertain outcome.
See [firmware update](roadmap.md#phase-10--simple-admin-web-firmware-upload) for wired-first installation, exact image selection, manual recovery, acceptance limits and reusable future hardware regression checks. Phase 10 is accepted; there are no signature/version policies or automatic rollback.
### SSH
GET `ssh` supplies service/session state plus identity generation, fixed P-256 algorithm, unpadded OpenSSH `SHA256:` base64 fingerprint and rotatable flag. Service actions use exactly `action`, `generation`, `target`; rotate adds `identity_generation` and requires target zero. Start/stop and exact-session disconnect use published state, saturated service generation and the command mutex; exhausted SSH session slots retire rather than wrap. Disconnect success is an owner close request, not completed teardown. HTTPD never calls wolfSSH or waits for the SSH task.
@@ -123,6 +131,6 @@ SSH changes leave invoking HTTPS available, so they use the ordinary ID-dispatch
## Diagnostics, recovery and scope boundaries
See [admission diagnostics](web_admission_diagnostics.md), [ordinary HTTPS idle cleanup](https_idle_cleanup.md), [throughput diagnostics](web_throughput_diagnostics.md) and [legacy storage compatibility](legacy_credential_removal.md). Broker read means transport handoff, not peer receipt; capture non-consuming counters before disconnect. TLS `-0x004C` is generic NET_RECV_FAILED, not evidence of OOM. Resource minima and counter observations require attribution, not inferred causes.
See [admission diagnostics](web_admission_diagnostics.md), [ordinary HTTPS idle cleanup](https_idle_cleanup.md), [throughput diagnostics](web_throughput_diagnostics.md) and [legacy storage compatibility](roadmap.md#phase8-legacy-credential-compatibility). Broker read means transport handoff, not peer receipt; capture non-consuming counters before disconnect. TLS `-0x004C` is generic NET_RECV_FAILED, not evidence of OOM. Resource minima and counter observations require attribution, not inferred causes.
Phase 8D.15's dedicated typed network-diagnostics UI/API was removed: diagnostics remain shell-based, subject to frontend policy. The unimplemented 8D.19 ordinary browser-session/native-USB control expansion was removed; existing SSH controls remain. No full shell parity, browser identity recovery/reset/export, encryption, secure boot or OTA is implied by acceptance. UART0 is the administrative recovery authority; native USB is binary-transparent, network-independent UART1 access. Neither permits bypassing the broker's single writer or recalling already-admitted work.
Phase 8D.15's dedicated typed network-diagnostics UI/API was removed: diagnostics remain shell-based, subject to frontend policy. The unimplemented 8D.19 ordinary browser-session/native-USB control expansion was removed; existing SSH controls remain. No full shell parity, browser identity recovery/reset/export, encryption or secure boot is implied by Phase 8 acceptance. Phase 10 application upload is complete by explicit user acceptance of upload and normal operation; unreported fault, NVS-comparison, power-loss and recovery checks are not implied. UART0 is the administrative recovery authority; native USB is binary-transparent, network-independent UART1 access. Neither permits bypassing the broker's single writer or recalling already-admitted work.
-40
View File
@@ -1,40 +0,0 @@
# Web administration acceptance
## Phase 8D.22 sign-off — 2026-09-13
The user explicitly accepted the tested firmware: **“Yep, I tested the firmware thats a 8d.22 signoff.”** Phase 8D is complete for its retained scope. This supersedes earlier per-slice target-pending, parent-review and integration-acceptance gates; it does not require additional testing to establish the user's sign-off. Phase 8A/B/C were already recorded as complete and target-hardware validated in the roadmap, so [Phase 8 is complete](roadmap.md#phase-8--role-based-users-and-administrative-access--complete).
Accepted functionality comprises cookie authentication, isolated browser serial/admin terminals, typed Serial/Accounts/authorized-key/Network/Display/Broker/SSH/HTTPS settings, contextual controls, confirmed generation-safe writer/service/identity actions and retained UART0/native-USB recovery. [Current contracts](web_administration.md) define actual permissions, bounded failures and partial effects; acceptance is not a claim of unrestricted browser-shell parity.
Scope decisions remain effective: 8D.15 dedicated typed network diagnostics was removed (shell diagnostics retained); the unimplemented 8D.19 ordinary browser-session/native-USB control remainder was removed (SSH controls retained). Browser identity Reset/recovery/export was not added. Phase 9 security/production hardening is next only on a separate user request.
## Latest firmware evidence
The **prior final firmware build passed**, reporting **100,556 bytes linked RAM / 1,828,573 bytes flash**, at **160 MHz**. This is recorded prior build evidence, not a build run during documentation consolidation. Host regression suites and independent implementation reviews were previously reported passing; no new host/runtime test pass is asserted here.
Latest user telemetry, bytes:
| Sample / memory capability | Free | Minimum free | Largest block |
|---|---:|---:|---:|
| Boot internal 8-bit | 59,808 | 58,840 | 31,744 |
| Boot internal DMA | 52,052 | 51,084 | 31,744 |
| Boot PSRAM | 8,196,968 | 8,183,972 | 8,126,464 |
| Loaded after burst, internal 8-bit | 31,508 | 2,052 | 18,432 |
| Loaded after burst, internal DMA | 23,752 | 460 | 18,432 |
| Loaded after burst, PSRAM | 8,136,624 | 8,065,972 | 7,995,392 |
Loaded SSH minimum-free stack was **15,028 bytes**. The capture had two active SSH sessions across the serial/admin roles, two serial WebSockets and USB, with SSH holding the serial writer. Browser admin had been used and then closed; it was **not active in the captured loaded sample**. Web send/queue/protocol error counters were zero; SSH IO errors were zero, with one handshake failure and one session revocation retained without attributing a cause.
Latest broker/serial counters were not supplied, so these transport counters do **not** establish an exact latest zero-drop or byte-integrity result. No latest full-mix-with-browser-admin-active, individual fault-injection, exact duration, soak, cleanup-cycle or reserve-floor result is inferred. Generic SDK TLS `-0x004C` / NET_RECV_FAILED is not an OOM diagnosis. Two boot authentication failures could plausibly involve stale browser cookies, but that explanation is unconfirmed.
## Earlier acceptance retained without replaying the timeline
- M1 browser login/logout and M2 shared browser administration were explicitly accepted by the user; later Serial/account/Network presentation and legacy-credential cleanup also received scoped acceptance.
- The user explicitly accepted the combined binary WebSocket-send fix at **160 MHz, 230400 baud with the full client mix, including browser admin**. That prior acceptance stands independently of the latest capture's closed browser-admin socket. It does not imply an unreported latest exact counter comparison or soak duration. Keep the combined send and bounded failed-send isolation, not the earlier frequency-only experiment.
- Ordinary HTTPS idle cleanup was accepted as working; that is not a guarantee against all future admission failures or owner delays.
## Nonblocking follow-ups and evidence limits
The extremely low internal/DMA lifetime minima remain an unresolved transient-headroom follow-up, **not a blocker reopening 8D sign-off and not an approved reserve**. Capability pools overlap; summed per-region lifetime minima can be conservative/non-simultaneous and do not prove an allocation failure. Do not add internal and DMA numbers together or attribute an error to memory pressure without correlated evidence. HTTPD/dispatcher stack margins, peak correlation, allocation reserve policy and long-run cleanup/soak evidence remain distinct future measurements.
[Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) and focused test runners remain available for future changes; listing them is not evidence they all ran on hardware. Documentation-only consolidation changes no firmware, configuration, generated assets or test implementation and performs no build/upload/erase/commit. No new Phase 9 work is authorized by this acceptance.
+1 -1
View File
@@ -1,6 +1,6 @@
# Web admission diagnostics
Current default-disabled instrumentation, not an admission fix or an allocation-failure detector. `src/web_diagnostics.{c,h}` observes public synchronous HTTPS create/close callbacks and the four serial/admin ticket/upgrade handlers. It adds no owner task, queued probe or socket-capacity change. See [ordinary idle cleanup](https_idle_cleanup.md) for the independent cleanup policy and [acceptance evidence](web_administration_acceptance.md) for user reports.
Current default-disabled instrumentation, not an admission fix or an allocation-failure detector. `src/web_diagnostics.{c,h}` observes public synchronous HTTPS create/close callbacks and the four serial/admin ticket/upgrade handlers. It adds no owner task, queued probe or socket-capacity change. See [ordinary idle cleanup](https_idle_cleanup.md) for the independent cleanup policy and [acceptance evidence](roadmap.md#phase8-acceptance-evidence) for user reports.
## Usage and interpretation
+1 -1
View File
@@ -2,7 +2,7 @@
## Current contract and accepted fix
Default-disabled instrumentation measures broker fan-out and binary WebSocket TX, not CPU usage, peer receipt or pure scheduler latency. The user separately accepted the combined binary header/payload-send fix at **160 MHz / 230400 baud with full mix including browser admin**. [Acceptance evidence](web_administration_acceptance.md) distinguishes that report from the latest capture and unmeasured reserves. The old frequency-only experiment is not the retained solution.
Default-disabled instrumentation measures broker fan-out and binary WebSocket TX, not CPU usage, peer receipt or pure scheduler latency. The user separately accepted the combined binary header/payload-send fix at **160 MHz / 230400 baud with full mix including browser admin**. [Acceptance evidence](roadmap.md#phase8-acceptance-evidence) distinguishes that report from the latest capture and unmeasured reserves. The old frequency-only experiment is not the retained solution.
`web_httpd_ws_send_binary` is an IDF-5.5.0-pinned owner-only session-override send with a bounded 516-byte header/payload scratch copy and 512-byte payload. Generation validation and one outstanding item per slot remain. A non-full send installs a reject-only override before close/shutdown, preventing SDK automatic PONG/CLOSE from reentering TLS with different arguments after incomplete output, even if shutdown fails. No retry/replay; HTTPD owns TLS destruction. Text/control/admin retain the SDK sender. One API call does not promise one TLS record/packet or peer receipt. Keep CPU160, priorities, scheduling and 4096-byte broker/512-byte web payload bounds unless separately changing them with evidence.
+6 -1
View File
@@ -16,7 +16,7 @@ CONFIG_ESP_WIFI_RX_BA_WIN=6
CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=4
# Keep concurrent HTTPS handshakes from exhausting scarce internal DRAM.
# Active TLS material remains unencrypted in PSRAM until the hardening phase.
# Active TLS material remains unencrypted in PSRAM; physical extraction is outside scope.
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
# CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set
# Hardware AES can hang in the PSRAM DMA path and cannot be shared safely with
@@ -72,3 +72,8 @@ CONFIG_ESP_WIFI_SOFTAP_SAE_SUPPORT=y
# Keep diagnostic and interactive-console logging concise but useful.
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# Intermittent-use baseline: avoid secret-bearing crash dumps/register output.
# Ordinary logging remains enabled; this is not physical-memory protection.
CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y
CONFIG_ESP_SYSTEM_PANIC_SILENT_REBOOT=y
+2
View File
@@ -38,6 +38,7 @@ idf_component_register(
"web_broker_settings.c"
"web_ssh_settings.c"
"web_lifecycle_settings.c"
"web_firmware_update.c"
"web_admin_tickets.c"
"web_admin_transport.c"
"web_assets_data.c"
@@ -59,6 +60,7 @@ idf_component_register(
"mdns_console.c"
INCLUDE_DIRS "."
REQUIRES
app_update
bootloader_support
console
esp_driver_gpio
-6
View File
@@ -142,12 +142,6 @@ esp_err_t admin_ssh_console_dispatch_read_input(
esp_err_t admin_ssh_console_dispatch_defer(
admin_ssh_deferred_action_type_t action, uint32_t argument);
/* SSH compatibility entry point, implemented by the owner in ssh_transport.c.
* Token/principal are copied; no SSH or socket objects cross this boundary.
* Existing feed/close/read/snapshot APIs below also accept open_owned tokens.
*/
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal);
void admin_ssh_console_close(const admin_ssh_console_token_t *token);
/* Called by the session owner. Returns false when input must be backpressured. */
+3 -1
View File
@@ -13,6 +13,7 @@
#include "esp_log.h"
#include "esp_netif_ip_addr.h"
#include "esp_system.h"
#include "web_firmware_update.h"
#include "freertos/FreeRTOS.h"
#include "freertos/portmacro.h"
#include "freertos/semphr.h"
@@ -1101,7 +1102,8 @@ static void execute_action(local_status_ui_state_t *state,
}
break;
case LOCAL_STATUS_ACTION_REBOOT:
state->restart_pending = true;
error = web_firmware_update_reserve_reboot();
state->restart_pending = error == ESP_OK;
break;
default:
error = ESP_ERR_INVALID_ARG;
+13 -1
View File
@@ -12,6 +12,7 @@
#include "esp_console.h"
#include "esp_err.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
@@ -186,7 +187,9 @@ typedef struct {
#define PING_EVENT_QUEUE_LENGTH (PING_MAX_COUNT + 1U)
static StaticQueue_t s_ping_queue_storage;
static uint8_t s_ping_queue_bytes[PING_EVENT_QUEUE_LENGTH * sizeof(ping_event_t)];
/* Dispatcher-owned lazy payload; retain for firmware lifetime so callback queue
* storage cannot dangle. Queue control stays internal. No internal-RAM fallback. */
static uint8_t *s_ping_queue_bytes;
static QueueHandle_t s_ping_queue;
static void ping_on_success(esp_ping_handle_t handle, void *arguments)
@@ -289,6 +292,15 @@ static int execute_ping(int argc, char **argv)
return 1;
}
if (s_ping_queue_bytes == NULL) {
s_ping_queue_bytes = heap_caps_malloc(
PING_EVENT_QUEUE_LENGTH * sizeof(ping_event_t),
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (s_ping_queue_bytes == NULL) {
printf("ping: PSRAM event storage unavailable\n");
return 1;
}
}
if (s_ping_queue == NULL) {
s_ping_queue = xQueueCreateStatic(PING_EVENT_QUEUE_LENGTH, sizeof(ping_event_t),
s_ping_queue_bytes, &s_ping_queue_storage);
+3 -9
View File
@@ -13,6 +13,7 @@
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "esp_system.h"
#include "web_firmware_update.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
@@ -265,6 +266,8 @@ static esp_err_t admin_console_perform(const admin_ssh_console_token_t *token,
}
switch (action) {
case ADMIN_SSH_DEFER_REBOOT:
if (web_firmware_update_reserve_reboot() != ESP_OK)
return ESP_ERR_INVALID_STATE;
esp_restart();
return ESP_OK;
case ADMIN_SSH_DEFER_STOP:
@@ -292,15 +295,6 @@ static const admin_console_owner_t s_admin_console_owner = {
.perform = admin_console_perform,
};
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
if (token == NULL || token->transport != ADMIN_CONSOLE_TRANSPORT_SSH) {
return ESP_ERR_INVALID_ARG;
}
return admin_ssh_console_open_owned(token, principal, &s_admin_console_owner);
}
static bool consume_external_close(const ssh_slot_t *slot, size_t slot_index)
{
taskENTER_CRITICAL(&s_lock);
+5
View File
@@ -10,6 +10,7 @@
#include "esp_console.h"
#include "esp_heap_caps.h"
#include "esp_system.h"
#include "web_firmware_update.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -57,6 +58,10 @@ static int command_reboot(int argc, char **argv)
printf("Reboot scheduled after console output drains; unsaved changes will be lost.\n");
return 0;
}
if (web_firmware_update_reserve_reboot() != ESP_OK) {
printf("Reboot refused: firmware update or reboot in progress.\n");
return 1;
}
printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n");
fflush(stdout);
/* Give the UART driver time to transmit the acknowledgement before reset. */
+21 -8
View File
@@ -11,6 +11,7 @@
#include "admin_ssh_console.h"
#include "console_input.h"
#include "esp_console.h"
#include "esp_heap_caps.h"
#include "mbedtls/base64.h"
#include "secure_random.h"
#include "ssh_transport.h"
@@ -19,8 +20,9 @@
#define USER_CONSOLE_KEY_LINE_CAPACITY 256U
/* `user` commands are serialized by the administration gate. */
static user_database_snapshot_t s_user_snapshot;
/* Gate-owned public projection (no verifiers). Lazily retained for firmware
* lifetime; allocation failure must not disable mutations or UART0 recovery. */
static user_database_snapshot_t *s_user_snapshot;
static void print_usage(void)
{
@@ -92,21 +94,30 @@ static void print_user(const user_database_user_snapshot_t *user)
static int show_users(const char *selected)
{
esp_err_t error = user_database_get_snapshot(&s_user_snapshot);
if (s_user_snapshot == NULL) {
s_user_snapshot = heap_caps_malloc(sizeof(*s_user_snapshot),
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (s_user_snapshot == NULL) {
printf("User status unavailable: PSRAM snapshot storage unavailable\n");
return 1;
}
}
esp_err_t error = user_database_get_snapshot(s_user_snapshot);
if (error != ESP_OK) {
printf("User database unavailable: %s\n", esp_err_to_name(error));
secure_wipe(s_user_snapshot, sizeof(*s_user_snapshot));
return 1;
}
if (selected == NULL) {
printf("User database: generation=%lu users=%u/%u admins=%u\n",
(unsigned long)s_user_snapshot.generation,
(unsigned int)s_user_snapshot.user_count,
(unsigned long)s_user_snapshot->generation,
(unsigned int)s_user_snapshot->user_count,
USER_DATABASE_MAX_USERS,
(unsigned int)s_user_snapshot.admin_count);
(unsigned int)s_user_snapshot->admin_count);
}
bool found = false;
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) {
const user_database_user_snapshot_t *user = &s_user_snapshot.users[index];
const user_database_user_snapshot_t *user = &s_user_snapshot->users[index];
if (!user->active ||
(selected != NULL &&
(strlen(selected) != user->username_length ||
@@ -118,11 +129,13 @@ static int show_users(const char *selected)
}
if (selected != NULL && !found) {
printf("User '%s' not found.\n", selected);
secure_wipe(s_user_snapshot, sizeof(*s_user_snapshot));
return 1;
}
if (s_user_snapshot.admin_count == 0U) {
if (s_user_snapshot->admin_count == 0U) {
printf("No administrators; use 'user add <username> admin' on UART0.\n");
}
secure_wipe(s_user_snapshot, sizeof(*s_user_snapshot));
return 0;
}
+3
View File
@@ -9,6 +9,7 @@
#include "esp_heap_caps.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "web_firmware_update.h"
#include "web_server.h"
#include "web_security.h"
#include "freertos/FreeRTOS.h"
@@ -114,6 +115,8 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
}
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
if (action == ADMIN_SSH_DEFER_REBOOT) {
if (web_firmware_update_reserve_reboot() != ESP_OK)
return ESP_ERR_INVALID_STATE;
esp_restart();
return ESP_OK;
}
+7 -1
View File
@@ -218,12 +218,18 @@ esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
return require(r, mutation, upgrade, 0, view, allowed);
}
esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit,
esp_err_t web_cookie_auth_require_body(httpd_req_t *r, size_t body_limit,
web_session_view_t *view, bool *allowed)
{
return require(r, true, false, body_limit, view, allowed);
}
esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit,
web_session_view_t *view, bool *allowed)
{
return web_cookie_auth_require_body(r, body_limit, view, allowed);
}
static bool secret(char out[65])
{
uint8_t bytes[32];
+3
View File
@@ -17,6 +17,9 @@ esp_err_t web_cookie_auth_require(httpd_req_t *request, bool mutation,
bool upgrade, web_session_view_t *view,
bool *allowed);
esp_err_t web_cookie_auth_handler(httpd_req_t *request);
/* Same mutation policy for a bounded raw body; caller validates content type. */
esp_err_t web_cookie_auth_require_body(httpd_req_t *request, size_t body_limit,
web_session_view_t *view, bool *allowed);
/* Same mutation policy, allowing a bounded body; caller validates JSON/content type. */
esp_err_t web_cookie_auth_require_json(httpd_req_t *request, size_t body_limit,
web_session_view_t *view, bool *allowed);
+228
View File
@@ -0,0 +1,228 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_firmware_update.h"
#include <stdlib.h>
#include <stdatomic.h>
#include <string.h>
#include "esp_app_desc.h"
#include "esp_app_format.h"
#include "esp_image_format.h"
#include "esp_ota_ops.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "secure_random.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#include "web_security.h"
#define BUFFER_SIZE 4096U
#define STALL_US 10000000LL
#define TOTAL_US 120000000LL
#define PREFIX_SIZE (sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t))
/* A successful reservation is retained through reset, not a check-then-reset.
* Only the upload owner accesses the selected latch while holding this gate. */
static atomic_bool s_reboot_gate;
static bool s_firmware_selected;
esp_err_t web_firmware_update_reserve_reboot(void)
{
bool expected = false;
return atomic_compare_exchange_strong(&s_reboot_gate, &expected, true) ?
ESP_OK : ESP_ERR_INVALID_STATE;
}
/* Allocated before boot selection. No HTTPD stop, queue allocation, captured
* request or socket in this owner. A successful send is not proof of receipt. */
static void reboot_owner(void *argument)
{
(void)argument;
uint32_t decision = 0;
xTaskNotifyWait(0, UINT32_MAX, &decision, portMAX_DELAY);
if (decision == 1) {
vTaskDelay(pdMS_TO_TICKS(500));
esp_restart();
}
vTaskDelete(NULL);
}
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);
/* Reject without HTTPD's unbounded discard of the remaining request body. */
return web_httpd_unread_body(request) ? ESP_FAIL : error;
}
static esp_err_t content_length(httpd_req_t *request, size_t limit)
{
/* Auth's adapter already rejects duplicate headers. IDF 5.5.0 parses a
* uint64_t length then narrows it to size_t; never trust that value alone.
* Its getters strip leading spaces only. Require 1..20 decimal digits. */
char raw[21];
size_t length = httpd_req_get_hdr_value_len(request, "Content-Length");
if (!length || length >= sizeof(raw) ||
httpd_req_get_hdr_value_str(request, "Content-Length", raw, sizeof(raw)) != ESP_OK ||
strspn(raw, "0123456789") != length)
return ESP_ERR_INVALID_ARG;
size_t value = 0;
for (size_t i = 0; i < length; ++i) {
unsigned digit = (unsigned)(raw[i] - '0');
if (value > limit / 10 || (value == limit / 10 && digit > limit % 10))
return ESP_ERR_INVALID_SIZE;
value = value * 10 + digit;
}
return value == request->content_len ? ESP_OK : ESP_ERR_INVALID_ARG;
}
static bool application_prefix(const uint8_t *buffer)
{
esp_image_header_t header;
esp_image_segment_header_t segment;
uint32_t magic;
memcpy(&header, buffer, sizeof(header));
memcpy(&segment, buffer + sizeof(header), sizeof(segment));
memcpy(&magic, buffer + sizeof(header) + sizeof(segment), sizeof(magic));
/* App descriptor distinguishes app images from bootloader/full-flash data.
* SDK end validation remains authoritative for revision, segments and hash. */
return header.magic == ESP_IMAGE_HEADER_MAGIC &&
header.chip_id == ESP_CHIP_ID_ESP32S3 &&
header.segment_count > 0 && header.segment_count <= ESP_IMAGE_MAX_SEGMENTS &&
header.hash_appended == 1 && segment.data_len >= sizeof(esp_app_desc_t) &&
magic == ESP_APP_DESC_MAGIC_WORD;
}
esp_err_t web_firmware_update_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
bool allowed = false, reserved = false, active = false, gate_owned = false;
uint32_t identity_token = 0;
uint8_t *buffer = NULL;
TaskHandle_t reboot_task = NULL;
esp_ota_handle_t ota = 0;
const char *status = "500 Internal Server Error";
const char *body = "{\"error\":\"firmware_write_failed\"}";
/* Auth rejects ambiguous headers/transfer encoding before any body IO.
* The actual inactive partition, not a compiled slot size, bounds the body. */
esp_err_t error = web_cookie_auth_require_body(request, SIZE_MAX, &view, &allowed);
if (error != ESP_OK || !allowed) goto done;
#define REJECT(s, b) do { status = (s); body = "{\"error\":\"" b "\"}"; goto failed; } while (0)
if (view.principal.role != USER_ROLE_ADMIN)
REJECT("403 Forbidden", "admin_required");
char type[40] = {0};
if (httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) != ESP_OK ||
strcmp(type, "application/octet-stream"))
REJECT("415 Unsupported Media Type", "firmware_content_type");
const esp_partition_t *running = esp_ota_get_running_partition();
const esp_partition_t *target = esp_ota_get_next_update_partition(NULL);
if (!running || !target || target->type != ESP_PARTITION_TYPE_APP ||
target->subtype < ESP_PARTITION_SUBTYPE_APP_OTA_0 ||
target->subtype > ESP_PARTITION_SUBTYPE_APP_OTA_15 ||
target->address == running->address)
REJECT("503 Service Unavailable", "firmware_unavailable");
esp_err_t length_error = content_length(request, target->size);
if (length_error == ESP_ERR_INVALID_SIZE)
REJECT("413 Payload Too Large", "firmware_too_large");
if (length_error != ESP_OK)
REJECT("400 Bad Request", "invalid_request");
if (request->content_len < PREFIX_SIZE)
REJECT("400 Bad Request", "invalid_firmware");
if (web_firmware_update_reserve_reboot() != ESP_OK)
REJECT("503 Service Unavailable", "busy");
gate_owned = true;
if (s_firmware_selected)
REJECT("409 Conflict", "firmware_selected_reboot_required");
if (web_firmware_update_reserve(request->handle) != ESP_OK)
REJECT("503 Service Unavailable", "busy");
reserved = true;
/* Also exclude direct canonical identity mutations which bypass server. */
if (web_security_reserve_identity(0, false, &identity_token) != ESP_OK)
REJECT("503 Service Unavailable", "busy");
buffer = malloc(BUFFER_SIZE);
if (!buffer || xTaskCreate(reboot_owner, "fw_reboot", 2048, NULL, 5, &reboot_task) != pdPASS)
REJECT("503 Service Unavailable", "firmware_resources");
size_t received = 0, prefix = 0;
int64_t started = esp_timer_get_time(), last_progress = started;
while (received < request->content_len) {
int64_t now = esp_timer_get_time();
if (now - started >= TOTAL_US || now - last_progress >= STALL_US)
REJECT("408 Request Timeout", "firmware_timeout");
/* Accumulate the entire prefix, even when TLS gives one byte at a time. */
size_t want = active ? request->content_len - received : PREFIX_SIZE - prefix;
if (want > BUFFER_SIZE) want = BUFFER_SIZE;
int count = httpd_req_recv(request, (char *)buffer + (active ? 0 : prefix), want);
now = esp_timer_get_time();
if (now - started >= TOTAL_US || now - last_progress >= STALL_US)
REJECT("408 Request Timeout", "firmware_timeout");
if (count == HTTPD_SOCK_ERR_TIMEOUT) continue;
if (count <= 0 || (size_t)count > want)
REJECT("400 Bad Request", "firmware_incomplete");
received += (size_t)count;
last_progress = now;
size_t write_size = (size_t)count;
if (!active) {
prefix += (size_t)count;
if (prefix < PREFIX_SIZE) continue;
if (!application_prefix(buffer))
REJECT("400 Bad Request", "invalid_firmware");
esp_err_t begin_error = esp_ota_begin(target, request->content_len, &ota);
/* IDF 5.5.0 can publish a live handle before an erase failure. */
active = ota != 0;
if (begin_error != ESP_OK) goto failed;
write_size = prefix;
}
if (esp_ota_write(ota, buffer, write_size) != ESP_OK) goto failed;
}
if (esp_timer_get_time() - started >= TOTAL_US)
REJECT("408 Request Timeout", "firmware_timeout");
/* esp_ota_end consumes the handle even on validation failure. */
active = false;
if (esp_ota_end(ota) != ESP_OK)
REJECT("400 Bad Request", "invalid_firmware");
/* SDK end validates flash, but does not compare its parsed image length to
* our HTTP length. Reject truncation into old flash and appended garbage. */
_Static_assert(sizeof(esp_image_metadata_t) <= BUFFER_SIZE, "metadata fits upload buffer");
esp_image_metadata_t *metadata = (esp_image_metadata_t *)buffer;
esp_partition_pos_t position = {.offset = target->address, .size = target->size};
if (esp_image_get_metadata(&position, metadata) != ESP_OK || metadata->image_len != received)
REJECT("400 Bad Request", "invalid_firmware");
bool current = false;
if (web_session_store_check_principal(view.id, &view.principal, &current) != ESP_OK || !current)
REJECT("401 Unauthorized", "authentication_required");
if (esp_ota_set_boot_partition(target) != ESP_OK)
REJECT("500 Internal Server Error", "firmware_commit_failed");
s_firmware_selected = true;
/* Commit is irreversible here. Failed send leaves the selected image for a
* later reboot, but deliberately does not schedule this upload's reboot.
* Never retry an upload automatically after a lost acknowledgement. */
error = respond(request, "200 OK", "{\"ok\":true,\"rebooting\":true}");
if (error == ESP_OK) {
xTaskNotify(reboot_task, 1, eSetValueWithOverwrite);
reboot_task = NULL;
reserved = false; /* Retain service/identity reservations until reset. */
identity_token = 0;
gate_owned = false; /* Reboot owner retains exclusion until reset. */
}
goto done;
failed:
if (active) { esp_ota_abort(ota); active = false; }
error = respond(request, status, body);
done:
if (reboot_task) xTaskNotify(reboot_task, 2, eSetValueWithOverwrite);
if (identity_token) web_security_release_identity(identity_token);
if (reserved) web_firmware_update_release();
free(buffer);
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
if (gate_owned) atomic_store(&s_reboot_gate, false);
return error;
#undef REJECT
}
+18
View File
@@ -0,0 +1,18 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_http_server.h"
#define WEB_FIRMWARE_UPDATE_URI "/api/firmware"
esp_err_t web_firmware_update_handler(httpd_req_t *request);
/* Atomically exclude uploads until reset. Call immediately before an ordinary
* reboot (and before any acknowledgement delay); failure means do not reset.
* Independent of HTTPS initialization, so UART0 recovery remains available. */
esp_err_t web_firmware_update_reserve_reboot(void);
/* Internal HTTPS owner reservation. HTTPD takes before flash and releases on
* failure; successful response transfers lifetime to the reboot owner. No
* service mutex is held during receive/flash/response. Implemented by server. */
esp_err_t web_firmware_update_reserve(httpd_handle_t server);
void web_firmware_update_release(void);
+31
View File
@@ -31,6 +31,7 @@
#include "web_broker_settings.h"
#include "web_ssh_settings.h"
#include "web_lifecycle_settings.h"
#include "web_firmware_update.h"
#include "web_admin_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
@@ -573,6 +574,12 @@ static const httpd_uri_t s_logo_uri = {
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_LOGO_PNG,
};
static const httpd_uri_t s_firmware_uri = {
.uri = WEB_FIRMWARE_UPDATE_URI,
.method = HTTP_POST,
.handler = web_firmware_update_handler,
};
static const httpd_uri_t *const s_uri_handlers[] = {
&s_root_uri,
&s_status_uri,
@@ -583,6 +590,7 @@ static const httpd_uri_t *const s_uri_handlers[] = {
&s_addon_fit_js_uri,
&s_app_js_uri,
&s_logo_uri,
&s_firmware_uri,
};
static const httpd_uri_t s_auth_uris[] = {
@@ -944,6 +952,29 @@ esp_err_t web_server_restart_current(uint32_t expected_generation)
return stop_server(expected_generation, true, false);
}
/* The upload retains the existing lifecycle fence, not the mutex. This also
* excludes canonical stop/start and service-coordinated identity replacement. */
esp_err_t web_firmware_update_reserve(httpd_handle_t server)
{
if (!s_server_mutex || xSemaphoreTake(s_server_mutex, 0U) != pdTRUE)
return ESP_ERR_INVALID_STATE;
if (!server || s_server != server || s_transitioning || s_last_error != ESP_OK) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
s_transitioning = true;
if (s_generation != UINT32_MAX) ++s_generation;
xSemaphoreGive(s_server_mutex);
return ESP_OK;
}
void web_firmware_update_release(void)
{
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
xSemaphoreGive(s_server_mutex);
}
esp_err_t web_server_reboot_current(uint32_t expected_generation)
{
if (!expected_generation) return ESP_ERR_INVALID_ARG;
+87 -29
View File
@@ -26,9 +26,13 @@ static const char s_index_html[] =
"--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n"
"*{box-sizing:border-box}\n"
"[hidden]{display:none!important}\n"
".quick-trigger{background:none;border:0;padding:0;color:inherit;text-align:left;cursor:pointer}.quick-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:3px}\n"
".quick-trigger{display:block;width:100%;color:inherit;text-align:left;font:inherit;cursor:pointer;overflow:hidden}.quick-trigger:disabled{cursor:default}.quick-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:3px}\n"
".settings-page[data-quick='true']{position:fixed;z-index:10;top:8px;right:8px;width:min(600px,calc(100vw - 16px));max-height:calc(100vh - 16px);max-height:calc(100dvh - 16px);overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:var(--radius);padding:16px;box-shadow:0 8px 32px #0008}\n"
"[data-quick='true']>#settings-navigation,[data-quick='true'] #network-password-mode-label,[data-quick='true'] #network-password-label,[data-quick='true'] #network-password-status{display:none}\n"
"[data-quick='true'] .full-settings-only,[data-quick='true'] #settings-navigation,[data-quick='true'] #quick-help,[data-quick='true'] h3,[data-quick='true']>div>h2,[data-quick='true'] p:not([role=status]),[data-quick='true'] #network-summary,[data-quick='true'] #broker-values,[data-quick='true'] #network-ssid-detail{display:none}\n"
"[data-quick='true'] #quick-header>h2{display:block}[data-quick='true'] #settings-values>:nth-child(n+3){display:none}\n"
"[data-quick='true'] #network-edit>*{display:none}[data-quick='true'] #network-edit>.settings-edit:first-of-type{display:grid}[data-quick='true'] #network-edit>.network-quick-actions{display:flex}\n"
"[data-quick='true'] #network-edit>.settings-edit>label{display:none}[data-quick='true'] #network-edit>.settings-edit>#network-target-label,[data-quick='true'] #network-edit>.settings-edit>#network-policy-label{display:grid}[data-quick='true'] #network-edit>.settings-edit>#network-enabled-label{display:flex}\n"
"[data-quick='true'] #network-wifi-load{display:none}\n"
"html,body{height:100%;margin:0;overflow:hidden}\n"
"body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);"
"color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"
@@ -55,7 +59,7 @@ static const char s_index_html[] =
".dashboard{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(280px,.65fr);gap:16px}\n"
".panel{background:linear-gradient(145deg,#151e2cdd,#0f1621ee);border:1px solid var(--line);"
"border-radius:var(--radius);box-shadow:0 16px 45px #0005}\n"
".status-grid{padding:16px;display:grid;grid-template-columns:repeat(4,minmax(105px,1fr));gap:12px}\n"
".status-grid{min-width:0;padding:16px;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}\n"
".status-item{min-width:0;padding:10px 12px;background:#0b111b99;border:1px solid #202c3e;"
"border-radius:10px}\n"
".status-item.wide{grid-column:span 2}\n"
@@ -152,14 +156,10 @@ static const char s_index_html[] =
"<span id=\"role-status\" class=\"badge\" data-tone=\"warn\">Observer</span></div>\n"
"<div class=\"status-item\"><span class=\"label\">Broker client</span>"
"<span id=\"client-id\" class=\"value\">—</span></div>\n"
"<div class=\"status-item\"><span class=\"label\">Active writer</span>"
"<button type=\"button\" id=\"quick-writer\" class=\"quick-trigger\" aria-label=\"Active writer management\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"writer-id\" class=\"value\">None</span></button></div>\n"
"<div class=\"status-item\"><span class=\"label\">Broker clients</span>"
"<button type=\"button\" id=\"quick-broker\" class=\"quick-trigger\" aria-label=\"Live broker clients\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"broker-clients\" class=\"value\"></span></button></div>\n"
"<div class=\"status-item wide\"><span class=\"label\">Wi-Fi</span>"
"<button type=\"button\" id=\"quick-network\" class=\"quick-trigger\" aria-label=\"Wi-Fi quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"wifi-summary\" class=\"value\">Loading…</span></button></div>\n"
"<div class=\"status-item wide\"><span class=\"label\">Serial</span>"
"<button type=\"button\" id=\"quick-serial\" class=\"quick-trigger\" aria-label=\"Serial quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span id=\"serial-summary\" class=\"value\">Loading…</span></button></div>\n"
"<button type=\"button\" id=\"quick-writer\" class=\"status-item quick-trigger\" aria-label=\"Active writer management\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span class=\"label\">Active writer</span><span id=\"writer-id\" class=\"value\">None</span></button>\n"
"<button type=\"button\" id=\"quick-broker\" class=\"status-item quick-trigger\" aria-label=\"Live broker clients\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span class=\"label\">Broker clients</span><span id=\"broker-clients\" class=\"value\"></span></button>\n"
"<button type=\"button\" id=\"quick-network\" class=\"status-item wide quick-trigger\" aria-label=\"Wi-Fi quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span class=\"label\">Wi-Fi</span><span id=\"wifi-summary\" class=\"value\">Loading…</span></button>\n"
"<button type=\"button\" id=\"quick-serial\" class=\"status-item wide quick-trigger\" aria-label=\"Serial quick settings\" aria-controls=\"serial-settings\" aria-expanded=\"false\" aria-haspopup=\"dialog\" disabled><span class=\"label\">Serial</span><span id=\"serial-summary\" class=\"value\">Loading…</span></button>\n"
"</div>\n"
"<div class=\"panel controls\">\n"
"<div class=\"button-row\">\n"
@@ -192,6 +192,7 @@ static const char s_index_html[] =
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button><button id=\"settings-broker\" class=\"button\" type=\"button\" aria-pressed=\"false\">Broker</button><button id=\"settings-ssh\" class=\"button\" type=\"button\" aria-pressed=\"false\">SSH</button><button id=\"settings-lifecycle\" class=\"button\" type=\"button\" aria-pressed=\"false\">HTTPS / Reboot</button></div>"
"<div id=\"lifecycle-settings\" hidden><h2>HTTPS identity, service and device reboot</h2>"
"<section aria-label=\"Firmware upload\"><h3>Firmware update</h3><p class=\"connection-detail\">Select the firmware.bin built for this device (max. 4 MiB). Saved settings are kept. Keep power connected during the update.</p><div class=\"settings-edit\"><label>Application .bin <input id=\"firmware-file\" type=\"file\" accept=\".bin\"></label></div><button id=\"firmware-upload\" class=\"button\" type=\"button\">Upload and reboot…</button><progress id=\"firmware-progress\" max=\"100\" value=\"0\" aria-label=\"Firmware upload progress\"></progress><p id=\"firmware-detail\" class=\"connection-detail\" role=\"status\">Choose a firmware file to begin.</p><a href=\"/\">Reconnect after reboot</a></section>"
"<p class=\"connection-detail\">Stop/Restart HTTPS closes ALL web logins and both browser terminal routes, including clients admitted before execution. Settings and certificate identity are unchanged; HTTPS restart preserves device working configuration. Save unsaved browser drafts first. Recover a stopped web service with <code>web start</code> through UART0 or still-running, reachable admin SSH. USB remains UART1 serial access, not a web administration console.</p>"
"<p class=\"connection-detail\">Reboot interrupts ALL clients and the entire device, including SSH, USB and UART operation during restart. Unsaved RAM-only working configuration and browser drafts can be lost. Saved configuration and identities are not reset. After boot, restore network reachability, reload and sign in explicitly; inspect the outcome before another action.</p>"
"<p class=\"connection-detail\">Rotate replaces and persists the HTTPS certificate AND private key, changes browser trust, and disconnects all web logins/terminals. No SSH identity or user/configuration change. Verify the NEW SHA-256 certificate fingerprint using trusted UART0 (<code>web certificate info</code>) before accepting browser trust; a certificate warning is not verification. Reload and sign in freshly. Native USB remains independent UART1 serial access, not administration. No browser TLS reset/recovery or key/certificate export.</p>"
@@ -201,7 +202,7 @@ static const char s_index_html[] =
"<div class=\"serial-actions\"><button id=\"lifecycle-stop\" class=\"button\" type=\"button\">Stop HTTPS…</button><button id=\"lifecycle-restart\" class=\"button\" type=\"button\">Restart HTTPS…</button><button id=\"lifecycle-reboot\" class=\"button\" type=\"button\">Reboot device…</button><button id=\"lifecycle-rotate\" class=\"button\" type=\"button\">Rotate HTTPS identity…</button><button id=\"lifecycle-result\" class=\"button\" type=\"button\">Check Operation Result</button></div>"
"<p id=\"lifecycle-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Acknowledgement is not peer receipt or completion. Connection loss, expiry, revocation or timeout does not prove cancellation after admission. No automatic mutation retry or restore. Check Result, inspect state, then act explicitly.</p><a href=\"/\">Reload / sign in after recovery</a></div>\n"
"<div id=\"ssh-settings\" hidden><h2>SSH service and sessions</h2><p class=\"connection-detail\">SSH only, TCP port 22. Start/Stop do not change saved settings or host identity. Stop closes all SSH sessions, including any admitted before execution; an SSH administrator's already executing command may finish. HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped. Targeted disconnect affects only the selected SSH connection, not all logins for its account. Viewing or selecting never changes services or writer ownership.</p><button id=\"ssh-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"ssh-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"ssh-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Disconnect SSH session<select id=\"ssh-target\"><option value=\"\">Select a session</option><option id=\"ssh-option-0\" hidden disabled></option><option id=\"ssh-option-1\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"ssh-start\" class=\"button\" type=\"button\">Start SSH…</button><button id=\"ssh-stop\" class=\"button\" type=\"button\">Stop SSH…</button><button id=\"ssh-rotate\" class=\"button\" type=\"button\">Rotate SSH host identity…</button><button id=\"ssh-disconnect\" class=\"button\" type=\"button\">Disconnect selected…</button><button id=\"ssh-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"ssh-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. After submission use Check Operation Result, then Refresh. Navigation or timeout does not cancel admitted work. No automatic mutation retry.</p></div>\n"
"<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh retains explicit selection without renewing its lease token. Stale selections require choosing the blank option then the target again. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</p></div>\n"
"<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh retains explicit selection without renewing its lease token. Stale selections require choosing the blank option then the target again. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\"><span class=\"full-settings-only\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</span></p></div>\n"
"<div id=\"display-settings\" hidden><h2>Display</h2>\n"
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 086400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
"<p class=\"connection-detail\">Apply and Defaults change RAM only. Save persists the working snapshot, not browser drafts. Load discards drafts and uses stored settings, or defaults if storage is absent/incompatible; it does not change NVS. Reset saves defaults and applies them. Refresh discards drafts. Intervening configuration edits reject stale operations: Refresh and review before retrying.</p>\n"
@@ -227,9 +228,9 @@ static const char s_index_html[] =
"<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-target-label\">Profile / AP mode<select id=\"network-target\"><option value=\"ap\">Access point</option>"
"<option id=\"network-profile-0\" value=\"0\">STA profile 0</option><option id=\"network-profile-1\" value=\"1\">STA profile 1</option>"
"<option id=\"network-profile-2\" value=\"2\">STA profile 2</option><option id=\"network-profile-3\" 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>"
@@ -246,7 +247,7 @@ static const char s_index_html[] =
"Switching modes is lossless or refused. Password replacement requires 863 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>"
"<div class=\"serial-actions network-quick-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>"
@@ -261,7 +262,7 @@ static const char s_index_html[] =
"<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>"
"<p id=\"network-operation-detail\" class=\"connection-detail\" role=\"status\"><span class=\"full-settings-only\">After uncertainty, Check Result and Refresh. Never assume timeout or navigation cancels work; no automatic mutation retry.</span></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>"
@@ -296,7 +297,7 @@ static const char s_index_html[] =
"<button id=\"serial-start\" class=\"button\" type=\"button\">Start</button>"
"<button id=\"serial-stop\" class=\"button\" type=\"button\">Stop</button></div></div>"
"<button id=\"serial-result\" class=\"button\" type=\"button\">Check Operation Result</button>"
"<p id=\"serial-operation-detail\" class=\"connection-detail\" role=\"status\">Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.</p></div>"
"<p id=\"serial-operation-detail\" class=\"connection-detail\" role=\"status\"><span class=\"full-settings-only\">Check Result after any uncertain submission; never assume timeout or navigation cancels an operation.</span></p></div>"
"<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>"
@@ -439,7 +440,7 @@ static const char s_app_js[] =
" element('quick-title').textContent = quickTitle(domain);\n"
" element('quick-help').textContent = brokerContext ? 'Live clients share one writer and isolated observation. Opening, refreshing and selecting never transfers the lease. Assign writer requires separate confirmation. Closing does not cancel admitted work; after uncertainty use Check Result, never automatic retry.' : 'Opening and selecting never applies changes. Apply changes RAM; Save persists working device values, not drafts. Closing discards drafts; admitted work is not cancelled. Password changes are available only on the full Network page.';\n"
" element('quick-header').hidden = false; trigger.setAttribute('aria-expanded', 'true');\n"
" terminalHost.hidden = previous !== 'serial'; adminHost.hidden = previous !== 'admin';\n"
" renderTerminalView(previous);\n"
" refreshSettings(); if (activate) element('quick-close').focus();\n"
"}\n"
"function quickContains(target) { return !!quick && !!target && (settingsHost.contains(target) || quick.trigger.contains(target)); }\n"
@@ -458,7 +459,7 @@ static const char s_app_js[] =
"settingsHost.addEventListener('pointerenter', () => window.clearTimeout(quickLeaveTimer));\n"
"settingsHost.addEventListener('pointerleave', quickLeave); settingsHost.addEventListener('focusout', quickLeave);\n"
"element('quick-close').addEventListener('click', () => closeQuick(true));\n"
"element('quick-full').addEventListener('click', event => { event.preventDefault(); if (!quick) return; resetQuick(); clearNetworkSecret(); networkButtons(); terminalHost.hidden = adminHost.hidden = true; element('settings-' + settingsDomain).focus(); });\n"
"element('quick-full').addEventListener('click', event => { event.preventDefault(); if (!quick) return; resetQuick(); clearNetworkSecret(); networkButtons(); renderTerminalView('settings'); element('settings-' + settingsDomain).focus(); });\n"
"window.addEventListener('pointerdown', event => { if (quick && !quickContains(event.target)) closeQuick(); });\n"
"window.addEventListener('keydown', event => { if (quick && event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeQuick(true); } });\n"
"function clearSettings() {\n"
@@ -498,7 +499,7 @@ static const char s_app_js[] =
" !['inactive', 'active', 'on-connect'].includes(value.dtr) || !Number.isInteger(value.rts_threshold) || value.rts_threshold < 1 || value.rts_threshold > 127) throw new Error('Invalid snapshot');\n"
" for (const key of settingsFields) element('setting-' + key).textContent = key === 'running' ? (value[key] ? 'Running' : 'Stopped') : String(value[key]);\n"
" for (const key of settingsFields.slice(1)) element('edit-' + key).value = String(value[key]);\n"
" element('serial-edit').hidden = false; element('settings-values').hidden = false; settingsDetail.textContent = (serialOperationPending ? 'Snapshot may be stale: operation outcome pending or unknown. ' : 'Working snapshot loaded. ') + 'Apply changes RAM; Save explicitly persists working settings. Refresh replaces your draft.';\n"
" element('serial-edit').hidden = false; element('settings-values').hidden = false; settingsDetail.textContent = (serialOperationPending ? 'Snapshot may be stale: operation outcome pending or unknown. ' : 'Working snapshot loaded. ') + (quick ? '' : 'Apply changes RAM; Save explicitly persists working settings. Refresh replaces your draft.');\n"
" } catch (error) {\n"
" if (live(generation) && current()) settingsDetail.textContent = (error.status ? error.message : 'Serial snapshot could not be read or was invalid.') + ' Snapshot stale or unavailable. Select Refresh to retry.';\n"
" } finally {\n"
@@ -817,7 +818,7 @@ static const char s_app_js[] =
" }\n"
" }\n"
" element('broker-target').value = brokerSelection && !brokerSelection.missing && brokerClients.some(c => c.id === brokerSelection.target) ? String(brokerSelection.target) : '';\n"
" brokerDetail.textContent = (brokerOperationPending ? 'Outcome pending or unknown. ' : '') + 'Writer: ' + (brokerWriter || 'None') + '. ' + brokerClients.length + ' connected clients. ' + (brokerSelection?.stale ? 'Selection stale: lease or client changed. Choose the blank option then explicitly select again before confirmation. ' : 'Refresh preserves selection, never renews its confirmation token. ') + (quick ? 'Live refresh every 5 seconds after completion. ' : '') + (brokerGeneration === 4294967295 ? 'Confirmation generation exhausted; use the admin shell.' : '');\n"
" brokerDetail.textContent = (brokerOperationPending ? 'Outcome pending or unknown. ' : '') + 'Writer: ' + (brokerWriter || 'None') + '. ' + brokerClients.length + ' connected clients. ' + (brokerSelection?.stale ? 'Selection stale: choose the blank option, then reselect a client. ' : quick ? '' : 'Refresh preserves selection, never renews its confirmation token. ') + (brokerGeneration === 4294967295 ? 'Confirmation generation exhausted; use the admin shell.' : '');\n"
" } catch (error) {\n"
" if (live(generation) && current()) { brokerGeneration = 0; if (brokerSelection) brokerSelection.stale = true; brokerDetail.textContent = (error.status ? error.message : 'Broker snapshot unavailable or invalid.') + ' Selection stale. Select Refresh and reselect to retry.'; }\n"
" } finally { window.clearTimeout(deadline); if (current()) { brokerAbort = null; brokerButtons(); if (brokerGeneration) scheduleBrokerLive(); } }\n"
@@ -895,11 +896,59 @@ static const char s_app_js[] =
" if (brokerSelection) brokerDetail.textContent = 'Explicit selection: ' + brokerLabel(client) + '. Confirm assignment separately; refresh never renews this lease token.';\n"
" brokerButtons();\n"
"});\n"
"let firmwareWork = null, firmwareLocked = false;\n"
"const firmwareUncertain = 'Update status unknown. The firmware may already be installed. Reconnect and check before retrying.';\n"
"const firmwareWarning = 'Upload firmware and reboot? All connections will close. Saved settings are kept; unsaved changes will be lost.';\n"
"function firmwareButtons() {\n"
" element('firmware-file').disabled = element('firmware-upload').disabled = !!firmwareWork || firmwareLocked || !!lifecycleAbort || lifecyclePending;\n"
"}\n"
"function cancelFirmware() {\n"
" const work = firmwareWork; firmwareWork = null; element('firmware-file').value = '';\n"
" if (work) { firmwareLocked = true; work.controller.abort(); work.xhr?.abort(); element('firmware-detail').textContent = firmwareUncertain; }\n"
" firmwareButtons();\n"
"}\n"
"async function uploadFirmware() {\n"
" if (firmwareWork || firmwareLocked || lifecycleAbort || lifecyclePending || selected !== 'settings' || settingsDomain !== 'lifecycle' || accountRole !== 'admin' || !sessionVerified || !csrf || suspended || unloading || navigating || loggingOut) return;\n"
" const file = element('firmware-file').files?.[0], detail = element('firmware-detail'), progress = element('firmware-progress');\n"
" if (!file || !/\\.bin$/i.test(file.name) || !Number.isSafeInteger(file.size) || file.size < 1 || file.size > 4 * 1024 * 1024) { detail.textContent = 'Choose a nonempty .bin file up to 4 MiB.'; return; }\n"
" if (!window.confirm(firmwareWarning)) return;\n"
" const generation = workGeneration, work = {controller:new AbortController(), xhr:null}; firmwareWork = work; lifecycleButtons();\n"
" const current = () => firmwareWork === work && live(generation) && sessionVerified && accountRole === 'admin' && !suspended && !loggingOut;\n"
" const finish = (message, locked) => { if (!current()) return; firmwareWork = null; firmwareLocked = locked; detail.textContent = message; element('firmware-file').value = ''; lifecycleButtons(); };\n"
" progress.value = 0; detail.textContent = 'Checking session…';\n"
" try {\n"
" if (!await loadSession(generation, work.controller.signal, false)) { finish('Session changed; no upload sent. Sign in again.', false); return; }\n"
" if (!current()) return;\n"
" const xhr = new XMLHttpRequest(); work.xhr = xhr;\n"
" // XHR uses CORS mode and same-origin cookies. File supplies the known body length; never set Origin or Content-Length manually.\n"
" xhr.open('POST', '/api/firmware'); xhr.timeout = 180000;\n"
" xhr.setRequestHeader('Content-Type', 'application/octet-stream'); xhr.setRequestHeader('X-CSRF-Token', csrf);\n"
" xhr.upload.onprogress = event => {\n"
" if (!current()) return;\n"
" if (event.lengthComputable && Number.isFinite(event.total) && event.total > 0 && Number.isFinite(event.loaded) && event.loaded >= 0) { progress.value = Math.min(100, Math.floor(event.loaded * 100 / event.total)); detail.textContent = progress.value === 100 ? 'Upload complete. Validating firmware…' : 'Uploading: ' + progress.value + '%'; }\n"
" else detail.textContent = 'Uploading… Progress unavailable.';\n"
" };\n"
" xhr.onerror = xhr.ontimeout = xhr.onabort = () => finish(firmwareUncertain, true);\n"
" xhr.onload = () => {\n"
" if (!current()) return;\n"
" if (xhr.status === 401) { cancelFirmware(); login(); return; }\n"
" let value; try { if (xhr.responseText.length > 128) throw new Error(); value = JSON.parse(xhr.responseText); } catch (_) { finish(firmwareUncertain, true); return; }\n"
" if (xhr.status === 200 && value && Object.keys(value).length === 2 && value.ok === true && value.rebooting === true) { progress.value = 100; finish('Firmware accepted; rebooting. Reconnect and sign in shortly.', true); return; }\n"
" const errors = {invalid_request:'Invalid upload request.', invalid_firmware:'Invalid or incompatible application image.', firmware_incomplete:'Incomplete firmware image.', authentication_required:'Session ended.', origin:'Origin security check failed.', csrf:'Session security check failed.', admin_required:'Administrator access required.', firmware_timeout:'Device upload deadline exceeded.', firmware_too_large:'Image exceeds the device partition capacity.', firmware_content_type:'Raw binary content type required.', firmware_write_failed:'Firmware write failed.', firmware_commit_failed:'Firmware commit failed; inspect the device before any reset or retry.', unavailable:'Service unavailable.', busy:'Device is busy.', firmware_unavailable:'Firmware update partition unavailable.', firmware_resources:'Insufficient device resources.'};\n"
" const code = value && Object.keys(value).length === 1 && value.error;\n"
" if (xhr.status >= 400 && Object.hasOwn(errors, code)) { finish(errors[code], ['authentication_required','origin','csrf','admin_required','firmware_commit_failed'].includes(code)); }\n"
" else finish(firmwareUncertain, true);\n"
" };\n"
" detail.textContent = 'Uploading… Keep power connected.'; xhr.send(file);\n"
" } catch (_) { finish(work.xhr ? firmwareUncertain : 'Session check failed; no upload sent. Reconnect and try again.', !!work.xhr); }\n"
"}\n"
"element('firmware-upload').addEventListener('click', uploadFirmware);\n"
"const lifecycleActions = ['stop','restart','reboot','rotate'];\n"
"let lifecycleSnapshot = null, lifecycleAbort = null, lifecyclePending = false, lifecycleAwaitingAck = false, lifecycleId = 0, lifecycleAction = '';\n"
"const lifecycleRecovery = 'Outcome may be unknown; no automatic retry. HTTPS stop: use UART0 or reachable admin SSH web start. HTTPS restart expires this login; reload and sign in again. Reboot interrupts every client, including USB; restore network after boot, reload/sign in and inspect before acting again. Rotation may have persisted a NEW identity even when stop/start fails; no rollback. The stored fingerprint may differ from a retained old server certificate. Inspect with trusted UART0 web certificate info, verify the new fingerprint before renewing trust, then reload and sign in freshly. SSH and native USB UART1 access are not stopped by rotation. A stuck ACK handoff requires canonical web stop then web start; this closes all web clients.';\n"
"function lifecycleButtons() {\n"
" const busy = !!lifecycleAbort;\n"
" firmwareButtons();\n"
" const busy = !!lifecycleAbort || !!firmwareWork || firmwareLocked;\n"
" element('lifecycle-refresh').disabled = element('lifecycle-result').disabled = busy;\n"
" for (const action of lifecycleActions) element('lifecycle-' + action).disabled = busy || lifecyclePending || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable);\n"
"}\n"
@@ -911,7 +960,7 @@ static const char s_app_js[] =
" lifecycleButtons();\n"
"}\n"
"async function lifecycleRequest(action, snapshotRead = false) {\n"
" if (settingsDomain !== 'lifecycle' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || lifecycleAbort || (action && lifecyclePending)) return;\n"
" if (settingsDomain !== 'lifecycle' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || firmwareWork || firmwareLocked || lifecycleAbort || (action && lifecyclePending)) return;\n"
" let body; const detail = element(snapshotRead ? 'lifecycle-detail' : 'lifecycle-operation-detail');\n"
" if (action) {\n"
" if (!lifecycleActions.includes(action) || !lifecycleSnapshot?.controllable || (action === 'rotate' && !lifecycleSnapshot?.rotatable)) return;\n"
@@ -1283,6 +1332,11 @@ static const char s_app_js[] =
" net('refresh').disabled = net('result').disabled = busy;\n"
" net('password-clear').hidden = net('password-clear').disabled = net('target').value === 'ap';\n"
" if (quick) { clearNetworkSecret(); net('password-mode').disabled = true; }\n"
" for (let i = 0; i < 4; ++i) {\n"
" const option = net('profile-' + i), profile = networkSnapshot?.wifi.profiles[i];\n"
" option.textContent = 'STA ' + i + (profile?.ssid ? ' — ' + networkSSIDSummary(profile.ssid) : ' (empty)');\n"
" option.hidden = option.disabled = !!quick && !profile?.ssid;\n"
" }\n"
"}\n"
"function clearNetwork() {\n"
" clearNetworkSecret();\n"
@@ -1357,9 +1411,9 @@ static const char s_app_js[] =
" ['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"
" if (!['ap','0','1','2','3'].includes(net('target').value) || quick && net('target').value !== 'ap' && !w.profiles[Number(net('target').value)]?.ssid) 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"
" net('detail').textContent = (networkPending ? 'Snapshot may be stale: outcome pending or unknown. ' : '') + (quick ? r.state + ' · IP: ' + (r.ip || 'none') + ' · AP: ' + (r.ap_running ? 'running' : 'off') + ' · Profile: ' + (r.active_profile < 0 ? 'none' : r.active_profile) : '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"
@@ -1555,15 +1609,18 @@ static const char s_app_js[] =
" for (let offset = 0; offset < bytes.length; offset += 512) adminSocket.send(bytes.subarray(offset, offset + 512));\n"
" });\n"
" }\n"
" renderTerminalView(mode);\n"
" settingsHost.hidden = mode !== 'settings';\n"
" lastFitWidth = lastFitHeight = 0; updateControls(); scheduleFit();\n"
" if (mode === 'settings') { if (read) refreshSettings(); } else if (focus) (mode === 'serial' ? terminal : adminTerminal).focus();\n"
"}\n"
"function renderTerminalView(mode) {\n"
" terminalHost.hidden = mode !== 'serial'; adminHost.hidden = mode !== 'admin';\n"
" adminToggle.hidden = adminDetail.hidden = mode !== 'admin';\n"
" element('select-serial').setAttribute('aria-pressed', String(mode === 'serial'));\n"
" element('select-admin').setAttribute('aria-pressed', String(mode === 'admin'));\n"
" element('select-settings').setAttribute('aria-pressed', String(mode === 'settings'));\n"
" settingsHost.hidden = mode !== 'settings';\n"
" element('terminal-title').textContent = mode === 'serial' ? 'Live serial stream' : mode === 'admin' ? 'Administration shell' : 'Settings';\n"
" lastFitWidth = lastFitHeight = 0; updateControls(); scheduleFit();\n"
" if (mode === 'settings') { if (read) refreshSettings(); } else if (focus) (mode === 'serial' ? terminal : adminTerminal).focus();\n"
"}\n"
"async function openAdmin() {\n"
" if (accountRole !== 'admin' || selected !== 'admin' || unloading || navigating || loggingOut || suspended || !csrf || adminSocket || adminAbort) return;\n"
@@ -1631,6 +1688,7 @@ static const char s_app_js[] =
"const requests = new Set();\n"
"const live = (generation) => generation === workGeneration && !unloading && !navigating;\n"
"const cancelWork = () => {\n"
" cancelFirmware();\n"
" clearSettings();\n"
" ++workGeneration;\n"
" ++connectionGeneration;\n"
+37 -7
View File
@@ -138,8 +138,9 @@ static bool admin_ssh_console_dispatch_is_current(void) {
if (checks==revoke_check) owner_current=false;
return owner_current && user_database_principal_is_current(&actor, &current)==ESP_OK && current;
}
static int admin_command_gate_take(void) { return ESP_OK; }
static void admin_command_gate_give(void) {}
static bool gate_held;
static int admin_command_gate_take(void) { assert(!gate_held); gate_held=true; return ESP_OK; }
static void admin_command_gate_give(void) { assert(gate_held); gate_held=false; }
static int console_input_read_hidden(const char *prompt, uint8_t *out, size_t cap,
size_t min, size_t max, size_t *n) {
(void)prompt; (void)min; (void)max; assert(cap>=13); ++prompts;
@@ -158,7 +159,28 @@ static int ssh_transport_revoke_user(const uint8_t *u, size_t n) {
++ssh_revokes; assert(strlen(revoked_name)==n && !memcmp(u,revoked_name,n)); return notify_error;
}
/* Forbidden paths are traps rather than alternative implementations. */
static int show_users(const char *n) { (void)n; return 0; }
#define MALLOC_CAP_SPIRAM 1U
#define MALLOC_CAP_8BIT 2U
static bool fail_snapshot_alloc;
static unsigned snapshot_allocations;
static struct { uint64_t before; user_database_snapshot_t value; uint64_t after; } snapshot_memory;
static void *heap_caps_malloc(size_t n, unsigned caps) {
assert(gate_held && n==sizeof(snapshot_memory.value));
assert(caps==(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
++snapshot_allocations;
if (fail_snapshot_alloc) return NULL;
snapshot_memory.before=snapshot_memory.after=UINT64_C(0xaabbccdd11223344);
memset(&snapshot_memory.value,0xa5,n);
return &snapshot_memory.value;
}
static unsigned user_registrations;
typedef struct { const char *command,*help,*hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
static int esp_console_cmd_register(const esp_console_cmd_t *c) {
assert(!strcmp(c->command,"user") && c->func); ++user_registrations; return ESP_OK;
}
static int mbedtls_base64_encode(uint8_t *out,size_t cap,size_t *n,const uint8_t *in,size_t len) {
(void)in; assert(cap>=4 && len==32); memcpy(out,"AAAA",4); *n=4; return 0;
}
static int add_key(const char *n) { (void)n; assert(!"key mutation"); return 1; }
static int add_key_parts(const char *n,const uint8_t *t,size_t tl,const uint8_t *b,size_t bl) {
@@ -190,17 +212,21 @@ db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
"user_database_clear_ssh_keys_current", "fill_principal", "user_database_authorize_ssh_public_key",
"initialize_dummy_verifier", "user_database_init", "user_database_recover_empty",
"user_database_get_snapshot"]
console_names = ["print_usage", "revoke_user_network_sessions", "read_password",
console_names = ["print_fingerprint", "print_user", "show_users", "print_usage", "revoke_user_network_sessions", "read_password",
"show_generated_password", "mutation_currentness", "add_user", "change_password",
"parse_key_index", "recover_database", "command_user_inner", "command_user"]
"parse_key_index", "recover_database", "command_user_inner", "command_user",
"user_console_register_commands"]
unit = prelude + header + "\n" + state + fakes
unit += "\n".join(function(db, n) for n in db_names)
unit += function(admin, "admin_ssh_console_web_user_command_allowed")
unit += console[console.index("static user_database_snapshot_t"):console.index("static void print_usage")]
unit += "\n".join(function(console, n) for n in console_names)
account_tests = (ROOT / "tests/admin_console_boundary/accounts.c").read_text()
key_tests = (ROOT / "tests/admin_console_boundary/account_keys.c").read_text()
account_tests = account_tests.replace('int main(void)', key_tests + '\nint main(void)')
account_tests = account_tests.replace(' typed_account_tests();', ' typed_key_tests();\n typed_account_tests();')
snapshot_tests = (ROOT / "tests/admin_console_boundary/psram_snapshot.c").read_text()
account_tests = account_tests.replace('int main(void)', snapshot_tests + '\nint main(void)')
account_tests = account_tests.replace(' typed_account_tests();', ' psram_snapshot_tests();\n typed_key_tests();\n typed_account_tests();')
assert ' typed_key_tests();' in account_tests
unit += account_tests
with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
@@ -209,9 +235,13 @@ with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-Wno-unused-variable",
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
"-lcrypto", "-o", str(path / "test")], check=True, timeout=30)
result = subprocess.run([str(path / "test")], check=True, timeout=10, capture_output=True, text=True)
result = subprocess.run([str(path / "test")], timeout=10, capture_output=True, text=True)
if result.returncode:
print(result.stderr)
result.check_returncode()
assert "test-password" not in result.stdout
assert "Generated password for" not in result.stdout
print("PASS: PSRAM snapshot allocation failure/retry/retention, status/list/show/unknown/unavailable, full wipe and bounds, gate ownership, mutation/recovery isolation")
print("PASS: empty initialization/recovery, unchanged v1 records, corrupt/unsupported fail-closed loads, first UART0 administrator and removed bootstrap commands")
print("PASS: canonical SSH keys: Ed25519/P256 parser and authorization, malformed/off-curve/truncated inputs, zero-wait fingerprints, stale ID/generation/recreation, duplicates/capacity, sparse indices, failed persistence and CLI parity (OpenSSL-backed curve/SHA adapters)")
print("PASS: operation-admission semantics: browser invalidated in derivation double before NVS; admitted add/password transactions still commit, only target is revoked, next command rejects; persistence failure still preserves live state (not precommit cancellation or real concurrency)")
+10 -6
View File
@@ -33,6 +33,7 @@ static bool admin_console_drained(const admin_ssh_console_token_t *);
static bool admin_console_is_current(const admin_ssh_console_token_t *, const user_principal_t *);
static bool consume_external_close(const ssh_slot_t *, size_t);
static esp_err_t admin_console_perform(const admin_ssh_console_token_t *, admin_ssh_deferred_action_type_t, uint32_t);
static const admin_console_owner_t s_admin_console_owner;
static void test_adapter(void)
{
admin_ssh_console_token_t token={ .slot_index=0, .session_id=7, .slot_generation=3 };
@@ -40,7 +41,7 @@ static void test_adapter(void)
.username_length=5, .username="admin" };
assert(admin_ssh_console_init()==ESP_OK);
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
assert(admin_ssh_console_open_available(&token,&admin,&s_admin_console_owner)==ESP_OK);
assert(!admin_console_drained(&token));
s_session_snapshots[0]=(ssh_transport_session_snapshot_t){ .active=true, .session_id=7, .generation=3 };
assert(!admin_console_drained(&token)); /* No published console binding. */
@@ -82,16 +83,19 @@ static void test_adapter(void)
admin.username_length=1; assert(!admin_console_is_current(&token,&admin)); admin.username_length=5;
token.transport=1; assert(!admin_console_drained(&token));
assert(!admin_console_is_current(&token,&admin));
assert(admin_ssh_console_open(&token,&admin)==ESP_ERR_INVALID_ARG);
token.transport=0; token.slot_generation=4; assert(!admin_console_drained(&token));
assert(!admin_console_is_current(&token,&admin));
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND && stopped==0);
token.slot_generation=3;
/* Same physical session can be assigned the other console slot. */
admin_ssh_console_close(&token);
token.slot_index=1;
assert(admin_ssh_console_open(&token,&admin)==ESP_OK);
active.console_slot_index=1; publish_slot(&active,0);
admin_ssh_console_token_t other={ .session_id=8, .slot_generation=1 };
assert(admin_ssh_console_open_available(&other,&admin,&s_admin_console_owner)==ESP_OK);
assert(other.slot_index==0);
assert(admin_ssh_console_open_available(&token,&admin,&s_admin_console_owner)==ESP_OK);
assert(token.slot_index==1);
active.console_slot_index=token.slot_index; publish_slot(&active,0);
assert(admin_console_is_current(&token,&admin));
assert(s_console_slot_indices[0]==1);
admin_ssh_console_token_t wrong=token; wrong.slot_index=0;
@@ -111,5 +115,5 @@ static void test_adapter(void)
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_ROTATE,0)==ESP_OK && rotated==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_RESET,0)==ESP_OK && reset==1);
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_REBOOT,0)==ESP_OK && restarted==1);
puts("PASS: actual SSH snapshot/principal publication and wiping, adapter identity/drain checks, legacy admission and lifecycle action routing");
puts("PASS: actual SSH snapshot/principal publication and wiping, adapter identity/drain checks, shared-slot admission and lifecycle action routing");
}
@@ -39,6 +39,8 @@ static esp_err_t web_server_stop(void) { ++stops; return stop_result; }
static esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
static esp_err_t web_server_clear_counters(void) { assert(false); return ESP_FAIL; }
static esp_err_t web_serial_transport_clear_counters(void) { assert(false); return ESP_FAIL; }
static bool reboot_busy;
static esp_err_t web_firmware_update_reserve_reboot(void) { return reboot_busy ? ESP_FAIL : ESP_OK; }
static void esp_restart(void) { ++reboots; }
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
#define pdMS_TO_TICKS(ms) (ms)
@@ -95,6 +97,9 @@ int main(void) {
assert(command_reboot(1,NULL)==1 && scheduled==5 && !reboots);
assert(command_reboot(2,NULL)==1 && scheduled==5 && !reboots);
remote=false;
reboot_busy=true;
assert(command_reboot(1,NULL)==1 && !reboots && !waits && scheduled==5);
reboot_busy=false;
assert(command_reboot(1,NULL)==0 && reboots==1 && waits==1 && scheduled==5);
char *rotate[]={"web", "certificate", "rotate", "--force", "extra"};
remote=web=true; schedule_result=ESP_OK;
+44
View File
@@ -0,0 +1,44 @@
static int run_ping(const char *count)
{
char *argv[]={"ping","localhost",(char *)count};
int result=network_console_execute(count?3:2,argv);
if(worker_started) { assert(!pthread_join(worker,NULL)); worker_started=false; }
return result;
}
int main(void)
{
_Static_assert(sizeof(ping_event_t)==200,"audited event payload changed");
assert(network_console_register_root_commands()==ESP_OK && registrations==3 && !allocations);
assert(run_ping("0")==1 && run_ping("21")==1 && run_ping("-1")==1);
assert(run_ping("999999999999999999999")==1 && !allocations);
resolve_fail=true; assert(run_ping(NULL)==1 && !allocations); resolve_fail=false;
fail_alloc=true; assert(run_ping(NULL)==1 && allocations==1 && !creations && !starts);
char *lookup[]={"nslookup","localhost"};
assert(network_console_execute(2,lookup)==0);
assert(network_console_register_root_commands()==ESP_OK && allocations==1);
fail_alloc=false; fail_queue=true;
assert(run_ping(NULL)==1 && allocations==2 && creations==1 && !starts);
void *retained=s_ping_queue_bytes;
fail_alloc=true; fail_queue=false; fail_new=true;
assert(run_ping(NULL)==1 && allocations==2 && creations==2 && !starts);
fail_new=false; fail_start=true;
assert(run_ping(NULL)==1 && deletes==1);
fail_start=false;
for(unsigned i=0;i<4;++i) {
burst=i%2==0;
assert(run_ping("20")==0);
assert(s_ping_queue_bytes==retained && allocations==2 && creations==2);
assert(queue.count==0);
assert(memory.before==UINT64_C(0x1122334455667788));
assert(memory.after==UINT64_C(0x1122334455667788));
}
/* Reset stale payload before a new session, preserving the end-event slot. */
ping_event_t stale={.kind=PING_EVENT_END,.profile_error=ESP_FAIL};
xQueueSend(s_ping_queue,&stale,0);
assert(run_ping("1")==0);
all_timeouts=true; assert(run_ping(NULL)==1); all_timeouts=false;
fail_profile=true; assert(run_ping(NULL)==1); fail_profile=false;
fail_delete=true; assert(run_ping(NULL)==1); fail_delete=false;
assert(run_ping(NULL)==0 && resets>=10 && allocations==2);
assert(s_ping_queue==&queue && s_ping_queue_bytes==retained);
}
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Production ping command/callbacks with bounded pthread queue and SDK doubles."""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[2]
s = (ROOT / 'src/network_console.c').read_text()
def function(name):
start = s.rfind('\n', 0, s.index(name + '(')) + 1
return s[start:s.index('\n}', start) + 2] + '\n'
prelude = (ROOT / 'tests/admin_console_boundary/psram_ping_fakes.c').read_text()
unit = prelude + '\n'
unit += s[s.index('#define PING_DEFAULT_COUNT'):s.index('/* This covers')]
unit += function('print_command_usage') + function('parse_bounded_u32')
unit += s[s.index('typedef enum {\n PING_EVENT_LINE'):s.index('static bool socket_addresses_equal')]
unit += function('network_console_is_command') + function('network_console_execute')
unit += function('network_console_register_root_commands')
unit += (ROOT / 'tests/admin_console_boundary/psram_ping.c').read_text()
with tempfile.TemporaryDirectory(prefix='psram-ping-') as d:
p = Path(d)
(p / 'test.c').write_text(unit)
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', '-pthread',
'-g', str(p / 'test.c'), '-o', str(p / 'test')],
check=True, timeout=30)
subprocess.run([str(p / 'test')], check=True, timeout=20)
print('PASS: production ping allocation/create-queue/session failures, retry/retention/reset, bounds, asynchronous success/timeout/end callbacks and registration isolation (host doubles)')
@@ -0,0 +1,119 @@
/* Deterministic SDK boundaries; queue data movement uses the supplied payload. */
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <pthread.h>
typedef int esp_err_t;
enum { ESP_OK, ESP_FAIL };
#define MALLOC_CAP_SPIRAM 1U
#define MALLOC_CAP_8BIT 2U
#define portMAX_DELAY UINT32_MAX
#define pdTRUE 1
static bool fail_alloc, fail_queue, fail_new, fail_start, fail_delete, fail_profile, resolve_fail;
static unsigned allocations, creations, resets, registrations, starts, deletes;
static struct { uint64_t before; uint8_t bytes[4200]; uint64_t after; } memory;
static void *heap_caps_malloc(size_t n, unsigned caps) {
++allocations; assert(n==sizeof(memory.bytes));
assert(caps==(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
if (fail_alloc) return NULL;
memory.before=memory.after=UINT64_C(0x1122334455667788);
return memory.bytes;
}
typedef struct { unsigned marker; } StaticQueue_t;
typedef struct { uint8_t *bytes; size_t length, size, read, count; } queue_t;
typedef queue_t *QueueHandle_t;
static queue_t queue;
static pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t ready=PTHREAD_COND_INITIALIZER;
static QueueHandle_t xQueueCreateStatic(size_t length,size_t size,uint8_t *bytes,StaticQueue_t *control) {
++creations; assert(length==21 && size==200 && bytes==memory.bytes && control);
if (fail_queue) return NULL;
queue=(queue_t){.bytes=bytes,.length=length,.size=size}; return &queue;
}
static int xQueueReset(QueueHandle_t q) {
pthread_mutex_lock(&mutex); ++resets; q->read=q->count=0;
pthread_mutex_unlock(&mutex); return pdTRUE;
}
static int xQueueSend(QueueHandle_t q,const void *event,unsigned wait) {
assert(wait==0); pthread_mutex_lock(&mutex); assert(q->count<q->length);
memcpy(q->bytes+((q->read+q->count)%q->length)*q->size,event,q->size);
++q->count; pthread_cond_signal(&ready); pthread_mutex_unlock(&mutex); return pdTRUE;
}
static int xQueueReceive(QueueHandle_t q,void *event,unsigned wait) {
assert(wait==portMAX_DELAY); pthread_mutex_lock(&mutex);
while (!q->count) pthread_cond_wait(&ready,&mutex);
memcpy(event,q->bytes+q->read*q->size,q->size);
q->read=(q->read+1)%q->length; --q->count;
pthread_mutex_unlock(&mutex); return pdTRUE;
}
typedef uint32_t ip_addr_t;
#define IP_IS_V4(p) ((void)(p), 1)
static char *ipaddr_ntoa_r(const ip_addr_t *ip,char *out,int n) {
(void)ip; assert(n>=10); strcpy(out,"127.0.0.1"); return out;
}
static size_t strlcpy(char *out,const char *in,size_t n) {
size_t len=strlen(in); if(n) { size_t copy=len<n-1?len:n-1; memcpy(out,in,copy); out[copy]=0; } return len;
}
static int resolve_ping_target(const char *host,ip_addr_t *ip,char *out,size_t n) {
(void)host; *ip=0; ipaddr_ntoa_r(ip,out,(int)n); return resolve_fail;
}
static const char *esp_err_to_name(int e) { (void)e; return "injected"; }
typedef void *esp_ping_handle_t;
typedef struct { uint32_t count; ip_addr_t target_addr; } esp_ping_config_t;
#define ESP_PING_DEFAULT_CONFIG() ((esp_ping_config_t){0})
typedef struct {
void *cb_args;
void (*on_ping_success)(esp_ping_handle_t,void *);
void (*on_ping_timeout)(esp_ping_handle_t,void *);
void (*on_ping_end)(esp_ping_handle_t,void *);
} esp_ping_callbacks_t;
enum { ESP_PING_PROF_SEQNO, ESP_PING_PROF_SIZE, ESP_PING_PROF_TIMEGAP,
ESP_PING_PROF_IPADDR, ESP_PING_PROF_TTL, ESP_PING_PROF_REQUEST,
ESP_PING_PROF_REPLY, ESP_PING_PROF_DURATION };
static esp_ping_callbacks_t callbacks;
static uint32_t probes, replies;
static uint16_t sequence;
static pthread_t worker;
static bool worker_started, all_timeouts, burst;
static int esp_ping_get_profile(esp_ping_handle_t h,int profile,void *out,size_t n) {
assert(h==(void *)1);
if (fail_profile) return ESP_FAIL;
if (profile==ESP_PING_PROF_SEQNO) { assert(n==2); memcpy(out,&sequence,n); }
else if(profile==ESP_PING_PROF_TTL) { assert(n==1); *(uint8_t *)out=64; }
else { assert(n==4); uint32_t value=profile==ESP_PING_PROF_REQUEST?probes:
profile==ESP_PING_PROF_REPLY?replies:1; memcpy(out,&value,n); }
return ESP_OK;
}
static int esp_ping_delete_session(esp_ping_handle_t h) {
assert(h==(void *)1); ++deletes; return fail_delete?ESP_FAIL:ESP_OK;
}
static void *produce(void *unused) {
(void)unused;
for(sequence=1;sequence<=probes;++sequence) {
if(all_timeouts || sequence%2==0) callbacks.on_ping_timeout((void *)1,callbacks.cb_args);
else callbacks.on_ping_success((void *)1,callbacks.cb_args);
}
callbacks.on_ping_end((void *)1,callbacks.cb_args); return NULL;
}
static int esp_ping_new_session(const esp_ping_config_t *c,const esp_ping_callbacks_t *cb,esp_ping_handle_t *h) {
assert(queue.bytes==memory.bytes && c->count>=1 && c->count<=20);
if(fail_new) return ESP_FAIL;
callbacks=*cb; probes=c->count; replies=all_timeouts?0:(probes+1)/2; *h=(void *)1; return ESP_OK;
}
static int esp_ping_start(esp_ping_handle_t h) {
assert(h==(void *)1); ++starts; if(fail_start) return ESP_FAIL;
assert(!pthread_create(&worker,NULL,produce,NULL)); worker_started=true;
if(burst) { assert(!pthread_join(worker,NULL)); worker_started=false; assert(queue.count==probes+1); }
return ESP_OK;
}
static int execute_nslookup(int argc,char **argv) { (void)argc; (void)argv; return 0; }
static int execute_traceroute(int argc,char **argv) { (void)argc; (void)argv; return 0; }
typedef struct { const char *command,*help,*hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t;
static int esp_console_cmd_register(const esp_console_cmd_t *c) {
assert(c->func); ++registrations; return ESP_OK;
}
@@ -0,0 +1,42 @@
/* Actual canonical handlers and database projection; included by accounts.py. */
static void psram_snapshot_tests(void)
{
reset(); remote=web=false;
fail_snapshot_alloc=true;
assert(user_console_register_commands()==ESP_OK && user_registrations==1);
assert(!snapshot_allocations);
assert(run("user status")==1);
assert(run("user list")==1);
assert(run("user show other")==1);
assert(snapshot_allocations==3 && s_user_snapshot==NULL && !gate_held);
assert(run("user add newcomer user")==0);
assert(snapshot_allocations==3);
s_initialized=false; s_mutex=NULL; storage_test=true;
assert(run("user recover --force")==0);
storage_test=false;
assert(snapshot_allocations==3);
reset(); remote=web=false;
fail_snapshot_alloc=false;
assert(run("user status")==0 && snapshot_allocations==4);
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
void *retained=s_user_snapshot;
fail_snapshot_alloc=true;
for (unsigned i=0;i<3;++i) {
assert(run("user")==0);
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
assert(run("user list")==0);
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
assert(run("user show other")==0);
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
assert(run("user show missing")==1);
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
s_initialized=false;
memset(s_user_snapshot,0xa5,sizeof(*s_user_snapshot));
assert(run("user status")==1);
assert(all_zero(s_user_snapshot,sizeof(*s_user_snapshot)));
s_initialized=true;
assert(s_user_snapshot==retained && snapshot_allocations==4 && !gate_held);
assert(snapshot_memory.before==UINT64_C(0xaabbccdd11223344));
assert(snapshot_memory.after==UINT64_C(0xaabbccdd11223344));
}
}
+2 -2
View File
@@ -21,7 +21,7 @@ No firmware build, network access or device operation is performed.
Implementer reports `run.py --tickets` PASS **25 transport / 12 ticket groups**, including certificate owner routing, currentness rejection and commit → stop → start short-circuit/error behavior. The owner's `dispatcher_actions` mask selects the existing 12 KiB dispatcher, not the 4 KiB control task. `tests/admin_console_boundary/run.py` (including `certificate.c`) separately covers typed deferred handoff/pending gate/executing reservation; `lifecycle.py` covers canonical handlers and unchanged SSH/UART0 behavior. Policy, server lifecycle **11**, cookie `--admin` and store `--serial` also pass as reported. Independent review has no actionable findings; sanitizer validation is unavailable due to missing libasan/libubsan. No hardware validation is claimed or performed by this documentation update.
Current [browser-shell policy](../../docs/web_administration.md#browser-shell-policy) permits selected other-account mutations as well as exact parsed `web status`, `web stop`, `web certificate rotate --force`, and `reboot`/self-close; network mutations and restricted SSH lifecycle/key mutations remain blocked in the shell. Typed Settings permissions are separate. Certificate drain/acknowledgement bounds do not bound queued execution or prove browser receipt. [Acceptance evidence](../../docs/web_administration_acceptance.md) records M2 and the later 8D.22 signoff; earlier pending gates below are historical, not current acceptance blockers.
Current [browser-shell policy](../../docs/web_administration.md#browser-shell-policy) permits selected other-account mutations as well as exact parsed `web status`, `web stop`, `web certificate rotate --force`, and `reboot`/self-close; network mutations and restricted SSH lifecycle/key mutations remain blocked in the shell. Typed Settings permissions are separate. Certificate drain/acknowledgement bounds do not bound queued execution or prove browser receipt. [Acceptance evidence](../../docs/roadmap.md#phase8-acceptance-evidence) records M2 and the later 8D.22 signoff; earlier pending gates below are historical, not current acceptance blockers.
## Earlier results recorded 2026-09-06
@@ -38,7 +38,7 @@ below. Final admin closure uses direct HTTPD-owned `shutdown`, not queued IDF
session-close work. Parent reports the sequential final firmware build after this
fix passed in **23.55 s**, at **95,580 B RAM / 1,637,273 B flash**, and the final
independent security integration review found no actionable findings. See
[terminal ownership](../../docs/web_administration.md#terminal-and-console-ownership) and [acceptance evidence](../../docs/web_administration_acceptance.md) for current contracts and signoff limits.
[terminal ownership](../../docs/web_administration.md#terminal-and-console-ownership) and [acceptance evidence](../../docs/roadmap.md#phase8-acceptance-evidence) for current contracts and signoff limits.
After the production empty-frame, input-deadline and timer-generation fixes:
+5
View File
@@ -15,6 +15,11 @@ static esp_err_t web_server_start(void) {
OUTSIDE(); assert(!httpd_owner && rotations && web_stops == 1 && web_stop_result == ESP_OK);
++web_starts; return web_start_result;
}
static bool reboot_busy;
static esp_err_t web_firmware_update_reserve_reboot(void) {
OUTSIDE(); assert(!httpd_owner);
return reboot_busy ? ESP_ERR_INVALID_STATE : ESP_OK;
}
static void esp_restart(void) { OUTSIDE(); assert(!httpd_owner); ++reboots; }
esp_err_t web_server_stop(void) { OUTSIDE(); assert(!httpd_owner); ++web_stops; return web_stop_result; }
static esp_err_t web_server_replace_identity(uint32_t service, uint32_t identity, bool reset, bool *committed) {
@@ -180,20 +180,20 @@ static void pipeline_tests(void) {
reset(); lifecycle_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(lifecycle_calls == failed_route && lifecycle_allocations == failure);
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(registered_count == (failed_route == 1 ? 37 : 38));
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
assert(!method_route("/api/settings/lifecycle-operation", HTTP_GET));
assert(!!method_route("/api/settings/lifecycle", HTTP_GET) == (failed_route != 1));
other_domains_complete(); network_complete(); display_complete(); broker_complete(); ssh_complete();
assert(web_server_stop() == ESP_OK);
lifecycle_fail_at = 0; fresh_registration(); start(); assert(registered_count == 39);
lifecycle_fail_at = 0; fresh_registration(); start(); assert(registered_count == 40);
assert(method_route("/api/settings/lifecycle-operation", HTTP_POST)->handler == web_lifecycle_operation_handler);
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six lifecycle route allocation positions preserve other domains and restart recovers the complete optional API");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); lifecycle_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 38 && unregister_calls == 1);
assert(registered_count == 39 && unregister_calls == 1);
assert(method_route("/api/settings/lifecycle-operation", HTTP_GET));
assert(!method_route("/api/settings/lifecycle-operation", HTTP_POST));
other_domains_complete(); ssh_complete();
@@ -201,7 +201,7 @@ static void pipeline_tests(void) {
assert(web_server_start() == ESP_ERR_INVALID_STATE);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
unregister_fail = false; lifecycle_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39 && web_server_stop() == ESP_OK);
assert(registered_count == 40 && web_server_stop() == ESP_OK);
}
puts("PASS lifecycle failed unregister leaves reads only; failed shutdown preserves ownership before successful restart");
}
+54 -49
View File
@@ -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) != 36:
raise RuntimeError('Review URI extraction: expected 34 descriptors and two tables')
if len(uri_tables) != 37:
raise RuntimeError('Review URI extraction: expected 35 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()
@@ -46,6 +46,7 @@ header = '\n'.join(line for line in header.splitlines()
if not line.startswith(('#include', '#pragma once')))
constants = define(SOURCE, 'WEB_SERVER_PORT')
for filename, names in {
'web_firmware_update.h': ('WEB_FIRMWARE_UPDATE_URI',),
'web_admin_transport.h': ('WEB_ADMIN_TICKET_URI', 'WEB_ADMIN_WS_URI'),
'web_serial_transport.h': ('WEB_SERIAL_TRANSPORT_TICKET_URI', 'WEB_SERIAL_TRANSPORT_WS_URI'),
'web_security.h': ('WEB_SECURITY_CERTIFICATE_DER_CAPACITY', 'WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY'),
@@ -104,7 +105,7 @@ static bool unregister_fail;
static bool settings_fail;
static unsigned settings_calls;
static unsigned operation_calls, operation_fail_at;
static const httpd_uri_t *registered[39];
static const httpd_uri_t *registered[40];
static char events[128]; static size_t event_length;
static void event(char value) { assert(!locked && event_length + 1 < sizeof(events)); events[event_length++] = value; events[event_length] = 0; }
static SemaphoreHandle_t xSemaphoreCreateMutex(void) { assert(!locked); return mutex_fail ? NULL : &mutex_storage; }
@@ -134,6 +135,7 @@ static esp_err_t web_security_replace_reserved(uint32_t token) {
}
static void web_security_release_identity(uint32_t token) { assert(!locked); if (token == identity_token) identity_token = 0; }
#define HANDLER(name) static esp_err_t name(httpd_req_t *r) { (void)r; assert(!"HTTP handler must not run in lifecycle harness"); return ESP_FAIL; }
HANDLER(web_firmware_update_handler)
HANDLER(root_handler) HANDLER(status_handler) HANDLER(traced_ticket_handler)
HANDLER(traced_websocket_handler) HANDLER(asset_handler) HANDLER(web_cookie_auth_handler)
HANDLER(traced_admin_ticket_handler) HANDLER(traced_admin_upgrade_handler)
@@ -277,7 +279,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 == 39 && config->port_secure == 443);
assert(config->httpd.max_uri_handlers == 40 && 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);
@@ -293,7 +295,7 @@ static esp_err_t register_one(httpd_handle_t server) {
}
static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t *uri) {
if (!strcmp(uri->uri, "/api/settings/serial")) {
assert(s == SERVER && auth_live && ssl_live && registration_calls >= 17);
assert(s == SERVER && auth_live && ssl_live && registration_calls >= 18);
assert(uri->method == HTTP_GET && uri->handler == serial_settings_handler);
++settings_calls;
if (settings_fail) return ESP_ERR_NO_MEM;
@@ -301,11 +303,11 @@ static esp_err_t httpd_register_uri_handler(httpd_handle_t s, const httpd_uri_t
return ESP_OK;
}
if (!strcmp(uri->uri, "/api/admin/ws-ticket") || !strcmp(uri->uri, "/ws/admin")) {
assert(registration_calls >= 16);
assert(registration_calls >= 17);
assert(serial_init_error != ESP_OK || serial_live);
} else assert(registration_calls < 14);
} else assert(registration_calls < 15);
esp_err_t error = register_one(s);
if (error == ESP_OK) { assert(registered_count < 39); registered[registered_count++] = uri; }
if (error == ESP_OK) { assert(registered_count < 40); registered[registered_count++] = uri; }
return error;
}
static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
@@ -355,7 +357,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) ||
assert((registration_calls == 19 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation") || !strcmp(uri, "/api/settings/broker-operation") || !strcmp(uri, "/api/settings/ssh-operation") || !strcmp(uri, "/api/settings/lifecycle-operation")) && method == HTTP_GET));
++unregister_calls;
for (unsigned i = 0; i < registered_count; ++i) {
@@ -372,15 +374,15 @@ static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri,
}
static esp_err_t httpd_register_err_handler(httpd_handle_t s, httpd_err_code_t code,
esp_err_t (*handler)(httpd_req_t *, httpd_err_code_t)) {
assert(registration_calls == 14 || registration_calls == 15);
assert(registration_calls == 15 || registration_calls == 16);
assert((code == 404 || code == 405) && handler == route_error_handler);
return register_one(s);
}
static esp_err_t web_serial_transport_attach_server(httpd_handle_t s) {
assert(!locked && s == SERVER && ssl_live && auth_live && registration_calls == 16);
assert(!locked && s == SERVER && ssl_live && auth_live && registration_calls == 17);
++serial_attaches; serial_live = true; return ESP_OK;
}
static esp_err_t web_admin_transport_init(void) { assert(!locked && auth_live && registration_calls == 18); ++admin_inits; return admin_init_error; }
static esp_err_t web_admin_transport_init(void) { assert(!locked && auth_live && registration_calls == 19); ++admin_inits; return admin_init_error; }
static esp_err_t web_admin_transport_attach(httpd_handle_t s) {
assert(!locked && s == SERVER && ssl_live && !admin_owned); ++admin_attaches;
admin_owned = admin_attach_error == ESP_OK; return admin_attach_error;
@@ -531,7 +533,8 @@ int main(void) {
}
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
reset(); start(); assert(registered_count == 39 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
reset(); start(); assert(registered_count == 40 && registration_calls == 19 && settings_calls == 1 && operation_calls == 2);
assert(route("/api/firmware")->method == HTTP_POST && route("/api/firmware")->handler == web_firmware_update_handler);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(route("/api/settings/serial")->handler == serial_settings_handler);
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
@@ -569,23 +572,23 @@ int main(void) {
assert(admin_detaches == 2 && serial_detaches == 1 && admin_stoppeds == 1 && !s_admin_transport_owned);
puts("PASS failed SSL stop retains admin ownership; stopped runs only after successful retry");
for (unsigned failure = 1; failure <= 16; ++failure) {
for (unsigned failure = 1; failure <= 17; ++failure) {
reset(); registration_fail_at = failure;
assert(web_server_start() == ESP_FAIL);
assert(registration_calls == failure && !admin_inits && !admin_attaches && !serial_attaches);
assert(!auth_live && !ssl_live && ssl_stops == 1 && !s_server && !s_admin_transport_owned);
assert(!admin_detaches && !admin_stoppeds && !s_transitioning && s_counters.start_failures == 1);
}
puts("PASS required registration positions 1..16 fail fatally before transport attachment");
puts("PASS required registration positions 1..17 fail fatally before transport attachment");
for (unsigned failure = 17; failure <= 18; ++failure) {
for (unsigned failure = 18; failure <= 19; ++failure) {
reset(); registration_fail_at = failure;
assert(web_server_start() == ESP_OK && registration_calls == failure);
assert(auth_live && ssl_live && serial_live && s_server == SERVER);
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 == 37 && unregister_calls == failure - 17);
assert(registered_count == 38 && unregister_calls == failure - 18);
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);
@@ -594,13 +597,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 == 39 && admin_attaches == 1 && s_counters.starts == 2);
assert(registered_count == 40 && 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");
puts("PASS optional positions 18..19 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 == 38);
reset(); registration_fail_at = 19; unregister_fail = true;
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 39);
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");
@@ -612,7 +615,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 == 39 && admin_attaches == 1 && web_server_stop() == ESP_OK);
assert(registered_count == 40 && 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;
@@ -634,7 +637,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 == 38);
assert(settings_calls == 1 && registered_count == 39);
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);
@@ -642,7 +645,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 == 37 && operation_calls == failure && unregister_calls == failure - 1);
assert(registered_count == 38 && 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);
@@ -650,25 +653,27 @@ 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 ? 36 : 37));
assert(account_calls == failure && registered_count == (failure == 1 ? 37 : 38));
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(route("/api/firmware")->method == HTTP_POST && route("/api/firmware")->handler == web_firmware_update_handler);
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
assert(auth_live && serial_live && admin_owned);
for (unsigned i = 0; i < registered_count; ++i)
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 == 39 && account_calls == 3);
assert(registered_count == 40 && account_calls == 3);
assert(web_server_stop() == ESP_OK);
}
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
assert(registered_count == 38 && auth_live && serial_live && admin_owned);
assert(registered_count == 39 && 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 == 38 && account_calls == 3);
assert(route("/api/firmware")->method == HTTP_POST && route("/api/firmware")->handler == web_firmware_update_handler);
assert(generation_calls == 1 && registered_count == 39 && 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);
@@ -680,12 +685,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 == 39);
assert(generation_calls == 2 && registered_count == 40);
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 == 38 && account_calls == 3 && generation_calls == 1);
assert(keys_calls == 1 && registered_count == 39 && 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);
@@ -700,7 +705,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 == 39);
assert(keys_calls == 2 && registered_count == 40);
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");
@@ -725,7 +730,7 @@ int main(void) {
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 ? 36 : 37));
assert(registered_count == (failed_route == 1 ? 37 : 38));
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));
@@ -733,13 +738,13 @@ int main(void) {
other_domains_complete();
assert(web_server_stop() == ESP_OK);
network_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); network_complete();
assert(registered_count == 40); 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 == 38 && unregister_calls == 1);
assert(registered_count == 39 && 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));
@@ -749,7 +754,7 @@ int main(void) {
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 == 39); network_complete();
assert(registered_count == 40); network_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
@@ -757,7 +762,7 @@ int main(void) {
reset(); display_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(display_calls == failed_route && display_allocations == failure);
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(registered_count == (failed_route == 1 ? 37 : 38));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/display-operation", HTTP_GET));
assert(!method_route("/api/settings/display-operation", HTTP_POST));
@@ -765,13 +770,13 @@ int main(void) {
other_domains_complete(); network_complete();
assert(web_server_stop() == ESP_OK);
display_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); display_complete();
assert(registered_count == 40); display_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six Display descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); display_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 38 && unregister_calls == 1);
assert(registered_count == 39 && unregister_calls == 1);
assert(route("/api/settings/display")->handler == web_display_settings_handler);
assert(method_route("/api/settings/display-operation", HTTP_GET)->handler == web_display_operation_handler);
assert(!method_route("/api/settings/display-operation", HTTP_POST));
@@ -781,7 +786,7 @@ int main(void) {
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; display_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); display_complete();
assert(registered_count == 40); display_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Display result unregister leaves reads only and preserves stop-failure ownership/restart");
@@ -789,7 +794,7 @@ int main(void) {
reset(); broker_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(broker_calls == failed_route && broker_allocations == failure);
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(registered_count == (failed_route == 1 ? 37 : 38));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/broker-operation", HTTP_GET));
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
@@ -797,13 +802,13 @@ int main(void) {
other_domains_complete(); network_complete(); display_complete();
assert(web_server_stop() == ESP_OK);
broker_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); broker_complete();
assert(registered_count == 40); broker_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six Broker descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); broker_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 38 && unregister_calls == 1);
assert(registered_count == 39 && unregister_calls == 1);
assert(route("/api/settings/broker")->handler == web_broker_settings_handler);
assert(method_route("/api/settings/broker-operation", HTTP_GET)->handler == web_broker_operation_handler);
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
@@ -813,7 +818,7 @@ int main(void) {
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
unregister_fail = false; broker_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); broker_complete();
assert(registered_count == 40); broker_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed Broker result unregister leaves reads only and preserves stop-failure ownership/restart");
@@ -821,7 +826,7 @@ int main(void) {
reset(); ssh_fail_at = failure; start();
unsigned failed_route = (failure + 1) / 2;
assert(ssh_calls == failed_route && ssh_allocations == failure);
assert(registered_count == (failed_route == 1 ? 36 : 37));
assert(registered_count == (failed_route == 1 ? 37 : 38));
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
assert(!method_route("/api/settings/ssh-operation", HTTP_GET));
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
@@ -829,13 +834,13 @@ int main(void) {
other_domains_complete(); network_complete(); display_complete(); broker_complete();
assert(web_server_stop() == ESP_OK);
ssh_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); ssh_complete();
assert(registered_count == 40); ssh_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS all six SSH descriptor/name allocation positions isolate failures and recover after restart");
for (unsigned failure = 5; failure <= 6; ++failure) {
reset(); ssh_fail_at = failure; unregister_fail = true; start();
assert(registered_count == 38 && unregister_calls == 1);
assert(registered_count == 39 && unregister_calls == 1);
assert(route("/api/settings/ssh")->handler == web_ssh_settings_handler);
assert(method_route("/api/settings/ssh-operation", HTTP_GET)->handler == web_ssh_operation_handler);
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
@@ -845,7 +850,7 @@ int main(void) {
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; ssh_fail_at = 0; fresh_registration(); start();
assert(registered_count == 39); ssh_complete();
assert(registered_count == 40); ssh_complete();
assert(web_server_stop() == ESP_OK);
}
puts("PASS failed SSH result unregister leaves reads only and preserves stop-failure ownership/restart");
@@ -927,7 +932,7 @@ static void management_tests(void) {
assert(reserved_gaps == 1 && !unlock_hook && !strcmp(events, "ADSHR"));
assert(s_generation == original + 2 && s_server == SERVER && !s_transitioning);
assert(auth_live && ssl_live && admin_owned && serial_live && idle_owned);
assert(ssl_starts == 2 && ssl_stops == 1 && serial_inits == 1 && registered_count == 39);
assert(ssl_starts == 2 && ssl_stops == 1 && serial_inits == 1 && registered_count == 40);
assert(web_server_stop_current(original) == ESP_ERR_INVALID_STATE);
assert(web_server_stop_current(s_generation) == ESP_OK);
puts("PASS conditional restart reserves stop-to-start gap against canonical and conditional callers");
+3
View File
@@ -241,6 +241,9 @@ int main(void) {
assert(web_stops == 1 && !reboots && !closes);
web_stop_result = ESP_OK;
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_OK && web_stops == 2);
reboot_busy = true;
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_INVALID_STATE && !reboots);
reboot_busy = false;
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_OK && reboots == 1);
httpd_owner = true;
ok("HTTPS stop/reboot marshal to lifecycle APIs outside HTTPD/locks; stop failure propagates");
+3 -3
View File
@@ -14,7 +14,7 @@ Coverage includes challenge reuse/consumption/expiry, capacities without evictio
This is **not** the full IDF parser/dispatcher, real handshake/TLS/socket, browser, multicore task or hardware test. The private struct doubles do not prove binary layout; firmware compilation uses the actual pinned headers, and the version guard requires a new audit on SDK changes. Handshake sending and transport revocation are doubled. Actual on-wire pre-101 rejection, frame routing, pipelining/early bytes, cookie/CSP/browser recovery and loaded expiry latency require real-target evidence beyond this harness; M1 and the retained Phase 8D scope have since been accepted. No sanitizer or runtime memory-reserve result is implied.
See [authentication contracts](../../docs/web_administration.md#authentication-and-admission), [acceptance evidence and limits](../../docs/web_administration_acceptance.md), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
See [authentication contracts](../../docs/web_administration.md#authentication-and-admission), [acceptance evidence and limits](../../docs/roadmap.md#phase8-acceptance-evidence), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
## Accounts (8D.10)
@@ -42,7 +42,7 @@ The second command separately exercises production conditional database mutation
and zero-wait list bodies with NVS/RTOS doubles, including last-admin protection,
target generation/recreation checks and commit-failure cleanup. It retains the
canonical CLI account tests. These are not end-to-end RTOS/flash/TLS tests.
See [Accounts contracts](../../docs/web_administration.md#accounts-and-authorized-keys) and [acceptance evidence](../../docs/web_administration_acceptance.md). Timer doubles do not prove hard cleanup latency.
See [Accounts contracts](../../docs/web_administration.md#accounts-and-authorized-keys) and [acceptance evidence](../../docs/roadmap.md#phase8-acceptance-evidence). Timer doubles do not prove hard cleanup latency.
## Read-only Serial Settings
@@ -62,4 +62,4 @@ registration. No SDK files are modified. Lifecycle registration/optional failure
orchestration is separately tested by `tests/web_admin_transport/server_lifecycle.py`.
This does not run the full serial task/UART driver, TLS/network dispatcher or a real
browser. See [typed Settings contracts](../../docs/web_administration.md#typed-settings-api-and-operation-lifetime) and [Serial semantics](../../docs/web_administration.md#serial-and-display). [Acceptance evidence](../../docs/web_administration_acceptance.md) records the 8D.22 signoff without inferring individual target comparisons or runtime memory/stack margins.
browser. See [typed Settings contracts](../../docs/web_administration.md#typed-settings-api-and-operation-lifetime) and [Serial semantics](../../docs/web_administration.md#serial-and-display). [Acceptance evidence](../../docs/roadmap.md#phase8-acceptance-evidence) records the 8D.22 signoff without inferring individual target comparisons or runtime memory/stack margins.
+13 -1
View File
@@ -3,6 +3,7 @@
#include <sys/socket.h>
#include "admin_ssh_console.h"
#include "web_admin_tickets.h"
#include "web_firmware_update.h"
#include "esp_timer.h"
#include "esp_heap_caps.h"
#include "freertos/task.h"
@@ -14,7 +15,13 @@ static void (*timer_poll)(void *), (*pending_poll)(void *);
static void *pending_argument;
static httpd_req_t connected;
static unsigned admin_closes;
/* These endpoint tests never dispatch lifecycle commands. */
static bool reboot_reservation_expected;
esp_err_t web_firmware_update_reserve_reboot(void) {
assert(reboot_reservation_expected);
reboot_reservation_expected = false;
return ESP_ERR_INVALID_STATE;
}
/* A rejected reboot reservation must never reach reset or other lifecycle effects. */
void esp_restart(void) { assert(false); }
esp_err_t web_server_stop(void) { assert(false); return ESP_FAIL; }
esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
@@ -131,6 +138,11 @@ static void admin_tests(void) {
assert(web_admin_transport_upgrade_handler(&req) == ESP_OK && upgrades == ++before);
connected = req; assert(console_active && admin_owner->is_current(&admin_token, &administrator));
if (mode == 0) {
reboot_reservation_expected = true;
assert(admin_owner->perform(&admin_token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_INVALID_STATE);
assert(!reboot_reservation_expected && console_active && !admin_closes);
assert(admin_owner->is_current(&admin_token, &administrator));
puts("PASS: live admin deferred reboot propagates firmware reservation refusal without reset or session closure");
admin_request(&admin, "/api/logout", true, true, true); expect("204 No Content");
assert(!console_active); present(&other);
} else if (mode == 1) stale_user = administrator.user_id;
+93
View File
@@ -0,0 +1,93 @@
# Firmware upload backend host tests
Run from the repository root:
```sh
python3 tests/web_firmware_update/run.py
python3 tests/web_cookie_auth/run.py
```
The first compiles the unchanged production upload source, production auth
admission helpers/parser, and production server upload reservation/typed reboot
functions with `-Wall -Wextra -Werror` and trapping undefined-behavior checks.
It uses installed ESP-IDF image-layout headers (`IDF_PATH`, otherwise the normal
PlatformIO framework directory). It performs no firmware build or device action.
HTTP IO, private header-adapter admission, database, OTA/flash and FreeRTOS
scheduling are doubles. The existing cookie-auth suite separately exercises
production private-adapter framing/duplicate-header handling and session storage.
The 61 cases cover:
- Cookie, Origin, CSRF, admin, query/method and readiness rejection before reads.
- Raw content type, nonempty body, actual partition capacity and inactive OTA app
selection; bytewise partial image prefix and bounded/exact streaming writes.
- Wrong magic/chip/segment/descriptor/hash-header rejection before OTA erase.
- Service/identity busy, allocation/task creation and OTA begin/write/end/metadata/
boot-selection failures; handle consumption versus abort cleanup.
- Parsed SDK image length mismatch, final session revocation/currentness failure.
- EOF before/after OTA begin, stalled receive and total slow-drip deadlines.
- Reservation rejection of competing upload/typed reboot, including during
receive and boot selection; response failure at every response stage.
- Successful commit followed by delayed reboot-owner action; failed response
retains selection but does not reboot and releases reservations/resources.
These are deterministic failure-injection tests, not real SDK image-integrity,
flash, concurrent scheduler, TLS, network, power-loss or hardware test evidence.
Stop/start/rotation guard use is source-checked, not dynamically executed here.
## Parent integration contract
`POST /api/firmware`, raw `application/octet-stream`, known `Content-Length`,
existing cookie + same-origin `Origin` + `X-CSRF-Token`, admin only. Use the
existing browser CORS-mode/same-origin-credentials request pattern. No multipart,
chunked transfer, `Expect`, query parameters, signature/version policy or filename
validation. Standard ESP32-S3 application image with appended SDK SHA-256 digest
is required. A filename alone is never trusted.
`200 {"ok":true,"rebooting":true}` means SDK validation and boot selection
succeeded. After synchronous send success, the preallocated owner task waits
500 ms and calls `esp_restart()` without stopping HTTPD or retaining a request
or socket. It keeps service/identity reservations until reset. Send success is
not peer receipt. Send failure after selection schedules **no reboot**, releases
reservations, and leaves the accepted image selected for a later reset. A lost
acknowledgement is therefore uncertain; never automatically retry.
All errors use `{"error":"code"}`:
| HTTP | Codes |
| --- | --- |
| 400 | `invalid_request`, `invalid_firmware`, `firmware_incomplete` |
| 401 | `authentication_required` |
| 403 | `origin`, `csrf`, `admin_required` |
| 408 | `firmware_timeout` |
| 413 | `firmware_too_large` |
| 415 | `firmware_content_type` |
| 500 | `firmware_write_failed`, `firmware_commit_failed` |
| 503 | `unavailable`, `busy`, `firmware_unavailable`, `firmware_resources` |
Rejections with unread bodies close rather than asking HTTPD to discard the
remaining upload. Responses are JSON, no-store, nosniff and no-referrer. Errors
contain no SDK diagnostic text or secret material. A boot-selection API failure
is not retried or followed by reboot; flash/power failures at metadata commit
cannot offer transactional certainty beyond SDK guarantees.
The synchronous handler may block other HTTPD work for the upload duration.
A 4 KiB heap buffer and a 2 KiB reboot-owner stack are prepared before flashing;
no whole-image allocation. Receive checks enforce 10 seconds without progress
and 120 seconds total, using the server's existing one-second receive timeout.
These checks do not preempt SDK flash/validation calls or scheduler delays.
Only the inactive app and SDK OTA metadata are written, not NVS/data partitions.
### Integration work outside backend ownership
- Server URI capacity is now **40**; `server_lifecycle.py` currently fails its
hardcoded 36-initializer extraction check (now 37). Its old 39-handler capacity,
required-registration positions 116 and optional positions 1718 also need
adjustment to 40, 117 and 1819, respectively, plus a firmware-handler fake.
That existing fixture is intentionally not edited here.
- HTTPS stop/start/rotation and typed reboot share the upload reservation; direct
identity mutation is separately reserved. **Direct `esp_restart()` callers in
`system_console.c`, `ssh_transport.c`, `web_admin_transport.c`, and
`local_status_ui.c` do not share this fence.** Those files were outside the
backend's allowed ownership. The parent must coordinate those reboot paths
for global software-reboot exclusion; do not claim that property yet.
+96
View File
@@ -0,0 +1,96 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include <assert.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_NOT_FOUND 2
#define ESP_ERR_INVALID_STATE 3
#define ESP_ERR_TIMEOUT 4
#define ESP_ERR_INVALID_ARG 5
#define ESP_ERR_NO_MEM 6
#define ESP_ERR_INVALID_SIZE 7
#define ESP_ERR_HTTPD_INVALID_REQ 8
#define ESP_ERR_HTTPD_RESULT_TRUNC 9
#define HTTPD_SOCK_ERR_TIMEOUT -3
#define HTTP_POST 1
#define HTTP_GET 0
#define USER_ROLE_ADMIN 1
#define USER_ROLE_USER 0
#define pdPASS 1
#define pdTRUE 1
#define portMAX_DELAY UINT32_MAX
#define pdMS_TO_TICKS(n) (n)
#define taskENTER_CRITICAL(p) ((void)(p))
#define taskEXIT_CRITICAL(p) ((void)(p))
typedef void *httpd_handle_t;
typedef struct { const char *name, *value; } test_header_t;
struct httpd_req_aux { char *scratch; size_t scratch_cur_size; unsigned req_hdrs_count; };
typedef struct {
const char *uri;
int method;
size_t content_len, received;
httpd_handle_t handle;
struct httpd_req_aux *aux;
test_header_t headers[12];
size_t header_count;
bool headers_valid;
} httpd_req_t;
typedef struct { int role; uint32_t user_id; } user_principal_t;
typedef uint64_t web_session_id_t;
typedef struct { web_session_id_t id; user_principal_t principal; char csrf[65]; } web_session_view_t;
typedef struct {
uint32_t security_rejections, login_failures, throttled, capacity_rejections;
} web_cookie_auth_snapshot_t;
typedef void *TaskHandle_t;
typedef void *SemaphoreHandle_t;
#define eSetValueWithOverwrite 1
int xTaskCreate(void (*)(void *), const char *, unsigned, void *, unsigned, TaskHandle_t *);
int xTaskNotify(TaskHandle_t, uint32_t, int);
int xTaskNotifyWait(uint32_t, uint32_t, uint32_t *, uint32_t);
void vTaskDelay(unsigned);
void vTaskDelete(TaskHandle_t);
int xSemaphoreTake(SemaphoreHandle_t, unsigned);
void xSemaphoreGive(SemaphoreHandle_t);
void esp_restart(void);
int64_t esp_timer_get_time(void);
void secure_wipe(void *, size_t);
size_t httpd_req_get_hdr_value_len(httpd_req_t *, const char *);
esp_err_t httpd_req_get_hdr_value_str(httpd_req_t *, const char *, char *, size_t);
esp_err_t httpd_resp_set_status(httpd_req_t *, const char *);
esp_err_t httpd_resp_set_type(httpd_req_t *, const char *);
esp_err_t httpd_resp_set_hdr(httpd_req_t *, const char *, const char *);
esp_err_t httpd_resp_sendstr(httpd_req_t *, const char *);
int httpd_req_recv(httpd_req_t *, char *, size_t);
bool web_httpd_headers_valid(httpd_req_t *);
bool web_httpd_unread_body(httpd_req_t *);
void web_httpd_wipe_request(httpd_req_t *, bool);
esp_err_t web_session_store_lookup(const char *, size_t, const char *, size_t, web_session_view_t *);
esp_err_t web_session_store_check_principal(web_session_id_t, const user_principal_t *, bool *);
esp_err_t web_cookie_auth_require_body(httpd_req_t *, size_t, web_session_view_t *, bool *);
esp_err_t web_security_reserve_identity(uint32_t, bool, uint32_t *);
void web_security_release_identity(uint32_t);
typedef uint32_t esp_ota_handle_t;
typedef struct { uint32_t type, subtype, address, size, erase_size; } esp_partition_t;
#define ESP_PARTITION_TYPE_APP 0
#define ESP_PARTITION_SUBTYPE_APP_OTA_0 0x10
#define ESP_PARTITION_SUBTYPE_APP_OTA_15 0x1f
const esp_partition_t *esp_ota_get_running_partition(void);
const esp_partition_t *esp_ota_get_next_update_partition(const esp_partition_t *);
esp_err_t esp_ota_begin(const esp_partition_t *, size_t, esp_ota_handle_t *);
esp_err_t esp_ota_write(esp_ota_handle_t, const void *, size_t);
esp_err_t esp_ota_end(esp_ota_handle_t);
esp_err_t esp_ota_abort(esp_ota_handle_t);
esp_err_t esp_ota_set_boot_partition(const esp_partition_t *);
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Host failure tests: real upload/auth/header admission and server reservation.
The backend uses OTA/HTTP IO/task/database doubles; a separate contract test
executes pinned SDK begin/abort with injected flash/allocation failures. SDK
headers/getters come only from verified IDF 5.5.0. No build or device operations.
"""
import argparse
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--idf-path", type=Path, help="Explicit ESP-IDF 5.5.0 source directory")
args = parser.parse_args()
if args.idf_path:
IDF = args.idf_path.expanduser().resolve()
else:
# Only this project's active build tree; never candidate builds or the
# unversioned PlatformIO package, which may now contain another SDK.
paths = set()
for cache in (ROOT / ".pio/build").glob("*/CMakeCache.txt"):
match = re.search(r"^esp-idf_SOURCE_DIR:[^=]+=(.+)$", cache.read_text(), re.M)
if match:
paths.add(Path(match.group(1)).resolve())
if len(paths) != 1:
parser.error("Cannot identify one active build SDK; pass --idf-path for ESP-IDF 5.5.0")
IDF = paths.pop()
try:
version_header = (IDF / "components/esp_common/include/esp_idf_version.h").read_text()
version = tuple(int(re.search(r"^#define ESP_IDF_VERSION_" + part + r"\s+(\d+)\s*$",
version_header, re.M).group(1))
for part in ("MAJOR", "MINOR", "PATCH"))
except (OSError, AttributeError) as error:
parser.error(f"Cannot verify SDK version at {IDF}: {error}")
if version != (5, 5, 0):
parser.error(f"ESP-IDF 5.5.0 required, found {'.'.join(map(str, version))} at {IDF}")
print(f"Using ESP-IDF 5.5.0: {IDF}", flush=True)
def function(source, name):
match = re.search(r"^(?:static )?(?:bool|void|size_t|esp_err_t|ota_ops_entry_t\s*\*)\s*" + name + r"\([^;{}]*\)\n\{.*?^\}", source, re.M | re.S)
if not match:
raise RuntimeError("Production function shape changed: " + name)
return match.group() + "\n"
with tempfile.TemporaryDirectory(prefix="web-firmware-update-") as directory:
tmp = Path(directory)
shutil.copy(HERE / "fakes.h", tmp / "fakes.h")
shutil.copy(HERE / "test.c", tmp / "test.c")
for name in ("esp_http_server.h", "esp_err.h", "esp_ota_ops.h", "esp_system.h",
"esp_timer.h", "secure_random.h", "web_cookie_auth.h", "web_httpd_adapter.h",
"web_security.h", "freertos/FreeRTOS.h", "freertos/task.h"):
path = tmp / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text('#pragma once\n#include "fakes.h"\n')
(tmp / "esp_assert.h").write_text('#define ESP_STATIC_ASSERT(c,m) _Static_assert(c,m)\n')
(tmp / "esp_attr.h").write_text('#define FORCE_INLINE_ATTR static inline\n')
(tmp / "esp_flash_partitions.h").write_text('#pragma once\n#include <stdint.h>\ntypedef struct { uint32_t offset, size; } esp_partition_pos_t;\n')
for component, name in (("bootloader_support", "esp_app_format.h"),
("bootloader_support", "esp_image_format.h"),
("esp_app_format", "esp_app_desc.h")):
shutil.copy(IDF / "components" / component / "include" / name, tmp / name)
for name in ("web_firmware_update.c", "web_firmware_update.h", "web_auth_parse.c", "web_auth_parse.h"):
shutil.copy(ROOT / "src" / name, tmp / name)
auth = (ROOT / "src/web_cookie_auth.c").read_text()
names = ("equal", "header", "origin", "cookie", "cookies_valid", "response", "failure",
"require", "web_cookie_auth_require_body")
constants = '\n'.join(line for line in auth.splitlines() if line.startswith(("#define SESSION_COOKIE", "#define PRELOGIN_COOKIE")))
(tmp / "auth_production.h").write_text(constants + "\n" + '\n'.join(function(auth, n) for n in names))
httpd = (IDF / "components/esp_http_server/src/httpd_parse.c").read_text()
adapter = (ROOT / "src/web_httpd_adapter.c").read_text()
(tmp / "httpd_production.h").write_text(
'\n'.join(function(httpd, n) for n in ("httpd_req_get_hdr_value_len", "httpd_req_get_hdr_value_str")) +
'\n#define web_httpd_headers_valid adapter_headers_valid\n' +
function(adapter, "web_httpd_headers_valid") + '\n#undef web_httpd_headers_valid\n')
sdk_ota = (IDF / "components/app_update/esp_ota_ops.c").read_text()
# Extract the actual SDK registry/type and begin/abort implementations, not
# a reimplementation of their ordering. Flash and allocation are injected.
registry = sdk_ota[sdk_ota.index('typedef struct ota_ops_entry_'):
sdk_ota.index('const static char *TAG')]
(tmp / "sdk_ota_production.h").write_text(registry + '\n' + '\n'.join(
function(sdk_ota, n) for n in ("is_ota_partition", "esp_ota_init_entry", "esp_ota_begin",
"get_ota_ops_entry", "esp_ota_abort")))
shutil.copy(HERE / "sdk_contract.c", tmp / "sdk_contract.c")
server = (ROOT / "src/web_server.c").read_text()
(tmp / "server_production.h").write_text('\n'.join(function(server, n) for n in
("web_firmware_update_reserve", "web_firmware_update_release", "web_server_reboot_current")))
# Review guards used by the same reservation. Full lifecycle harness is owned
# elsewhere; its hardcoded URI count must change from 39 to 40 upstream.
assert "s_transitioning != reserved" in function(server, "stop_server")
assert "s_transitioning != reserved" in function(server, "start_server")
assert "if (s_transitioning ||" in function(server, "web_server_replace_identity")
assert '.handler = web_firmware_update_handler' in server
assert '&s_firmware_uri,' in server
compiler = os.environ.get("CC", "cc")
command = [compiler, "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
"-fsanitize=undefined", "-fsanitize-undefined-trap-on-error", "-I", str(tmp),
str(tmp / "test.c"), str(tmp / "web_auth_parse.c"), "-o", str(tmp / "test")]
subprocess.run(command, check=True, timeout=30)
subprocess.run([str(tmp / "test")], check=True, timeout=30)
command = command[:command.index(str(tmp / "test.c"))] + [str(tmp / "sdk_contract.c"), "-o", str(tmp / "sdk_contract")]
subprocess.run(command, check=True, timeout=30)
subprocess.run([str(tmp / "sdk_contract")], check=True, timeout=30)
+69
View File
@@ -0,0 +1,69 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Execute pinned SDK begin/abort and its real linked registry with injected
* allocation/erase failures. Not a flash or firmware-build test. */
#include "fakes.h"
#include <sys/queue.h>
#define WORD_ALIGNED_ATTR
#define ALIGN_UP(num, align) (((num) + ((align) - 1)) & ~((align) - 1))
#define ESP_PARTITION_SUBTYPE_APP_OTA_MAX 0x20
#define ESP_PARTITION_TYPE_BOOTLOADER 2
#define ESP_PARTITION_TYPE_PARTITION_TABLE 3
#define ESP_ERR_OTA_PARTITION_CONFLICT 10
#define OTA_WITH_SEQUENTIAL_WRITES 0xfffffffeU
#define OTA_SIZE_UNKNOWN 0xffffffffU
static const esp_partition_t running = {.type = ESP_PARTITION_TYPE_APP,
.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_0, .erase_size = 4096, .size = 0x400000};
static const esp_partition_t target = {.type = ESP_PARTITION_TYPE_APP,
.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_0 + 1, .erase_size = 4096, .size = 0x400000};
static bool fail_allocation, fail_erase;
static unsigned live_allocations, erase_calls;
static const esp_partition_t *esp_partition_verify(const esp_partition_t *p) { return p; }
const esp_partition_t *esp_ota_get_running_partition(void) { return &running; }
static void *sdk_calloc(size_t n, size_t size)
{
if (fail_allocation) return NULL;
void *p = calloc(n, size); assert(p); ++live_allocations; return p;
}
static void sdk_free(void *p) { assert(p && live_allocations); --live_allocations; free(p); }
static esp_err_t esp_partition_erase_range(const esp_partition_t *p, size_t offset, size_t size)
{
/* The actual registry allocation is still live when erase fails. */
assert(p == &target && !offset && size == 12288 && live_allocations == 1);
++erase_calls; return fail_erase ? ESP_FAIL : ESP_OK;
}
static void esp_image_bootloader_offset_set(uint32_t offset) { (void)offset; assert(0); }
static void *esp_flash_default_chip;
static void esp_flash_set_dangerous_write_protection(void *chip, bool enabled)
{
(void)chip; (void)enabled; assert(0);
}
#define calloc sdk_calloc
#define free sdk_free
#include "sdk_ota_production.h"
#undef calloc
#undef free
int main(void)
{
esp_ota_handle_t handle = 0;
assert(esp_ota_begin(NULL, 12000, &handle) == ESP_ERR_INVALID_ARG && !handle);
assert(esp_ota_begin(&running, 12000, &handle) == ESP_ERR_OTA_PARTITION_CONFLICT && !handle);
fail_allocation = true;
assert(esp_ota_begin(&target, 12000, &handle) == ESP_ERR_NO_MEM && !handle);
assert(!live_allocations && !erase_calls && LIST_EMPTY(&s_ota_ops_entries_head));
fail_allocation = false; fail_erase = true;
for (unsigned i = 0; i < 3; ++i) {
handle = 0;
assert(esp_ota_begin(&target, 12000, &handle) == ESP_FAIL && handle);
assert(get_ota_ops_entry(handle) && live_allocations == 1);
assert(esp_ota_abort(handle) == ESP_OK);
assert(!live_allocations && LIST_EMPTY(&s_ota_ops_entries_head));
assert(esp_ota_abort(handle) == ESP_ERR_NOT_FOUND);
}
fail_erase = false; handle = 0;
assert(esp_ota_begin(&target, 12000, &handle) == ESP_OK && handle && live_allocations == 1);
assert(esp_ota_abort(handle) == ESP_OK && !live_allocations);
puts("PASS actual ESP-IDF 5.5.0 begin/init/registry/abort: early failure has no handle; erase failure publishes live handle; abort frees it exactly once");
return 0;
}
+452
View File
@@ -0,0 +1,452 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "fakes.h"
#include "web_auth_parse.h"
#include "esp_app_format.h"
#include "esp_app_desc.h"
#include "esp_image_format.h"
#include "web_firmware_update.h"
static bool httpd_valid_req(httpd_req_t *r) { return r && r->aux; }
static size_t strlcpy(char *out, const char *in, size_t size)
{
size_t length = strlen(in);
if (size) { size_t n = length < size - 1 ? length : size - 1; memcpy(out, in, n); out[n] = 0; }
return length;
}
/* IDF getters compare ptrdiff_t with size_t; retain their exact source. */
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wsign-compare"
#include "httpd_production.h"
#pragma GCC diagnostic pop
static int s_lock;
static bool s_ready;
static web_cookie_auth_snapshot_t s_counts;
#include "auth_production.h"
static SemaphoreHandle_t s_server_mutex;
static httpd_handle_t s_server;
static bool s_transitioning;
static esp_err_t s_last_error;
static uint32_t s_generation;
#include "server_production.h"
static void *test_malloc(size_t);
static void test_free(void *);
#define malloc test_malloc
#define free test_free
#include "web_firmware_update.c"
#undef malloc
#undef free
static uint8_t image[12000], flash[12000];
static httpd_req_t request;
static esp_partition_t running, target;
static bool missing_target, missing_running;
static bool lookup_current, final_current, lookup_unavailable, final_unavailable;
static int role, stage_fail, response_fail, response_step;
static bool malloc_fail, task_fail, identity_busy, locked, mutex_busy;
static unsigned allocations, allocations_live, tasks_created, tasks_deleted;
static unsigned begins, writes, ends, aborts, metadata_reads, commits, receives, wipes;
static unsigned restarts, response_sends, releases, identity_releases;
static size_t flash_size, max_chunk, cutoff, stall_at, parsed_size;
static int64_t now_us, read_us;
static char response_status[64], response_body[160];
static uint32_t notification, identity_token;
static void (*task_entry)(void *);
static int task_storage, server_storage, mutex_storage;
static bool receive_race, commit_race, revoke_at_metadata;
static unsigned cases;
static bool ota_live;
static char raw_length[32], header_scratch[2048];
static struct httpd_req_aux request_aux;
static void *test_malloc(size_t size)
{
assert(size == BUFFER_SIZE); ++allocations;
if (malloc_fail) return NULL;
++allocations_live; return malloc(size);
}
static void test_free(void *p) { if (p) { assert(allocations_live == 1); --allocations_live; } free(p); }
void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
int64_t esp_timer_get_time(void) { return now_us; }
int xSemaphoreTake(SemaphoreHandle_t m, unsigned ticks)
{
assert(m == &mutex_storage && !locked);
if (mutex_busy) { assert(ticks == 0); return 0; }
locked = true; return pdTRUE;
}
void xSemaphoreGive(SemaphoreHandle_t m) { assert(m == &mutex_storage && locked); locked = false; }
int xTaskCreate(void (*entry)(void *), const char *name, unsigned stack, void *arg,
unsigned priority, TaskHandle_t *out)
{
assert(!strcmp(name, "fw_reboot") && stack == 2048 && !arg && priority == 5);
assert(s_transitioning && identity_token && !commits && !task_entry);
if (task_fail) return 0;
task_entry = entry; *out = &task_storage; ++tasks_created; return pdPASS;
}
int xTaskNotify(TaskHandle_t t, uint32_t value, int action)
{
assert(t == &task_storage && task_entry && !notification && action == eSetValueWithOverwrite);
assert(value == 1 || value == 2);
if (value == 1) assert(commits == 1 && response_sends == 1 && !response_fail && !aborts);
notification = value; return pdPASS;
}
int xTaskNotifyWait(uint32_t clear_in, uint32_t clear_out, uint32_t *value, uint32_t wait)
{
assert(!clear_in && clear_out == UINT32_MAX && wait == portMAX_DELAY && notification);
*value = notification; return pdTRUE;
}
void vTaskDelay(unsigned ticks) { assert(ticks == 500 && notification == 1); }
void vTaskDelete(TaskHandle_t task) { assert(!task); ++tasks_deleted; }
void esp_restart(void) { assert(!locked); ++restarts; }
static void finish_owner(void)
{
if (task_entry) {
assert(notification); task_entry(NULL); task_entry = NULL;
assert(tasks_deleted == tasks_created);
}
}
static void set_header(const char *name, const char *value)
{
for (size_t i = 0; i < request.header_count; ++i) {
if (!strcmp(name, request.headers[i].name)) { request.headers[i].value = value; return; }
}
assert(request.header_count < 12);
request.headers[request.header_count++] = (test_header_t){name, value};
}
static void set_length(size_t length)
{
request.content_len = length;
snprintf(raw_length, sizeof(raw_length), "%zu", length);
set_header("Content-Length", raw_length);
}
bool web_httpd_headers_valid(httpd_req_t *r)
{
request_aux = (struct httpd_req_aux){.scratch = header_scratch};
for (size_t i = 0; i < r->header_count; ++i) {
if (!r->headers[i].value) continue;
size_t used = request_aux.scratch_cur_size;
int written = snprintf(header_scratch + used, sizeof(header_scratch) - used,
"%s:%s", r->headers[i].name, r->headers[i].value);
assert(written >= 0 && (size_t)written + 1 <= sizeof(header_scratch) - used);
request_aux.scratch_cur_size += (size_t)written + 1;
++request_aux.req_hdrs_count;
}
r->aux = &request_aux;
return r->headers_valid && adapter_headers_valid(r);
}
bool web_httpd_unread_body(httpd_req_t *r) { return r->received < r->content_len; }
void web_httpd_wipe_request(httpd_req_t *r, bool unread)
{
assert(unread == (r->received < r->content_len)); ++wipes;
}
static esp_err_t response_result(void) { return ++response_step == response_fail ? ESP_FAIL : ESP_OK; }
esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s)
{
assert(r == &request && strlen(s) < sizeof(response_status)); strcpy(response_status, s);
return response_result();
}
esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s)
{
assert(r == &request && !strcmp(s, "application/json; charset=utf-8")); return response_result();
}
esp_err_t httpd_resp_set_hdr(httpd_req_t *r, const char *name, const char *value)
{
assert(r == &request && name && value); return response_result();
}
esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *body)
{
assert(r == &request && strlen(body) < sizeof(response_body));
++response_sends; strcpy(response_body, body); return response_result();
}
static void competing_lifecycle(void)
{
assert(s_transitioning && !locked);
assert(web_firmware_update_reserve(request.handle) == ESP_ERR_INVALID_STATE);
assert(web_server_reboot_current(s_generation) == ESP_ERR_INVALID_STATE);
assert(!restarts);
uint32_t token = 0;
assert(web_security_reserve_identity(0, false, &token) == ESP_ERR_INVALID_STATE && !token);
}
int httpd_req_recv(httpd_req_t *r, char *out, size_t want)
{
assert(r == &request && want && want <= BUFFER_SIZE && s_transitioning && identity_token);
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
++receives; now_us += read_us;
if (receive_race) competing_lifecycle();
if (r->received >= stall_at) return HTTPD_SOCK_ERR_TIMEOUT;
if (r->received >= cutoff) return 0;
size_t count = want < max_chunk ? want : max_chunk;
if (count > cutoff - r->received) count = cutoff - r->received;
assert(r->received + count <= sizeof(image));
memcpy(out, image + r->received, count); r->received += count; return (int)count;
}
esp_err_t web_session_store_lookup(const char *token, size_t length, const char *origin,
size_t origin_length, web_session_view_t *view)
{
assert(length == 64 && token[0] == 'a' && origin_length == strlen(origin));
assert(!strcmp(origin, "https://device"));
if (lookup_unavailable) return ESP_FAIL;
if (!lookup_current) return ESP_ERR_NOT_FOUND;
view->id = 42; view->principal.role = role; view->principal.user_id = 9;
memset(view->csrf, 'b', 64); view->csrf[64] = 0; return ESP_OK;
}
esp_err_t web_session_store_check_principal(web_session_id_t id, const user_principal_t *principal, bool *current)
{
assert(id == 42 && principal->role == USER_ROLE_ADMIN && principal->user_id == 9);
assert(ends == 1 && metadata_reads == 1 && !commits);
*current = final_current; return final_unavailable ? ESP_FAIL : ESP_OK;
}
esp_err_t web_security_reserve_identity(uint32_t expected, bool reset, uint32_t *token)
{
assert(!expected && !reset && s_transitioning); *token = 0;
if (identity_busy || identity_token) return ESP_ERR_INVALID_STATE;
*token = identity_token = 7; return ESP_OK;
}
void web_security_release_identity(uint32_t token)
{
assert(token && token == identity_token && s_transitioning);
identity_token = 0; ++identity_releases;
}
const esp_partition_t *esp_ota_get_running_partition(void) { return missing_running ? NULL : &running; }
const esp_partition_t *esp_ota_get_next_update_partition(const esp_partition_t *p)
{
assert(!p); return missing_target ? NULL : &target;
}
esp_err_t esp_ota_begin(const esp_partition_t *p, size_t size, esp_ota_handle_t *handle)
{
assert(p == &target && p->address != running.address && size == request.content_len);
assert(size <= target.size && request.received == PREFIX_SIZE && s_transitioning && identity_token);
assert(tasks_created == 1 && !locked); ++begins;
assert(!ota_live && *handle == 0);
if (stage_fail == 1) return ESP_FAIL;
*handle = 123; ota_live = true;
/* IDF publishes the handle before erasing; this models erase failure. */
return stage_fail == 6 ? ESP_FAIL : ESP_OK;
}
esp_err_t esp_ota_write(esp_ota_handle_t handle, const void *data, size_t size)
{
assert(handle == 123 && s_transitioning && !locked && size <= BUFFER_SIZE);
assert(flash_size + size <= sizeof(flash)); ++writes;
if (stage_fail == 2) return ESP_FAIL;
memcpy(flash + flash_size, data, size); flash_size += size; return ESP_OK;
}
esp_err_t esp_ota_end(esp_ota_handle_t handle)
{
assert(handle == 123 && flash_size == request.content_len && !memcmp(flash, image, flash_size));
assert(!aborts && !locked && s_transitioning && tasks_created == 1 && ota_live); ++ends;
ota_live = false; /* End consumes on success AND validation failure. */
return stage_fail == 3 ? ESP_FAIL : ESP_OK;
}
esp_err_t esp_ota_abort(esp_ota_handle_t handle)
{
assert(handle == 123 && begins == 1 && !ends && !commits && ota_live);
ota_live = false; ++aborts; return ESP_OK;
}
esp_err_t esp_image_get_metadata(const esp_partition_pos_t *p, esp_image_metadata_t *metadata)
{
assert(p->offset == target.address && p->size == target.size && ends == 1);
++metadata_reads; metadata->image_len = (uint32_t)parsed_size;
if (revoke_at_metadata) final_current = false;
return stage_fail == 4 ? ESP_FAIL : ESP_OK;
}
esp_err_t esp_ota_set_boot_partition(const esp_partition_t *p)
{
assert(p == &target && ends == 1 && metadata_reads == 1 && final_current && !final_unavailable);
assert(tasks_created == 1 && !response_sends && s_transitioning && identity_token && !locked);
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
if (commit_race) competing_lifecycle();
if (stage_fail == 5) return ESP_FAIL;
++commits; return ESP_OK;
}
static void reset(void)
{
assert(!allocations_live && !task_entry && !locked && !ota_live);
atomic_store(&s_reboot_gate, false);
s_firmware_selected = false;
++cases;
memset(&request, 0, sizeof(request));
request.uri = WEB_FIRMWARE_UPDATE_URI; request.method = HTTP_POST;
request.content_len = sizeof(image); request.handle = &server_storage; request.headers_valid = true;
set_header("Host", "device"); set_header("Origin", "https://device");
set_header("Content-Type", "application/octet-stream");
set_length(sizeof(image));
set_header("Cookie", SESSION_COOKIE "=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
set_header("X-CSRF-Token", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb");
memset(image, 0x55, sizeof(image)); memset(flash, 0, sizeof(flash));
esp_image_header_t header = {.magic = ESP_IMAGE_HEADER_MAGIC, .chip_id = ESP_CHIP_ID_ESP32S3,
.segment_count = 3, .hash_appended = 1};
esp_image_segment_header_t segment = {.data_len = sizeof(esp_app_desc_t)};
uint32_t magic = ESP_APP_DESC_MAGIC_WORD;
memcpy(image, &header, sizeof(header)); memcpy(image + sizeof(header), &segment, sizeof(segment));
memcpy(image + sizeof(header) + sizeof(segment), &magic, sizeof(magic));
running = (esp_partition_t){.type = 0, .subtype = 0x10, .address = 0x10000, .size = 0x400000};
target = (esp_partition_t){.type = 0, .subtype = 0x11, .address = 0x410000, .size = 0x400000};
missing_target = missing_running = false;
s_ready = lookup_current = final_current = true;
s_server_mutex = &mutex_storage; s_server = &server_storage;
s_transitioning = false; s_last_error = ESP_OK; s_generation = 11;
lookup_unavailable = final_unavailable = malloc_fail = task_fail = identity_busy = mutex_busy = false;
receive_race = commit_race = revoke_at_metadata = false;
role = USER_ROLE_ADMIN; stage_fail = response_fail = response_step = 0;
allocations = allocations_live = tasks_created = tasks_deleted = 0;
begins = writes = ends = aborts = metadata_reads = commits = receives = wipes = 0;
restarts = response_sends = releases = identity_releases = 0;
flash_size = 0; max_chunk = BUFFER_SIZE; cutoff = stall_at = SIZE_MAX; parsed_size = sizeof(image);
now_us = 0; read_us = 1000; notification = identity_token = 0;
response_status[0] = response_body[0] = 0;
}
static void rejected(const char *status, const char *code)
{
bool pre_reserved = s_transitioning;
bool gate_reserved = atomic_load(&s_reboot_gate);
esp_err_t result = web_firmware_update_handler(&request);
assert(!strcmp(response_status, status) && strstr(response_body, code));
assert(result == (request.received < request.content_len ? ESP_FAIL : ESP_OK));
assert(!commits && !allocations_live && !restarts && wipes == 1 && !ota_live);
assert(s_transitioning == pre_reserved && !identity_token);
assert(atomic_load(&s_reboot_gate) == gate_reserved);
if (task_entry) assert(notification == 2);
finish_owner(); assert(!restarts);
}
static void success(void)
{
assert(web_firmware_update_handler(&request) == ESP_OK);
assert(!strcmp(response_status, "200 OK") && !strcmp(response_body, "{\"ok\":true,\"rebooting\":true}"));
assert(commits == 1 && ends == 1 && !aborts && s_transitioning && identity_token);
assert(!allocations_live && notification == 1 && !restarts && wipes == 1);
assert(s_firmware_selected && web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
assert(request.received == request.content_len && !memcmp(image, flash, sizeof(image)));
finish_owner(); assert(restarts == 1);
}
int main(void)
{
reset();
assert(web_firmware_update_reserve_reboot() == ESP_OK);
rejected("503 Service Unavailable", "busy");
assert(!receives && !begins && !allocations);
reset(); s_server_mutex = NULL; s_server = NULL;
assert(web_firmware_update_reserve_reboot() == ESP_OK);
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
reset(); success();
reset(); max_chunk = 1; receive_race = commit_race = true; success();
assert(receives == sizeof(image));
reset(); max_chunk = 17; success();
puts("PASS bounded streaming, bytewise partial prefix, exact writes, delayed owner restart and reservation races");
reset(); lookup_current = false; rejected("401 Unauthorized", "authentication_required"); assert(!receives && !allocations);
reset(); lookup_unavailable = true; rejected("503 Service Unavailable", "unavailable");
reset(); role = USER_ROLE_USER; rejected("403 Forbidden", "admin_required"); assert(!receives && !allocations);
reset(); set_header("X-CSRF-Token", NULL); rejected("403 Forbidden", "csrf");
reset(); set_header("X-CSRF-Token", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); rejected("403 Forbidden", "csrf");
reset(); set_header("Origin", NULL); rejected("403 Forbidden", "origin");
reset(); set_header("Origin", "null"); rejected("403 Forbidden", "origin");
reset(); set_header("Origin", "https://attacker"); rejected("403 Forbidden", "origin");
reset(); set_header("Sec-Fetch-Site", "cross-site"); rejected("403 Forbidden", "origin");
reset(); set_header("Cookie", NULL); rejected("401 Unauthorized", "authentication_required");
reset(); request.headers_valid = false; rejected("400 Bad Request", "invalid_request");
reset(); request.uri = "/api/firmware?x=1"; rejected("400 Bad Request", "invalid_request");
reset(); request.method = HTTP_GET; rejected("400 Bad Request", "invalid_request");
reset(); s_ready = false; rejected("503 Service Unavailable", "unavailable");
puts("PASS production cookie/Origin/CSRF/admin admission before receive or flash (SDK getters/adapter real, database doubled)");
const char *invalid_lengths[] = {NULL, "", " ", "+12000", "-12000", "12000x", "1.2e4",
"12 000", "12000,12000", "12000 ", "12000\t", "\t12000", "11999", "12001",
"000000000000000012000"};
for (size_t i = 0; i < sizeof(invalid_lengths) / sizeof(invalid_lengths[0]); ++i) {
reset(); set_header("Content-Length", invalid_lengths[i]);
rejected("400 Bad Request", "invalid_request");
assert(!receives && !begins && !allocations && !notification);
}
const char *oversized_lengths[] = {"4194305", "4296815136", "4294967296",
"18446744073709551615", "18446744073709551616", "99999999999999999999"};
for (size_t i = 0; i < sizeof(oversized_lengths) / sizeof(oversized_lengths[0]); ++i) {
reset(); set_header("Content-Length", oversized_lengths[i]);
/* Explicitly model HTTPD's 64-bit -> ESP32 size_t narrowing. */
request.content_len = i == 1 ? 1847840 : i == 2 ? 0 : sizeof(image);
rejected("413 Payload Too Large", "firmware_too_large");
assert(!receives && !begins && !allocations && !notification);
}
reset();
request.headers[request.header_count++] = (test_header_t){"content-length", "12000"};
rejected("400 Bad Request", "invalid_request"); assert(!receives && !begins && !allocations);
reset(); set_header("Content-Length", " 12000"); success();
reset(); set_header("Content-Length", "00000000000000012000"); success();
puts("PASS raw Content-Length missing/duplicate/malformed/oversized/64-bit wrap and overflow reject before receive/erase; IDF leading-space semantics");
reset(); set_header("Content-Type", "multipart/form-data"); rejected("415 Unsupported Media Type", "firmware_content_type");
reset(); set_header("Content-Type", NULL); rejected("415 Unsupported Media Type", "firmware_content_type");
reset(); set_length(0); rejected("400 Bad Request", "invalid_firmware");
reset(); set_length(PREFIX_SIZE - 1); rejected("400 Bad Request", "invalid_firmware");
reset(); set_length(target.size + 1); rejected("413 Payload Too Large", "firmware_too_large"); assert(!allocations);
reset(); target.size = sizeof(image); success();
reset(); missing_target = true; rejected("503 Service Unavailable", "firmware_unavailable");
reset(); missing_running = true; rejected("503 Service Unavailable", "firmware_unavailable");
reset(); target.address = running.address; rejected("503 Service Unavailable", "firmware_unavailable");
reset(); target.type = 1; rejected("503 Service Unavailable", "firmware_unavailable");
reset(); target.subtype = 0; rejected("503 Service Unavailable", "firmware_unavailable");
for (unsigned field = 0; field < 6; ++field) {
reset(); esp_image_header_t header; memcpy(&header, image, sizeof(header));
if (field == 0) header.magic = 0;
if (field == 1) header.chip_id = ESP_CHIP_ID_ESP32;
if (field == 2) header.segment_count = 0;
if (field == 3) header.hash_appended = 0;
memcpy(image, &header, sizeof(header));
if (field == 4) memset(image + sizeof(header) + sizeof(esp_image_segment_header_t), 0, 4);
if (field == 5) memset(image + sizeof(header), 0, sizeof(esp_image_segment_header_t));
rejected("400 Bad Request", "invalid_firmware"); assert(!begins && !aborts);
}
puts("PASS content type, length/capacity, inactive app selection, format/chip/descriptor/hash header rejection before erase");
reset(); s_transitioning = true; rejected("503 Service Unavailable", "busy");
reset(); identity_busy = true; rejected("503 Service Unavailable", "busy");
reset(); mutex_busy = true; rejected("503 Service Unavailable", "busy");
reset(); s_server = NULL; rejected("503 Service Unavailable", "busy");
reset(); malloc_fail = true; rejected("503 Service Unavailable", "firmware_resources"); assert(!tasks_created && !begins);
reset(); task_fail = true; rejected("503 Service Unavailable", "firmware_resources"); assert(!tasks_created && !begins);
for (int stage = 1; stage <= 5; ++stage) {
reset(); stage_fail = stage;
rejected(stage == 3 || stage == 4 ? "400 Bad Request" : "500 Internal Server Error",
stage == 3 || stage == 4 ? "invalid_firmware" : stage == 5 ? "firmware_commit_failed" : "firmware_write_failed");
assert(aborts == (unsigned)(stage == 2));
}
reset(); stage_fail = 6;
rejected("500 Internal Server Error", "firmware_write_failed");
assert(begins == 1 && aborts == 1 && !writes && !ends && !ota_live);
puts("PASS begin failure after handle publication aborts once; unpublished failure and consumed end never double-abort");
reset(); parsed_size--; rejected("400 Bad Request", "invalid_firmware");
reset(); parsed_size++; rejected("400 Bad Request", "invalid_firmware");
reset(); revoke_at_metadata = true; rejected("401 Unauthorized", "authentication_required");
reset(); final_unavailable = true; rejected("401 Unauthorized", "authentication_required");
puts("PASS busy/resources/OTA begin-write-end-metadata-commit failures, final auth revocation and exact SDK image length");
reset(); cutoff = 10; rejected("400 Bad Request", "firmware_incomplete"); assert(!begins);
reset(); cutoff = 300; rejected("400 Bad Request", "firmware_incomplete"); assert(aborts == 1);
reset(); stall_at = 0; read_us = 1000000; rejected("408 Request Timeout", "firmware_timeout"); assert(!begins && receives == 10);
reset(); stall_at = PREFIX_SIZE; read_us = 1000000; rejected("408 Request Timeout", "firmware_timeout"); assert(aborts == 1);
reset(); read_us = STALL_US; rejected("408 Request Timeout", "firmware_timeout"); assert(!begins);
reset(); max_chunk = 1; read_us = 40000; rejected("408 Request Timeout", "firmware_timeout"); assert(aborts == 1 && now_us == TOTAL_US);
puts("PASS incomplete body, stalled receive and total slow-drip deadline; no selection/reboot, handle abort when live");
for (int step = 1; step <= 6; ++step) {
reset(); response_fail = step;
assert(web_firmware_update_handler(&request) == ESP_FAIL);
assert(commits == 1 && !s_transitioning && !identity_token && !allocations_live);
assert(notification == 2 && !restarts && !aborts); finish_owner(); assert(!restarts);
assert(s_firmware_selected && !atomic_load(&s_reboot_gate));
request.received = 0; response_fail = 0;
assert(web_firmware_update_handler(&request) == ESP_FAIL);
assert(!strcmp(response_status, "409 Conflict") && strstr(response_body, "firmware_selected_reboot_required"));
assert(commits == 1 && begins == 1 && !atomic_load(&s_reboot_gate));
assert(web_firmware_update_reserve_reboot() == ESP_OK);
assert(web_firmware_update_reserve_reboot() == ESP_ERR_INVALID_STATE);
}
puts("PASS postcommit response failure: selected image retained, no automatic reboot, resources/reservations released");
printf("PASS %u firmware backend cases; SDK validation, flash and scheduling are mocked, not device evidence\n", cases);
return 0;
}
+1 -1
View File
@@ -64,7 +64,7 @@ survives until its owner releases it after service stop/start.
`python3 tests/web_admin_transport/server_lifecycle.py` additionally links real
production security and mbedTLS to the production HTTPS owner, with NVS/HTTPD doubles,
for unchanged identity/storage before commit and no rollback after stop/start failure.
See [HTTPS identity contracts](../../docs/web_administration.md#service-lifecycle-and-identity-rotation) and [acceptance evidence and limits](../../docs/web_administration_acceptance.md).
See [HTTPS identity contracts](../../docs/web_administration.md#service-lifecycle-and-identity-rotation) and [acceptance evidence and limits](../../docs/roadmap.md#phase8-acceptance-evidence).
Removed: two credential functions (`show_credentials`, `rotate_credentials`),
one credential struct type, three username/password capacity/length constants,
+2 -2
View File
@@ -17,7 +17,7 @@ and inline asset-failure script, not a separately maintained implementation.
Historical 8D.17 run: **126 behavior groups**, plus production renderer/HTML/CSP checks. Seven new quick groups cover mouse/focus/touch activation, single DOM ownership, focused-editor hover stability, Escape/outside dismissal, late reads/expiry, terminal input/drain isolation, full-page draft/promotion preservation, explicit Apply/Save and pending non-replay, Network secret exclusion/strict schema/401. `Element` focus/containment/hover and events are doubles, not browser event-dispatch proof.
Optional geometry: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py`. The fixture covers 18 cases (four full views plus Serial/Wi-Fi quick panels at320/600/1200px, quick height360), including viewport bounds/scrolling and secret-control exclusion. The 8D.17 attempt was **blocked before measurement** by sandbox socket/Crash Reports restrictions; no geometry pass claimed. See [contextual-control contracts](../../docs/web_administration.md#broker-and-contextual-controls), [acceptance evidence and limits](../../docs/web_administration_acceptance.md), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
Optional geometry: `WEB_UI_CHROMIUM=/usr/bin/chromium-browser python3 tests/web_ui_session/run.py`. The fixture covers 36 cases at 320/600/900/1200px: full settings and quick panels plus dashboard card hit-testing, overflow/ellipsis, compact controls, status visibility, and full-page restoration. Quick panels use height 360px. The 2026-09-14 cosmetic update passed these Chromium fixtures and all 161 browser behavior groups; fixtures are not live-device validation. See [contextual-control contracts](../../docs/web_administration.md#broker-and-contextual-controls), [acceptance evidence and limits](../../docs/roadmap.md#phase8-acceptance-evidence), and [regression procedures](../../docs/user_administration_tests.md#integrated-web-administration-regression-procedure).
Coverage:
@@ -134,7 +134,7 @@ These tests model DOM, timers, fetch cancellation and WebSocket events. They do
not prove real-browser CSP enforcement, script-loading errors, TLS/HTTPD behavior,
actual bfcache policy, cookie expiry, server revocation, or hardware serial byte
integrity, actual xterm escape parsing, hidden prompts, or desktop/mobile layout.
See [Accounts contracts](../../docs/web_administration.md#accounts-and-authorized-keys) and [acceptance evidence](../../docs/web_administration_acceptance.md). The following are historical 8D.10 slice 2 reports, not current totals: parent build PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash.
See [Accounts contracts](../../docs/web_administration.md#accounts-and-authorized-keys) and [acceptance evidence](../../docs/roadmap.md#phase8-acceptance-evidence). The following are historical 8D.10 slice 2 reports, not current totals: parent build PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash.
The generated endpoint is independently optionally registered (23 handlers), with
route-agent lifecycle 15 PASS for failure isolation/restart. UI 57/CSP and lifecycle
15 results are agent-attributed, not claims of the parent's additional reruns.
+38 -3
View File
@@ -14,7 +14,15 @@ const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => se
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': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': [], '/api/settings/lifecycle': [], '/api/settings/lifecycle-operation': []};
const fits = [];
const fits = [], uploads = [];
class Upload {
constructor() { this.upload = {}; this.headers = {}; uploads.push(this); }
open(method, url) { this.method = method; this.url = url; }
setRequestHeader(key, value) { this.headers[key] = value; }
send(file) { this.file = file; }
abort() { this.aborted = true; this.onabort?.(); }
reply(status, value) { this.status = status; this.responseText = typeof value === 'string' ? value : JSON.stringify(value); this.onload?.(); }
}
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
const on = (key, fn) => { if (!(events[key] ||= []).includes(fn)) events[key].push(fn); };
@@ -60,7 +68,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
constructor() { this.measurements = []; this.calls = 0; fits.push(this); }
proposeDimensions() { ++this.calls; return this.measurements.length ? this.measurements.shift() : {cols: 80, rows: 24}; }
}},
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket,
TextEncoder, TextDecoder, Uint8Array, ArrayBuffer, AbortController, URL, Date: Clock, performance: {now: () => now}, WebSocket: Socket, XMLHttpRequest: Upload,
fetch: async (url, options) => {
// Apply the Origin regression guard to every mutation, including logout.
assert.ok(Object.hasOwn(queues, url));
@@ -83,7 +91,7 @@ function browser({onlyLoader = false, withLoader = false, role = 'user', usernam
const match = [...timers].find(([, t]) => t.ms === ms); assert.ok(match, `missing timer ${ms}`);
const [id, t] = match; if (!t.interval) timers.delete(id); t.fn();
};
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire,
return {nodes, document: context.document, calls, redirects, timers, sockets, terminals, queues, fits, events, emit, start, fire, uploads,
click: id => nodes[id].click(), elapse: ms => { now += ms; }, window};
}
async function connected() { const b = browser(); b.start(); await tick(); assert.equal(b.sockets.length, 1); return b; }
@@ -115,6 +123,32 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
trigger.pointerenter({pointerType:'mouse'}); await tick(); assert.equal(host['data-quick'],'false');
b.terminals[0].input('x'); assert.equal(ws.sent.length, 1); assert.ok(!ws.closed);
});
await test('Every quick card retains Serial/Admin title, toolbar and selected-view semantics until full promotion', async () => {
for (const mode of ['serial', 'admin']) for (const domain of ['serial', 'network', 'broker', 'writer']) {
const b = browser({role:'admin'}); b.start(); await tick();
if (mode === 'admin') { b.click('select-admin'); await tick(); }
const assertView = view => {
assert.equal(b.nodes['terminal-title'].textContent, {serial:'Live serial stream', admin:'Administration shell', settings:'Settings'}[view], domain);
for (const id of ['admin-toggle', 'admin-detail']) assert.equal(b.nodes[id].hidden, view !== 'admin', id);
for (const choice of ['serial', 'admin', 'settings']) assert.equal(b.nodes['select-' + choice]['aria-pressed'], String(choice === view), choice);
assert.equal(b.nodes.terminal.hidden, view !== 'serial');
assert.equal(b.nodes['admin-terminal'].hidden, view !== 'admin');
};
b.click('quick-' + domain); await tick(); assertView(mode);
assert.equal(b.nodes['serial-settings'].hidden, false);
assert.equal(b.nodes['serial-settings']['data-quick'], 'true');
b.click('quick-close'); await tick(); assertView(mode);
b.click('quick-' + domain); await tick();
const calls = b.calls.length;
b.nodes['quick-full'].click({preventDefault(){}}); await tick();
assertView('settings');
assert.equal(b.nodes['serial-settings']['data-quick'], 'false');
assert.equal(b.nodes['quick-header'].hidden, true);
assert.equal(b.nodes['quick-' + domain]['aria-expanded'], 'false');
assert.equal(b.document.activeElement, b.nodes['settings-' + (domain === 'writer' ? 'broker' : domain)]);
assert.equal(b.calls.length, calls, 'promotion must not reload the shared controller');
}
});
await test('Quick adjacent trigger keyboard focus survives switching without focusing either terminal', async () => {
const b = browser({role:'admin'}); b.start(); await tick();
b.nodes['quick-network'].focus(); await tick();
@@ -1373,5 +1407,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./ssh.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./lifecycle.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
await require('./firmware.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; });
+91
View File
@@ -0,0 +1,91 @@
'use strict';
const assert = require('node:assert/strict');
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html}) => {
const snapshot = {generation:7,running:true,transitioning:false,controllable:true,identity_generation:11,fingerprint:'ab'.repeat(32),rotatable:true};
const file = (extra={}) => ({name:'firmware.bin',size:4194304,type:'',...extra});
const n = (b,id) => b.document.getElementById('firmware-'+id);
async function open() {
const b=await adminBrowser(); b.click('select-settings'); await tick();
b.queues['/api/settings/lifecycle'].push(json(snapshot)); b.click('settings-lifecycle'); await tick();
n(b,'file').files=[file()]; return b;
}
async function upload(b) { b.click('firmware-upload'); await tick(); return b.uploads.at(-1); }
await test('Firmware admin-only card validates filename hint/size, not unreliable browser MIME, and requires explicit confirmation', async()=>{
assert.match(html,/accept="\.bin"/); assert.match(html,/firmware\.bin built for this device/); assert.match(html,/Saved settings are kept/);
const u=browser(); u.start(); await tick(); n(u,'file').files=[file()]; await upload(u); assert.equal(u.uploads.length,0);
for(const f of [null,file({name:'flash.txt'}),file({size:0}),file({size:4194305}),file({size:NaN}),file({size:1.5})]) {
const b=await open(); n(b,'file').files=f?[f]:[]; await upload(b); assert.equal(b.uploads.length,0); assert.match(n(b,'detail').textContent,/nonempty .bin/);
}
const b=await open(); let warning=''; b.window.confirm=s=>{warning=s;return false;}; await upload(b); assert.equal(b.uploads.length,0);
assert.equal(warning,'Upload firmware and reboot? All connections will close. Saved settings are kept; unsaved changes will be lost.');
b.window.confirm=()=>true; n(b,'file').files=[file({name:'APP.BIN',type:'text/plain'})]; const x=await upload(b); assert.ok(x.file);
});
await test('Firmware sends original File raw with current CSRF and browser-managed headers; one flight and lifecycle controls gated',async()=>{
const b=await open(), f=n(b,'file').files[0], x=await upload(b);
assert.equal(x.method,'POST'); assert.equal(x.url,'/api/firmware'); assert.equal(x.file,f);
assert.deepEqual(x.headers,{'Content-Type':'application/octet-stream','X-CSRF-Token':token}); assert.equal(x.timeout,180000);
assert.ok(n(b,'file').disabled && n(b,'upload').disabled);
for(const action of ['stop','restart','rotate','reboot']) {assert.ok(b.nodes['lifecycle-'+action].disabled); b.click('lifecycle-'+action);}
await upload(b); assert.equal(b.uploads.length,1); assert.ok(!b.calls.some(c=>c.url.endsWith('lifecycle-operation')&&c.method==='POST'));
x.upload.onprogress({lengthComputable:true,loaded:25,total:100}); assert.equal(n(b,'progress').value,25);
x.upload.onprogress({lengthComputable:true,loaded:100,total:100}); assert.match(n(b,'detail').textContent,/Validating firmware/);
for(const e of [{lengthComputable:false},{lengthComputable:true,loaded:NaN,total:100},{lengthComputable:true,loaded:1,total:0}]) { x.upload.onprogress(e); assert.match(n(b,'detail').textContent,/unavailable/); }
x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/accepted; rebooting/); assert.match(n(b,'detail').textContent,/Reconnect and sign in/);
await upload(b); assert.equal(b.uploads.length,1); assert.ok(n(b,'upload').disabled);
x.onerror(); assert.match(n(b,'detail').textContent,/accepted; rebooting/);
});
await test('Firmware fences delayed session validation, changed identity/role/CSRF and logout before sending',async()=>{
for(const response of [failure(401),session({role:'user'}),session({role:'admin',username:'changed'}),session({role:'admin',csrf:'b'.repeat(64)})]) {
const b=await open(); b.queues['/api/session'].push(response); await upload(b); assert.equal(b.uploads.length,0); assert.ok(b.redirects.length);
}
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); await upload(b); await upload(b); assert.equal(b.uploads.length,0);
b.click('sign-out'); await tick(); d.resolve(session({role:'admin'})); await tick(); assert.equal(b.uploads.length,0);
});
await test('Firmware session loss/pagehide/logout abort once and fence all late progress/success/error/401 callbacks',async()=>{
for(const end of ['logout','pagehide','expiry','identity']) {
const b=await open(), x=await upload(b);
if(end==='logout') b.click('sign-out');
else if(end==='pagehide') b.emit('pagehide');
else if(end==='expiry') x.reply(401,{error:'authentication_required'});
else { b.queues['/api/session'].push(session({role:'admin',username:'changed'})); b.click('select-serial'); b.click('connection-toggle'); await tick(); b.click('connection-toggle'); }
await tick(); assert.ok(x.aborted,end); const detail=n(b,'detail').textContent, redirects=b.redirects.length;
assert.match(detail,/may already be installed/);
x.upload.onprogress({lengthComputable:true,loaded:50,total:100}); x.reply(200,{ok:true,rebooting:true}); x.reply(401,{}); x.onerror();
assert.equal(n(b,'detail').textContent,detail); assert.equal(b.redirects.length,redirects); assert.equal(b.uploads.length,1);
}
});
await test('Firmware navigation retains single upload and lifecycle gate without resending',async()=>{
const b=await open(), x=await upload(b); b.click('settings-serial'); await tick(); b.click('settings-lifecycle'); await tick();
assert.ok(!x.aborted); assert.ok(b.nodes['lifecycle-reboot'].disabled); await upload(b); assert.equal(b.uploads.length,1);
x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/rebooting/);
});
await test('Firmware maps every backend error safely, never displays arbitrary response text or retries',async()=>{
const groups={400:['invalid_request','invalid_firmware','firmware_incomplete'],403:['origin','csrf','admin_required'],408:['firmware_timeout'],413:['firmware_too_large'],415:['firmware_content_type'],500:['firmware_write_failed','firmware_commit_failed'],503:['unavailable','busy','firmware_unavailable','firmware_resources']};
for(const [status,codes] of Object.entries(groups)) for(const error of codes) {
const b=await open(), x=await upload(b); x.reply(Number(status),{error}); assert.doesNotMatch(n(b,'detail').textContent,/Update status unknown/); assert.ok(n(b,'detail').textContent.length > 0 && n(b,'detail').textContent.length < 100); assert.equal(b.uploads.length,1);
}
for(const response of ['SECRET ERROR BODY','x'.repeat(129),{ok:true},{ok:true,rebooting:true,secret:'bad'},{error:'<script>'},{error:'toString'}]) {
const b=await open(), x=await upload(b); x.reply(200,response); assert.match(n(b,'detail').textContent,/Update status unknown/); assert.doesNotMatch(n(b,'detail').textContent,/SECRET|script|bad/); assert.ok(n(b,'upload').disabled);
}
});
await test('Firmware refuses competing lifecycle work and stale prior-upload callbacks cannot affect an explicit retry',async()=>{
const b=await open(), d=deferred(); b.queues['/api/session'].push(d.promise); b.click('lifecycle-reboot'); await tick();
await upload(b); assert.equal(b.uploads.length,0); assert.ok(n(b,'upload').disabled);
b.queues['/api/settings/lifecycle-operation'].push(new Response(JSON.stringify({id:1,action:'reboot',state:'pending'}),{status:202}));
d.resolve(session({role:'admin'})); await tick(); await upload(b); assert.equal(b.uploads.length,0);
const retry=await open(), first=await upload(retry); first.reply(503,{error:'busy'});
assert.equal(n(retry,'upload').disabled,false); n(retry,'file').files=[file()]; const second=await upload(retry);
const message=n(retry,'detail').textContent; first.reply(401,{}); first.onerror(); first.upload.onprogress({lengthComputable:true,loaded:1,total:2});
assert.equal(retry.redirects.length,0); assert.equal(n(retry,'detail').textContent,message); assert.ok(!second.aborted);
second.reply(200,{ok:true,rebooting:true}); assert.match(n(retry,'detail').textContent,/accepted; rebooting/);
const preflight=await open(); preflight.queues['/api/session'].push(()=>{throw Error('offline');}); await upload(preflight);
assert.equal(preflight.uploads.length,0); assert.match(n(preflight,'detail').textContent,/no upload sent/); assert.equal(n(preflight,'upload').disabled,false);
});
await test('Firmware network/timeout/abort and lost acknowledgement are uncertain, locked, and never auto-replayed',async()=>{
for(const event of ['onerror','ontimeout','onabort']) {
const b=await open(), x=await upload(b); x[event](); assert.match(n(b,'detail').textContent,/Reconnect and check/); assert.ok(n(b,'upload').disabled);
await upload(b); assert.equal(b.uploads.length,1); x.reply(200,{ok:true,rebooting:true}); assert.match(n(b,'detail').textContent,/Update status unknown/);
}
const fresh=await open(); assert.equal(fresh.uploads.length,0); assert.equal(n(fresh,'upload').disabled,false);
});
};
+93 -8
View File
@@ -52,6 +52,19 @@ def check_layout(html):
assert trigger['attrs']['aria-expanded'] == 'false'
assert trigger['attrs']['aria-haspopup'] == 'dialog'
assert trigger['attrs']['aria-label'] and 'disabled' in trigger['attrs']
assert {'status-item', 'quick-trigger'} <= set(classes(trigger)), domain
assert 'status-grid' in classes(trigger['parent']), 'the button itself must be the grid card'
assert [classes(n) for n in trigger['children']] == [['label'], ['value']], domain
assert all(n['tag'] == 'span' for n in trigger['children'])
value_id = {'serial': 'serial-summary', 'network': 'wifi-summary', 'broker': 'broker-clients', 'writer': 'writer-id'}[domain]
assert ids[value_id]['parent'] is trigger
assert ('wide' in classes(trigger)) == (domain in ('serial', 'network'))
assert not any(n['tag'] in ('button', 'a', 'input', 'select') for n in descendants(trigger))
options = ids['network-target']['children']
assert [n['attrs']['value'] for n in options] == ['ap', '0', '1', '2', '3']
for i, option in enumerate(options[1:]):
assert ids['network-profile-' + str(i)] is option
assert 'hidden' not in option['attrs'] and 'disabled' not in option['attrs']
assert ids['quick-full']['tag'] == 'a' and ids['quick-full']['attrs']['href'] == '#serial-settings'
assert 'hidden' in ids['quick-header']['attrs']
assert ids['network-password']['parent'] is ids['network-password-label']
@@ -87,6 +100,19 @@ def check_layout(html):
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 (
'.quick-trigger{display:block;width:100%;color:inherit;text-align:left;font:inherit;cursor:pointer;overflow:hidden}',
'.status-grid{min-width:0;padding:16px;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}',
'.status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}',
'.status-item{min-width:0;',
'.value{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
"[data-quick='true'] #quick-header>h2{display:block}",
"[data-quick='true'] #settings-values>:nth-child(n+3){display:none}",
"[data-quick='true'] #network-edit>*{display:none}",
"[data-quick='true'] #network-edit>.settings-edit>label{display:none}",
"[data-quick='true'] #network-edit>.settings-edit>#network-target-label,[data-quick='true'] #network-edit>.settings-edit>#network-policy-label{display:grid}",
"[data-quick='true'] #network-edit>.settings-edit>#network-enabled-label{display:flex}",
"[data-quick='true'] #network-wifi-load{display:none}",
'.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}',
@@ -100,8 +126,18 @@ def check_layout(html):
'.settings-edit{grid-template-columns:minmax(0,1fr)}',
):
assert rule in css, rule
# These selectors must share a hide rule, not merely occur in explanatory text.
import re
hide_selectors = {selector.strip() for selectors in re.findall(r'([^{}]+)\{display:none\}', css) for selector in selectors.split(',')}
for selector in ('#settings-navigation', '#quick-help', 'h3', '>div>h2', 'p:not([role=status])', '#network-summary', '#broker-values'):
scoped = "[data-quick='true']" + ('' if selector.startswith('>') else ' ') + selector
assert scoped in hide_selectors, scoped
for ident in ('settings-detail', 'serial-operation-detail', 'network-detail', 'network-operation-detail', 'broker-detail', 'broker-operation-detail'):
assert ids[ident]['attrs']['role'] == 'status', ident
assert [n['tag'] for n in ids['settings-values']['children'][:2]] == ['dt', 'dd']
assert ids['settings-values']['children'][0]['text'] == 'Service'
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 seven settings views')
print('PASS HTML layout: full-card triggers, compact quick CSS, profile slots, status roles and shared settings structure/styles')
def check_browser_layout(html, tmp, executable):
@@ -114,15 +150,45 @@ def check_browser_layout(html, tmp, executable):
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', 'display-settings']) for (const quick of [false, true]) {
if (quick && !['serial-settings-content', 'network-settings'].includes(view)) continue;
const widths = [320, 600, 900, 1200];
const views = ['serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings'];
for (const width of widths) {
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, errors = [];
for (const id of ['quick-serial', 'quick-network', 'quick-broker', 'quick-writer']) {
const card = d.getElementById(id), value = card.querySelector('.value');
card.disabled = false; value.textContent = 'long-unbroken-status-'.repeat(20);
}
const grid = d.querySelector('.status-grid'), gridRect = grid.getBoundingClientRect();
if (grid.scrollWidth > grid.clientWidth + 1 || gridRect.left < 0 || gridRect.right > width + 1) errors.push('status grid overflow');
for (const id of ['quick-serial', 'quick-network', 'quick-broker', 'quick-writer']) {
const card = d.getElementById(id), value = card.querySelector('.value');
const r = card.getBoundingClientRect(), v = value.getBoundingClientRect(), style = win.getComputedStyle(value);
if (r.width <= 0 || r.left < gridRect.left || r.right > gridRect.right + 1 || card.scrollWidth > card.clientWidth + 1) errors.push('card overflow:' + id);
if (v.left < r.left || v.right > r.right || value.scrollWidth <= value.clientWidth) errors.push('missing constrained long value:' + id);
if (style.textOverflow !== 'ellipsis' || style.whiteSpace !== 'nowrap' || style.overflowX !== 'hidden') errors.push('missing ellipsis:' + id);
// Probe card padding/corners as well as label/value: a nested text-only button must fail.
for (const [x, y] of [[r.left + 6, r.top + 6], [r.right - 6, r.bottom - 6], [r.left + r.width / 2, r.top + r.height / 2], [v.left + 1, v.top + 1]]) {
const hit = d.elementFromPoint(x, y);
if (hit?.closest('button') !== card) errors.push('card hit target:' + id + ' at ' + x + ',' + y + ' hit ' + (hit?.id || hit?.tagName || 'nothing'));
}
}
resolve({width, view:'status-cards', errors});
};
}));
frame.srcdoc = FIXTURE; document.body.append(frame);
}
for (const width of widths) for (const view of views) for (const quick of [false, true]) {
if (quick && !['serial-settings-content', 'network-settings', 'broker-settings'].includes(view)) continue;
const height = quick ? 360 : 900;
const frame = document.createElement('iframe'); frame.style.width = width + 'px'; frame.style.height = height + 'px';
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', 'display-settings']) d.getElementById(id).hidden = id !== view;
for (const id of views) d.getElementById(id).hidden = id !== view;
const section = d.getElementById(view);
section.querySelectorAll('[hidden]').forEach(n => n.hidden = false);
section.querySelectorAll('dl').forEach(dl => {
@@ -142,9 +208,27 @@ def check_browser_layout(html, tmp, executable):
if (rect.left < 0 || rect.top < 0 || rect.right > width || rect.bottom > height) errors.push('popover viewport overflow');
if (host.scrollWidth > host.clientWidth + 1) errors.push('popover horizontal overflow');
if (win.getComputedStyle(host).overflowY !== 'auto') errors.push('popover not scrollable');
for (const id of ['settings-navigation', 'network-password-mode-label', 'network-password-label', 'network-password-status']) {
for (const id of ['settings-navigation', 'network-password-mode-label', 'network-password-label', 'network-password-status', 'network-summary', 'broker-values']) {
if (d.getElementById(id).getBoundingClientRect().width) errors.push('quick hidden content:' + id);
}
const visible = node => !!node.getBoundingClientRect().width;
for (const node of section.querySelectorAll('p:not([role=status]),h2,h3')) if (visible(node)) errors.push('quick explanatory content:' + node.tagName);
if (!visible(d.getElementById('quick-title'))) errors.push('missing quick title');
const statuses = { 'serial-settings-content':['settings-detail','serial-operation-detail'], 'network-settings':['network-detail','network-operation-detail'], 'broker-settings':['broker-detail','broker-operation-detail'] };
for (const id of statuses[view]) if (!visible(d.getElementById(id))) errors.push('hidden result/status:' + id);
if (view === 'serial-settings-content') {
const rows = [...d.getElementById('settings-values').children];
if (!rows.every((node, i) => visible(node) === (i < 2))) errors.push('quick serial must show only service pair');
}
if (view === 'network-settings') {
const allowed = ['network-target','network-policy','network-enabled','network-apply','network-wifi-save','network-result','network-refresh'];
const controls = [...section.querySelectorAll('input,select,textarea,button')];
for (const node of controls) if (visible(node) !== allowed.includes(node.id)) errors.push('quick control visibility:' + node.id);
// CSS-only promotion restores the full editor; application draft/option state is checked in Node.
host.dataset.quick = 'false';
for (const node of controls) if (!visible(node)) errors.push('full control not restored:' + node.id);
host.dataset.quick = 'true';
}
}
for (const node of section.querySelectorAll('dl,input,select,textarea,.settings-edit,.serial-edit,.serial-actions')) {
const rect = node.getBoundingClientRect();
@@ -190,6 +274,7 @@ def check_browser_layout(html, tmp, executable):
assert result.returncode == 0, result.stderr
parsed = Document(result.stdout)
results = json.loads(parsed.ids['layout-results']['text'])
assert len(results) == 18
assert all(not case['errors'] for case in results), results
print('PASS Chromium layout: four full views plus Serial/Wi-Fi popovers at 320/600/1200px (quick height360); viewport/overflow/secret exclusion and full-page whitespace checks (fixtures, not live app)')
assert len(results) == 36
failures = [case for case in results if case['errors']]
assert not failures, failures
print('PASS Chromium layout: full-card hit targets/ellipsis, five full views and Serial/Wi-Fi/Broker popovers at 320/600/900/1200px (quick height360); compact controls/status, promotion restoration and whitespace checks (fixtures, not live app)')
+46 -3
View File
@@ -35,16 +35,59 @@ module.exports = async ({test, browser, adminBrowser, tick, json, session, failu
assert.equal(reads(b).length,1); assert.equal(posts(b).length,0); clean(b);
assert.ok(n(b,'password-mode').disabled);
target(b,'0'); assert.equal(posts(b).length,0); clean(b);
input(b,'ssid','new network');
n(b,'enabled').checked = false; n(b,'enabled').change();
b.queues[operation].push(ack('profile-patch')); b.click('network-apply'); await tick();
assert.equal(posts(b).length,1);
const body = JSON.parse(posts(b)[0].body); assert.equal(body.action,'profile-patch');
assert.ok(!Object.hasOwn(body,'password')); clean(b);
assert.deepEqual(JSON.parse(posts(b)[0].body), {action:'profile-patch', generation:7, profile:0, enabled:false});
clean(b);
b.click('quick-close'); await tick(); assert.equal(n(b,'ssid').value,''); clean(b);
b.queues[path].push(json(fixture())); b.click('quick-network'); await tick();
assert.equal(posts(b).length,1); assert.ok(n(b,'apply').disabled);
assert.match(n(b,'operation-detail').textContent,/pending|unknown|uncertain/);
});
await test('Quick Wi-Fi names configured profile slots, hides only empty slots and restores all slots on promotion', async () => {
const value = fixture();
value.wifi.profiles[2].ssid = '<b>office backup</b>'; // Configured but disabled remains selectable; label is safe text.
value.wifi.profiles[2].password_configured = true;
const b = await adminBrowser(); b.queues[path].push(json(value)); b.click('quick-network'); await tick();
const slots = quick => {
for (let i = 0; i < 4; i++) {
const option = n(b, 'profile-' + i), empty = !value.wifi.profiles[i].ssid;
assert.ok(option, 'missing profile option ' + i);
assert.equal(option.hidden, quick && empty, 'profile visibility ' + i);
assert.equal(option.disabled, quick && empty, 'profile selectability ' + i);
if (!empty) assert.ok(option.textContent.includes(value.wifi.profiles[i].ssid), 'SSID label ' + i);
assert.equal(option.children.length, 0, 'SSID must not become markup');
}
};
slots(true); assert.equal(n(b,'target').value, 'ap');
target(b,'2'); n(b,'enabled').checked = true; n(b,'enabled').change();
const selectedOption = n(b,'profile-2'), count = b.calls.length;
b.nodes['quick-full'].click({preventDefault(){}}); await tick();
slots(false); assert.equal(n(b,'profile-2'), selectedOption);
assert.equal(n(b,'target').value, '2'); assert.equal(n(b,'enabled').checked, true);
assert.equal(n(b,'ssid').value, value.wifi.profiles[2].ssid);
assert.equal(b.nodes['terminal-title'].textContent, 'Settings');
assert.equal(b.calls.length, count, 'promotion must preserve the draft without fetching');
target(b,'3'); assert.equal(n(b,'ssid').value, '', 'full page can configure an empty slot');
b.click('select-admin'); b.queues[path].push(json(value)); b.click('quick-network'); await tick(); slots(true);
value.wifi.profiles[2].ssid = ''; value.wifi.profiles[2].password_configured = false;
value.wifi.profiles[3].ssid = 'replacement'; value.wifi.profiles[3].password_configured = true;
b.queues[path].push(json(value)); b.click('network-refresh'); await tick(); slots(true);
assert.equal(posts(b).length, 0, 'opening, selecting, promotion and refresh never apply'); clean(b);
});
await test('Quick Wi-Fi AP policy applies only its typed patch; Save persists working values, not the draft', async () => {
for (const action of ['wifi-patch', 'wifi-save']) {
const b = await adminBrowser(); b.queues[path].push(json(fixture())); b.click('quick-network'); await tick();
assert.equal(n(b,'target').value, 'ap');
input(b,'policy','always','change');
assert.equal(posts(b).length, 0);
b.queues[operation].push(ack(action)); b.click(action === 'wifi-patch' ? 'network-apply' : 'network-wifi-save'); await tick();
assert.equal(posts(b).length, 1);
assert.deepEqual(JSON.parse(posts(b)[0].body), action === 'wifi-patch' ? {action, generation:7, ap_policy:'always'} : {action, generation:7});
clean(b);
}
});
await test('Quick Network rejects extra secret fields, fences401 and keeps full-page secret draft out of hover', async () => {
const b = await open(); secret(b); const count = b.calls.length;
b.nodes['quick-serial'].pointerenter({pointerType:'mouse'}); await tick();