Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e17df0ac5 |
@@ -1,10 +0,0 @@
|
||||
CompileFlags:
|
||||
Add:
|
||||
- -isystem
|
||||
- /home/mscholz/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include
|
||||
Remove:
|
||||
- -mlongcalls
|
||||
- -mdisable-hardware-atomics
|
||||
- -fstrict-volatile-bitfields
|
||||
- -fno-tree-switch-conversion
|
||||
- -fno-shrink-wrap
|
||||
@@ -1,17 +0,0 @@
|
||||
// Folder-specific settings
|
||||
//
|
||||
// For a full list of overridable settings, and general information on folder-specific settings,
|
||||
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||
{
|
||||
"lsp": {
|
||||
"clangd": {
|
||||
"binary": {
|
||||
"path": "/usr/bin/clangd",
|
||||
"arguments": [
|
||||
"--background-index",
|
||||
"--query-driver=/home/mscholz/.platformio/packages/**/bin/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,21 +14,13 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
|
||||
|
||||
## Development status
|
||||
|
||||
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D.3 browser login/logout is implemented, host-tested and build-verified; [M1 is validated by user sign-off](docs/phase8d3_implementation.md) after both-role login, mixed-client operation and post-soak evidence. Numeric memory reserve gates remain open. Browser admin-shell mode is implemented with M2 signed off; typed Serial/account settings through 8D.10 are accepted, and 8D.11 key settings are implemented. Admin-only Network settings (8D.12/8D.13, jointly authorized) now deliver STA/AP/profile and mDNS edits, explicit persistence, password replacement/disabled-STA clear and connection controls; final parent build/tests and target acceptance are pending. Settings navigation preserves terminal sessions and serial writer ownership; actual network disruption can disconnect network clients. Further contextual administration and full M3 acceptance remain pending. Configurable STA-only mDNS naming as `sak-<suffix>.local` is implemented with independent NVS persistence; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
|
||||
|
||||
### Browser Network settings (8D.12/8D.13)
|
||||
|
||||
Administrators can open **Settings → Network**; normal users cannot access its APIs. Refresh reads working configuration/runtime without exporting saved passwords or their lengths. SSIDs have UTF-8 text and reversible hex-byte modes (32-byte maximum). Password **Keep** preserves the current secret; **Replace** requires explicit new input; **Clear** is allowed only for a disabled STA profile, never AP. Inputs are transient and never prefilled from storage.
|
||||
|
||||
Apply changes RAM; **Save** explicitly persists device working state, not unsent drafts. Wi-Fi Load uses stored configuration only; there is no browser Wi-Fi reset/default-secret generation or secret export. mDNS Set/Load/Defaults request STA reannouncement; Save persists the name. The profile selector chooses what to edit, not what to connect to: **Next profile** follows enabled profiles in canonical priority order.
|
||||
|
||||
Confirm disruptive actions only with a recovery route ready. `accepted` does not mean online or verified DNS, and HTTPS/SSH/both browser terminals may disconnect before acknowledgement. Never automatically replay uncertain operations: reconnect via STA/AP, use Check Result/Refresh and inspect state. UART0 remains administrative recovery and native USB remains network-independent UART1 access. Changed hostnames require client DNS/trust/login checks. Browser-shell command restrictions are unchanged. See the [full bounded API, implementation evidence and pending target checklist](docs/phase8d12_13_implementation.md); no new commands or generated assets are introduced.
|
||||
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D remains in progress: explicit browser sessions, exact-session serial/admin WebSockets, the canonical browser admin shell, typed serial controls, guided user/password/role/authorized-key management, generation-safe Wi-Fi profile/AP/secret editing and saving, display-aging controls, broker-client popovers, and atomic writer transfer are implemented. Switching terminal modes preserves the browser serial client and writer lease. Service/session administration, network diagnostics, security/danger operations, and unusual hardware/debug commands remain in the Admin shell rather than guided forms. All current Phase 8D target-hardware validation remains pending. Configurable STA-only mDNS naming as `sak-<suffix>.local` is implemented with independent NVS persistence; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [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 the planned integrated web-administration acceptance matrix.
|
||||
- [Role-based user database and UART0 administration](docs/user_administration_tests.md): user migration and administration, HTTPS/SSH authentication, session revocation, and the pending integrated web-administration validation matrix.
|
||||
- [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands.
|
||||
|
||||
## Flash partition layout
|
||||
@@ -37,7 +29,7 @@ The N16R8 target has 16 MiB flash and 8 MiB octal PSRAM. PlatformIO uses the cus
|
||||
|
||||
| Partition | Offset | Size | Purpose |
|
||||
|---|---:|---:|---|
|
||||
| `nvs` | `0x009000` | 512 KiB | Serial, Wi-Fi, mDNS hostname, local-display, role-based user, HTTPS identity, and SSH host-key data |
|
||||
| `nvs` | `0x009000` | 512 KiB | Serial, Wi-Fi, mDNS hostname, local-display, role-based user, legacy recovery credential, HTTPS identity, and SSH host-key data |
|
||||
| `otadata` | `0x089000` | 8 KiB | Active OTA-slot selection metadata |
|
||||
| `phy_init` | `0x08B000` | 4 KiB | Optional PHY initialization data |
|
||||
| `nvs_key` | `0x08C000` | 4 KiB | Reserved for future encrypted-NVS keys |
|
||||
@@ -81,18 +73,12 @@ The firmware provides an interactive UART0 console at `serial-tool>`. Run `help`
|
||||
|
||||
The console supports session history, line editing, cursor movement, and hierarchical Tab completion. After an unattended boot, attach an ANSI-capable terminal and press Enter once to enable enhanced editing; this avoids blocking while no terminal is attached.
|
||||
|
||||
Serial, Wi-Fi, and mDNS hostname edits remain in RAM until explicitly saved with `serial save`, `wifi save`, or `mdns save`. Authenticated admin SSH sessions expose the shared operational administration registry, including interactive secrets, TLS/SSH identity management, network diagnostics, and deferred reboot/SSH lifecycle commands. Create the first administrator on UART0 with `user add <username> admin` (optionally `--generate`). Explicit recovery of an unavailable user database remains UART0-only and rebuilds it empty; it refuses a healthy database. An administrator also cannot generate a replacement password for its own account over SSH, preventing the one-time value from being lost when that mutation revokes the session. Legacy web credential commands and `user bootstrap` are removed.
|
||||
Serial, Wi-Fi, display-aging, and mDNS hostname edits remain in RAM until explicitly saved with `serial save`, `wifi save`, `display save`, or `mdns save`. Authenticated admin SSH sessions and the admin-only browser shell expose the shared operational administration registry, including interactive secrets, recovery-material management, network diagnostics, and deferred self-affecting lifecycle commands. Initial administrator bootstrap and explicit recovery of an unavailable user database remain UART0-only. A remotely connected administrator also cannot generate a replacement password for its own account, preventing the one-time value from being lost when that mutation revokes the session. `web credentials show` exposes only the legacy migration/recovery credential, not an active Phase 8B network login.
|
||||
|
||||
## 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 an explicit same-origin login/logout flow over TLS; opaque eight-hour browser sessions are held in a bounded RAM table, while raw session tokens are sent only in a host-only `Secure`, `HttpOnly`, `SameSite=Strict` cookie. State-changing web requests require a session-bound CSRF token and strict same-origin validation; 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 legacy recovery password, 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.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [GNU General Public License version 3 only](LICENSE) (`GPL-3.0-only`). Third-party components remain subject to their respective licenses. The integration baseline uses Espressif registry components `espressif/mdns` `1.12.0`, `wolfssl/wolfssl` `5.8.2~1`, and `wolfssl/wolfssh` `1.4.20`; review upstream security releases before production use.
|
||||
|
||||
### Legacy credential removal
|
||||
|
||||
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.
|
||||
|
||||
+30
-80
@@ -23,16 +23,6 @@ SSH role=admin ------> shared administration dispatcher <------ UART0
|
||||
(does not join the broker)
|
||||
```
|
||||
|
||||
## Typed Network settings (8D.12/8D.13)
|
||||
|
||||
`web_network_settings` provides admin-only secret-free GET snapshot and GET/POST operation routes; `web_ui.c` adds Network without changing broker/terminal ownership. Wi-Fi working/runtime projection is zero-wait under one mutex; mDNS is a separate consistent projection. SSIDs use reversible byte JSON, with explicit UTF-8-text/hex UI conversion; passwords are never returned/prefilled, only `password_configured`. Omitted passwords preserve current secrets; explicit replacement and disabled-STA clear are distinct, AP clear is always denied.
|
||||
|
||||
Only an ID enters the existing administration dispatcher. Its session/deadline revalidation precedes canonical generation-checked mutations; `wifi_manager` remains radio and reannouncement owner. Wi-Fi generation compare/merge/validation and queue-before-publication occur under its mutex; Save holds selected bytes stable, Load is stored-only without default-secret generation. mDNS independently checks generation for Set/Save/Load/Defaults and queues reannouncement; changed RAM with queue failure is explicitly reported, not rolled back. Edits require explicit Save. Next profile follows canonical enabled-priority order, not the profile editor's selected index.
|
||||
|
||||
One static login-bound pending/result slot and one firmware-lifetime one-second timer bound queued secret retention to 30 seconds plus scheduling latency. Dequeued locals wipe on return; admitted work is not hard-cancelled by logout/deadline. `accepted` means apply/owner admission, not online/DNS completion. Response delivery before disruption is not guaranteed; recovery is STA/AP inspection plus independent UART0 administration/native USB UART1 access, never automatic mutation replay. UI navigation preserves serial traffic/lease; actual network loss can disconnect network clients.
|
||||
|
||||
Bounds: 768-byte/four-receive request, 2,048-byte snapshot, 128-byte result; 27 handlers/six sockets, no task/stack-size/dispatcher-item/queue-depth/schema growth. Optional staged Network registration failures preserve unrelated routes; timer failure denies mutation without gating snapshot reads. Timer heap and target HTTPD/dispatcher stack/memory floors remain unmeasured. Full fields, registration rollback, states, UI and validation limits: `docs/phase8d12_13_implementation.md`. Browser-shell restrictions remain unchanged.
|
||||
|
||||
## Startup and initialization
|
||||
|
||||
`app_main()` in `src/main.c` is the composition root. The implemented order matters:
|
||||
@@ -42,14 +32,14 @@ Bounds: 768-byte/four-receive request, 2,048-byte snapshot, 128-byte result; 27
|
||||
3. Attempt optional OLED initialization and a bounded boot animation. Display failure is nonfatal; a working display can delay later recovery services by about five seconds.
|
||||
4. Initialize button diagnostics and load local-UI and serial configurations, falling back to RAM defaults on load failure.
|
||||
5. Initialize the serial service, session broker, and permanent USB transport task. UART1 is not started automatically here.
|
||||
6. Load/generate HTTPS material, then initialize the independent user database, committing an empty database when storage is missing. User-database failure makes network authentication fail closed.
|
||||
6. Load/generate HTTPS material, then initialize the user database using the legacy web credential for first migration when available. User-database failure makes network authentication fail closed.
|
||||
7. Initialize the HTTPS runtime, SSH host-key material, and permanent SSH owner task.
|
||||
8. Load Wi-Fi configuration and the independent mDNS hostname configuration, persist generated first-boot Wi-Fi defaults when appropriate, initialize the nonfatal mDNS configuration service and Wi-Fi manager, and start Wi-Fi when configured for boot. The Wi-Fi manager owns subsequent mDNS announcement transitions.
|
||||
9. Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. HTTPS additionally requires its own security/runtime readiness; SSH independently requires its own security/runtime readiness, not HTTPS identity readiness. This reflects `main.c` after accepted legacy-credential cleanup.
|
||||
9. Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. Both gates also require HTTPS security readiness, and SSH additionally requires its own security/runtime readiness. The HTTPS-security gate on SSH is an implemented dependency even though SSH has a separate host key.
|
||||
10. Start the local status/control task if button initialization succeeded.
|
||||
11. Construct ESP-IDF's UART REPL to initialize `esp_console`, but do not start the stock REPL task. Register command groups, install completion, and start the custom UART frontend that feeds the shared dispatcher.
|
||||
|
||||
Several core initializers use `ESP_ERROR_CHECK`; optional display and network/security paths generally log failure while retaining UART0 administrative recovery and network-independent UART1 access through USB. SSH starts before command registration, so role-`user` sessions can be admitted in that interval while role-`admin` sessions are rejected until the administration frontend is ready.
|
||||
Several core initializers use `ESP_ERROR_CHECK`; optional display and network/security paths generally log failure while retaining UART0 administrative recovery and network-independent UART1 access through USB. Network services start before command registration, so role-`user` serial access can be admitted in that interval while remote admin-console admission is rejected until the administration frontend is ready.
|
||||
|
||||
## Serial service and physical ownership
|
||||
|
||||
@@ -106,37 +96,21 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li
|
||||
|
||||
### HTTPS, WebSocket, and web serial
|
||||
|
||||
`web_server` runs HTTPS only on port 443 using the device-specific self-signed P-256 certificate from `web_security`. Current routes provide the UI, static assets, status, ticket issuance, and serial WebSocket upgrade.
|
||||
`web_server` runs HTTPS only on port 443 using the device-specific self-signed P-256 certificate from `web_security`. Current routes provide same-origin login/logout, the UI and static assets, status, typed admin operations, and separate serial/admin WebSocket ticket and upgrade paths.
|
||||
|
||||
HTTPS login uses `user_database` and opaque server-side cookie sessions; Basic authentication and its cache are removed in 8D.3. No legacy credential is imported, synchronized or consulted for authentication. Both roles retain the same shipped web status/serial UI. 8D.5 adds an admin-only backend without a normal UI entry.
|
||||
Browser passwords authenticate through `user_database`; the legacy web credential remains migration/recovery material and is not an active browser login after bootstrap. Successful login creates one of eight fixed eight-hour RAM sessions, with at most two retained per account. The raw 192-bit token is sent only in the host-only `__Host-sak-session` cookie (`Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`) while storage retains its SHA-256 digest, copied principal, generation-safe slot identity, and monotonic expiry. A boot-local key derives a deterministic session-bound CSRF token. Logout and every state-changing endpoint require strict `Origin == https://<Host>` and CSRF validation; a four-entry source-address limiter provides bounded, deliberately modest login throttling.
|
||||
|
||||
`web_cookie_auth` owns login/session/logout policy: four 120-second digest-only pre-login challenges, explicit same-origin bootstrap, five credential verifications per 60-second global window, and no live-record eviction. Host-only `__Host-` Secure/HttpOnly/SameSite=Strict cookies have absolute lifetimes. Login consumes a challenge, validates bounded JSON and issues a fresh session; logout invalidates only its originating session. Mutations require CSRF and strict canonical HTTPS Origin; serial/admin upgrades require matching cookie/Origin/ticket, with admin role additionally required by the admin endpoints.
|
||||
The serial WebSocket requires a one-time exact-browser-session-bound ticket with a maximum 30-second lifetime. Tickets are stored as digests, consumed before currentness validation, and never persisted. Admission and active input revalidate both the copied principal and exact browser-session reference. An admitted serial connection starts the service if necessary, creates a broker client, and opportunistically requests writer ownership. The serial transport has two fixed slots and four ticket slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the permanent transport task mediates broker work through bounded scheduling.
|
||||
|
||||
`web_session_store` holds four static records with token/origin digests, copied principal, separate CSRF state, one-hour absolute expiry and non-reused 64-bit session IDs. These are live cookie sessions in 8D.3, with no sliding renewal. A portMUX protects short state copies/mutations; database/RNG/SHA calls occur outside it. Resolution rechecks ID/expiry after database validation; issuance also checks an invalidation epoch. Stop wipes records without resetting IDs/epochs. Only admitted HTTPS starts initialize the store; failed starts and accepted stops disable it before cleanup. Authentication/store-init failure now gates HTTPS startup rather than falling back to Basic. Sensitive views must be wiped by callers; snapshots contain only counts and storage sizes. Focused host checks live in `tests/web_session_store/`.
|
||||
Administrators can separately mint a one-time ticket for one fixed browser admin-console WebSocket. That transport has its own permanent task, bounded input/output storage, copied admin principal, exact browser-session reference, and a single canonical-console slot; it never starts serial service or joins the broker. Terminal-mode switching changes only DOM visibility/focus and lazily opens the admin route, leaving the serial socket and writer lease alive. The combined Connect/Disconnect serial control explicitly manages only the serial route.
|
||||
|
||||
Serial tickets/slots bind to distinct originating web-session IDs; 8D.3 rejects zero instead of treating it as Basic. Trusted internal mint/upgrade callers supply the ID; bound checks also compare the session's copied principal, with no CSRF export. Mint/consume/admission/input and existing 250 ms owner checks validate session liveness/currentness. Session-specific transport revocation invalidates the store first, then clears matching tickets and flags matching reserved/active slots for existing HTTPD/broker cleanup. Account/global transport revocation now invalidates cookie records even if serial initialization failed; existing console mutation callers reach these hooks unchanged. A non-wrapping transport epoch cancels in-flight ticket publication across revocation and server detach/re-attach. Store/database checks remain authoritative if notification is missed. 8D.3 activates these checks for all browser routes, with five added authentication handlers (14 total), unchanged six HTTPS sockets and no new task.
|
||||
Typed admin endpoints cover full serial framing/lifecycle/persistence operations; Wi-Fi lifecycle/profile rotation; guided user CRUD, roles, entered/generated passwords, and authorized Ed25519/P-256 key add/remove; generation-safe station-profile/AP/secret editing and exact-generation save; display-aging apply/save/load/defaults/reset; a bounded secret-free broker-client list; and atomic expected-writer-to-target transfer. All authenticate the admin role server-side; mutators revalidate the exact browser session after their bounded body is parsed and immediately before the typed side effect. The common URL-form parser decodes in its body buffer, accepts at most 512 bytes and 10 unique fields, and rejects duplicate fields. User edits compare the database generation plus stable user ID; Wi-Fi edits compare the working-config generation, and reads disclose only `secret_set` flags. On stale conflicts the browser reloads user/Wi-Fi state without replaying the request and clears entered/generated secret fields on failure or Settings close.
|
||||
|
||||
A WebSocket connection requires a one-time, principal-bound ticket with a maximum 30-second lifetime. Only four tickets can be outstanding; expired/stale identities are reclaimed and live capacity is rejected with 503/Retry-After, not eviction. Ticket issuance and upgrade require `Origin` matching validated Host after host-case/default-port normalization; missing Origin fails even for non-browser clients. Tickets are stored as digests, consumed before currentness validation, and are never persisted. An admitted session starts the serial service if necessary, creates a broker client, and opportunistically requests writer ownership. The web transport has two fixed session slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the web transport task mediates broker work through bounded scheduling. The browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect; after a user-paused disconnect it changes to Connect, which resumes connection attempts.
|
||||
The Admin shell remains the web route for broader service/session controls, network diagnostics, security/danger operations, and unusual hardware/debug commands; those operations do not have guided forms.
|
||||
|
||||
`web_httpd_adapter` is the sole private ESP-IDF 5.5.0 boundary. Its compile-time version guard requires review on upgrades. It validates NUL-separated parsed headers because public getters return only the first field, and rejects duplicates/ambiguous framing. The serial URI is registered as ordinary HTTP GET so cookie/ticket/principal/broker admission precedes explicit 101 and frame-handler installation; automatic IDF WebSocket routing would send 101 too early. Cleanup wipes consumed scratch but preserves right-aligned unread pending data. CMake compiles HTTPD logs above ERROR out to prevent header/ticket logging. No SDK patch or component copy exists. See `docs/phase8d3_implementation.md` for source verification, tests and pending on-wire checks.
|
||||
Serial and admin WebSocket initialization are failure-isolated from base HTTPS. An unavailable transport leaves login and non-WebSocket routes available. A lifecycle mutex serializes start, stop, and TLS refresh; a generation records explicit desired-running intent so a post-material refresh cannot override a newer request. Certificate rotation and full material reset require that refresh after persistence. HTTPS stop first disables further transport-owned HTTPD calls and tracks calls already in progress. Failed HTTPD destruction retains the handle for a retry, while a timed-out or failed admin detach is finalized only after successful HTTPD destruction and remains pending for retry before a later start.
|
||||
|
||||
`web_diagnostics` observes public synchronous HTTPS create/close callbacks without replacing socket/transport cleanup, and wraps only the four serial/admin ticket/upgrade handlers. Six always-maintained post-TLS metadata records supply a console-safe occupancy snapshot without querying HTTPD off-owner; an opt-in 32-entry numeric ring adds timing/heap/HTTPD stack samples. Firmware-lifetime connection sequences and capture epochs survive clear/restart and fence fd reuse/toggle races. No tasks/probes/event subscriptions; no authentication/request data retained. UART0/admin SSH commands never wait for HTTPD; browser policy remains unchanged. This is successful-TLS occupancy, not preaccept or failed/in-progress handshake instrumentation; exact limits/overhead in `docs/phase8d11_implementation.md`.
|
||||
|
||||
Ordinary HTTPS idle retention is independently enforced by `web_httpd_idle`: one persistent one-second ESP timer, at most one generation-qualified HTTPD work reservation and six owner-only rows. The private adapter observes IDF's all-route successful `req_new`/`req_delete` completion marker, checks actual WS/async flags and pending/readable input, and shuts down only the current expired ordinary fd after 15 seconds of observed idle. TLS-create resets reused-fd observations before diagnostic publication. No TLS cleanup override, LRU eviction, in-progress request interruption or diagnostics dependency. Stop fences submissions before destroying HTTPD; only successful stop retires queued state. Slow owner work and accepted-but-lost nonblocking UDP work preclude a hard wall-clock guarantee; loss stays bounded until successful restart. Timer preparation failure gates HTTPS start. SDK audit, tests and exact limits: `docs/https_idle_cleanup.md`.
|
||||
|
||||
Web serial initialization is failure-isolated from the base HTTPS service: if the transport cannot initialize, `web_server_init()` can still succeed and serve authenticated non-WebSocket routes.
|
||||
|
||||
`web_ui.c` contains authored index/application strings and response policy; it validates `/api/session` before connect/restore, adds explicit Sign out, and cancels stale work on 401/logout/page exit. `web_login_ui` is a standalone public page without protected-asset dependencies. Both authentication documents and app script are no-store. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets.
|
||||
|
||||
### Browser admin backend
|
||||
|
||||
8D.8 adds an in-document admin-only Settings/Serial view and optional `GET /api/settings/serial`. It requires current cookie/principal/admin authorization, rejects bodies/queries and inherits ordinary-GET Origin/no-store policy. Eight working config/running fields fit a 256-byte response; `serial_service_get_snapshot()` takes the existing state mutex with zero wait, releasing it before encoding/send and returning unavailable on contention. No mutation, NVS, broker or socket-lifecycle operation occurs. Both hidden terminals continue draining; Settings input is disabled, refresh is explicit/single-flight with session identity checks that cannot supersede serial admission, and cancellation clears/fences the settings view. URI budget is now 17, sockets remain six/no LRU. The private adapter's startup-only exact-GET registration stages descriptor/name allocation before table publication, unlike installed IDF's public failure path; HTTPD retains normal free ownership. Only Settings uses that helper; existing registration callers remain unchanged. See `docs/phase8d8_implementation.md` for bounds, tests and target-pending evidence.
|
||||
|
||||
The 8D.6 document binds retained terminal state to its first validated username/role/session-stable CSRF tuple. Every later session adoption must match, otherwise both terminal hosts are hidden, both sockets/work are closed/fenced and a clean `/` document is required. Pagehide hides scrollback until same-session revalidation; no clearing is needed for unchanged-session restore or mode changes. Terminal-fit readiness uses successful-bounds caching and at most three generation-fenced animation-frame retries per external request, never unbounded polling.
|
||||
|
||||
8D.6 `web_ui.c` now supplies the admin-only Serial/Admin selector using this backend. Selection leaves serial and any open admin socket connected and draining; only focus, displayed terminal and keyboard destination change. Broker identity/lease and writer controls remain serial-owned in both views. Admin opens/reopens explicitly and closes independently. Two page-lifetime terminals have separate 5,000-line scrollbacks and 64 KiB callback-accounted pending output each; browser overflow is dropped with visible counts. Admin input is bounded to 4 KiB admission and 512-byte frames. Logout/expiry/page exit closes both with generation fencing and socket-listener cleanup; bfcache revalidates serial/session but never automatically reopens admin. No server policy/capacity changes or 8D.7 lifecycle parity. See `docs/phase8d6_implementation.md`; the following paragraph describes the original backend boundary, before its UI entry was added.
|
||||
|
||||
8D.5 additionally supplies `web_admin_transport` and `web_admin_tickets`: one optional admin socket, two 30-second digest-only tickets bound to current originating session/principal, the same two shared console slots, no serial broker client. Ticket POST requires cookie/Origin/CSRF/admin; ordinary GET upgrade requires cookie/Origin/admin/ticket and console admission before 101. Six total HTTPS sockets remain, LRU purge is disabled, and two routes bring the handler budget to 16. Optional admin registration/PSRAM allocation failures do not take down M1. A 20 ms ESP timer queues at most one HTTPD poll, with no new task; only HTTPD accesses the 1,552-byte PSRAM-only RX/TX payload or socket IO. Notifiers close the generation-qualified console and flag the socket. HTTPD shuts down the verified current fd directly and owns subsequent read cleanup, avoiding IDF's queued reusable `sock_db *` close race. Detach fences submissions; failed stop retains ownership, and queued state is retired only after successful HTTPD stop. Console dispatcher/prompt and owner input/output/idle checks enforce session and principal currentness. WEB supports deferred self-close only; parsed canonical policy denies unsupported lifecycle/network/account mutations before handler side effects. No normal UI entry, typed settings or lifecycle parity is included. See `docs/phase8d5_implementation.md` for validation limits and exact restrictions.
|
||||
`web_ui.c` contains authored index/application strings and response policy. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets.
|
||||
|
||||
### SSH
|
||||
|
||||
@@ -159,59 +133,41 @@ Network code holds copied, secret-free principals rather than pointers into data
|
||||
|
||||
Revocation has two layers:
|
||||
|
||||
1. after a database mutation commits, the command layer makes best-effort targeted WebSocket/SSH revocation calls; notification failure does not roll back the mutation;
|
||||
1. `user_admin_service` serializes typed web and console mutations with `admin_command_gate`; after a database mutation commits, it makes best-effort targeted WebSocket/SSH revocation calls, and notification failure does not roll back the mutation;
|
||||
2. transports periodically and at sensitive boundaries recheck principal currentness, providing authoritative fail-safe closure if notification fails.
|
||||
|
||||
The final administrator cannot be deleted or demoted. UART0 establishes the first administrator through normal `user add <username> admin` and owns explicit unavailable-database recovery to empty. Recovery refuses a healthy database. No bootstrap API or command remains. Authenticated admin SSH can run the operational registry but is denied recovery; other secret-bearing commands are remotely available unless their handlers deny them.
|
||||
The web user editor supplies an expected database generation for every mutation and the stable target user ID for existing-account operations. A mismatch rejects stale state, including delete/recreate of the same username. The final administrator remains protected, and remote self-generated password replacement is rejected so its one-time result cannot be lost during revocation.
|
||||
|
||||
NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials and TLS/SSH private keys remain recoverable under physical flash extraction.
|
||||
The final administrator cannot be deleted or demoted. UART0 is trusted for initial administrator bootstrap and explicit unavailable-database recovery. Authenticated admin SSH and the browser Admin shell can run the operational registry but are denied those two recovery operations; other secret-bearing commands are remotely available unless their handlers deny them.
|
||||
|
||||
## Typed Accounts settings (8D.10)
|
||||
|
||||
**8D.11 extension:** Authorized-key operations share the Accounts slot/dispatcher and canonical database key transactions with mutation-lock account ID/generation checks. A zero-wait per-account projection returns only key slot/type/SHA256 fingerprint. Independently optional admin/Origin/CSRF JSON POST `/api/settings/accounts/keys` reads this projection; existing operation POST admits bounded public-key text or key-delete/key-clear. Three stable slots may be sparse. Successful mutations target-revoke, including self; uncertain acknowledgement never triggers automatic replay. 24 handlers, unchanged socket/task/stack-size/queue budgets. Runtime stack margins remain pending. See `docs/phase8d11_implementation.md`; older no-8D.11 statements below are historical.
|
||||
|
||||
Current slice 2 extends the same dispatcher slot to create/password and self role/delete/password. Mutation bodies are 768 bytes/four receives; results stay secret-free, 96 bytes, replaceable and session-bound. Conditional password mutation checks identity under the canonical database mutex. A one-second firmware-lifetime ESP timer cancels/wipes non-executing queued credentials at their 30-second deadline plus timer/scheduling latency; dequeue wipes shared inputs after copying, and dispatcher-local credentials persist until admitted work returns. This is not a hard execution/erasure deadline. Separate bodyless admin/Origin/CSRF POST `/api/settings/accounts/generate-password` returns a 24-character value before any commit, without retained retrieval. UI generation has a 60-second best-effort lifetime and context-bound saved acknowledgement before separate submission; JavaScript cannot securely wipe strings. Self revocation may prevent final response/result access; disconnect/401 proves neither success nor cancellation. Browser-shell restrictions and UART0 first-admin provisioning/recovery remain unchanged. The generated endpoint is independently optionally registered, with failure isolation/restart coverage and 23 handlers. Implementation is complete, host-tested/build-verified; target validation/signoff remains pending. Parent build: 25.61 s, 95,908 B RAM / 1,694,237 B flash; timer runtime costs and stack/heap margins remain unmeasured. No task/stack/queue depth/socket expansion or 8D.11 work. Current contracts and attributed host evidence: `docs/phase8d10_implementation.md`.
|
||||
|
||||
**Historical slice 1 architecture (superseded scope/counts, retained evidence):**
|
||||
|
||||
`web_account_settings` supplies an optional admin-only compact account list and one session-bound role/delete operation/result slot, separate from Serial's slot but executed on the same dispatcher queue. HTTPD authorizes/parses/queues; the dispatcher revalidates the initiating login/admin and 30-second dequeue deadline, then calls conditional database mutations and best-effort target web/SSH revocation after success. `user_database_get_accounts()` copies at most eight username/role/ID/auth-generation records under the existing mutex with zero wait and no key/password fields. `user_database_delete_current()` and `user_database_set_role_current()` compare target identity under the mutation lock and share canonical CLI commit/invariant logic; stale selection never intentionally mutates a replacement account. Results are replaceable, not durable/idempotent, and already-admitted work can complete after logout. Self-target, create/password/generated-secret workflows remain unavailable in this slice; first-admin provisioning/recovery remain UART0-only. Accounts UI confirms mutations, retains visible stale lists/outcomes during bounded auto-check/refresh and fences navigation/session changes without changing broker ownership. Three optional routes bring HTTPD handlers to 22; six sockets/no LRU and existing tasks/stacks/queue depth remain. See `docs/phase8d10_implementation.md` for limits and pending target checks.
|
||||
|
||||
## Typed Serial settings (8D.9)
|
||||
|
||||
`web_serial_settings` admits strict bounded admin cookie/Origin/CSRF JSON into one static session-bound operation/result slot, queuing only a non-reused ID on the existing administration dispatcher. HTTPD never runs serial/NVS mutations. The dispatcher checks session/principal currentness and a 30-second dequeue deadline before canonical Apply/Start/Stop/Save/Load/Defaults/Reset APIs; already admitted work may complete after logout. A blocked dispatcher retains the slot, not a timed job cancellation. Results are login-isolated and replaceable after completion; no durable history/idempotent retry guarantee. After acknowledgement the UI checks at one-second intervals, bounded to 10 GET attempts and a 15-second overall deadline including session checks, then automatically refreshes working values for known terminal outcomes. Errors/exhaustion use manual recovery; no automatic mutation retries or navigation resumption. Settings remain visible with stale/pending labels; only Reset confirms saved-NVS overwrite. Selecting the current view is a no-op. Settings UI retains uncertain-result warnings, explicit RAM/NVS/discard explanations and both terminal sockets/lease. Two optional exact GET/POST registrations bring the URI budget to 19, with six sockets and unchanged tasks/stacks/queue depth. `/api/status` also uses the zero-wait serial snapshot and emits `running:null` when unavailable. See `docs/phase8d9_implementation.md` for resource and target-pending evidence.
|
||||
NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials, legacy recovery credentials, and TLS/SSH private keys remain recoverable under physical flash extraction.
|
||||
|
||||
## Console architecture
|
||||
|
||||
UART0 and admin SSH share canonical command implementations:
|
||||
UART0, admin SSH, and the browser admin shell share canonical command implementations:
|
||||
|
||||
```text
|
||||
UART0 linenoise frontend --\
|
||||
> fixed request queue -> one dispatcher -> esp_console_run()
|
||||
admin SSH line editor ----/ |
|
||||
+-> registered *_console handlers
|
||||
UART0 linenoise frontend -----\
|
||||
admin SSH line editor ---------> fixed request queue -> one dispatcher -> esp_console_run()
|
||||
browser admin line editor -----/ |
|
||||
+-> registered *_console handlers
|
||||
```
|
||||
|
||||
`admin_ssh_console` creates the dispatcher before network services but marks command dispatch ready only after ESP-IDF console registration and successful UART frontend task creation. An admin SSH connection during that boot window is rejected rather than racing an incomplete registry.
|
||||
`admin_ssh_console` creates the transport-neutral dispatcher before network services but marks command dispatch ready only after ESP-IDF console registration and successful UART frontend task creation. Remote admin admission during that boot window is rejected rather than racing an incomplete registry.
|
||||
|
||||
The dispatcher is the sole caller of `esp_console_run()`, serializing UART0 and all admin SSH commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes.
|
||||
The dispatcher is the sole caller of `esp_console_run()`, serializing UART0, admin SSH, and browser-admin commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes.
|
||||
|
||||
The 8D.4/8D.5 boundary retains `admin_ssh_console_open_owned()` and adds available-slot admission for runtime SSH/browser owners: copied transport-qualified slot/session/generation identity plus a firmware-lifetime immutable owner adapter. The existing two console slots are shared, not multiplied per frontend; active/executing slots cannot be replaced. Owners serialize per-session input, consume output and enforce transport liveness; completion scratch is claimed nonblockingly across owners. The existing control task calls drain/lifecycle adapters outside console locks. SSH uses generation-checked published snapshots, principal copies and its assigned console index, never wolfSSH from the control task. `SELF_CLOSE` is owner-relative; legacy SSH actions remain SSH-specific and unsupported owner actions are rejected. Dispatcher-side owner `is_current` checks run outside console locks, with full identity recheck after validation. Commands revalidate immediately before the runner; prompts revalidate before publication and after waits (250 ms polling plus check/scheduling latency), rejecting revoked submitted input and stale wakes. SSH preserves close intent through external-close consumption. Consumed output is wiped. These checks do not cancel arbitrary executing handlers or replace owner-side input/output and lifecycle validation.
|
||||
For remote frontends, standard output/error is redirected to the invoking session's bounded output ring. `console_input` routes visible or hidden prompts to UART0 or the active remote session. SSH and browser admin use the same bounded editor, four-entry history, completion formatter, overlong-line discard state, prompt handling, and generation-safe console tokens. Exact frontend callbacks revalidate the transport/session binding immediately before dispatch. Only the SSH owner task moves SSH ring output through wolfSSH; only the web-admin task queues browser output to HTTPD.
|
||||
|
||||
For SSH, standard output/error is redirected to the invoking session's bounded output ring. `console_input` routes visible or hidden prompts to UART0 or the active SSH session. `exit` and Ctrl+D on an empty admin SSH line use bounded deferred self-disconnect after their acknowledgement drains; role-`user` SSH remains a binary-transparent serial stream. Session tokens include slot and generation so late queued work cannot attach to a reused SSH slot. Only the SSH owner task moves ring output through wolfSSH.
|
||||
|
||||
Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
|
||||
|
||||
The first 8D.7 slice enables browser-admin reboot and HTTPS stop through the same control task. `web stop` is deferred only for browser origin; UART0/admin SSH keep their synchronous HTTPS-stop path. WEB performs authoritative cookie/principal/token validation after drain and delay, then calls lifecycle APIs outside console locks, never socket IO. Console snapshots expose pending deferral; HTTPD discards buffered/new input observed during it and latches each frame's discard decision across payload reception/cancellation. HTTPS stop intentionally closes both browser routes.
|
||||
|
||||
The second 8D.7 slice additionally permits exact parsed browser `web certificate rotate --force`. The request queue has a typed command-line/deferred-action union with unchanged capacity. An immutable owner `dispatcher_actions` mask sends certificate work, after the bounded drain and 200 ms delay, nonblockingly to the existing 12 KiB dispatcher rather than the 4 KiB control stack; zero mask retains SSH control-task behavior. Pending input remains gated through queueing/execution. Dispatcher token/principal/session/owner revalidation and an executing-slot reservation prevent stale execution or reuse during self-detach; WEB validates currentness again before lifecycle APIs. Transactional certificate generation/persistence commits before stop → start; generation/commit error skips lifecycle calls, stop error skips start and retains HTTPD ownership, and later lifecycle failure does not roll back committed material. HTTPD alone owns socket IO. Successful restart invalidates browser sessions and both routes; certificate trust and login must be renewed, while USB/UART0/SSH remain available. Account/legacy-credential/network/restricted SSH mutations remain blocked. No new tasks, depths, routes, assets or stack sizes. Drain/acknowledgement bounds are not execution deadlines or delivery guarantees; owner-mask/local-scratch target layout and control/dispatcher stack margins remain unmeasured (host sizeof is not target proof).
|
||||
|
||||
The third 8D.7 slice permits browser other-account interactive add/password and forced delete/role mutations, with shared parsed policy at dispatcher admission and canonical-handler defense. Self-target, generated-secret, key, bootstrap and recovery workflows remain blocked. Account/owner/session/token currentness is checked after password prompts and before database API operation admission. This is operation-admission currentness, not an atomic liveness/NVS-commit guarantee: an admitted derivation/mutation may finish and target-revoke after disconnect or expiry; subsequent stale operations reject. Reconnecting administrators must inspect uncertain account outcomes rather than assume cancellation. Existing transaction cleanup, account invariants and best-effort targeted notifications remain unchanged.
|
||||
Remote `exit`, reboot, SSH stop/session disconnect/host-key changes, and web-origin HTTPS stop/restart use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
|
||||
|
||||
## Wi-Fi and persistence
|
||||
|
||||
`wifi_config` owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy `off`, `fallback`, or `always`. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used.
|
||||
|
||||
`wifi_manager` is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP deadlines, profile failover, AP policy, retries/backoff, next-profile requests, and the mDNS announcement lifecycle. `mdns_service` initializes the responder at most once after a validated STA `GOT_IP`; the managed component's own event handlers withdraw and restore the STA announcement across transient connectivity changes, while the project tracks whether announcement is currently expected. Initialization failure is latched rather than retried because partial upstream low-memory initialization is not safely recoverable; mDNS failure is nonfatal. It also reconciles against authoritative driver/netif state so dropped events do not permanently wedge policy. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save. Edits to disabled station profiles are staged in RAM without restarting the radio; enabling/disabling a profile or changing enabled station/AP policy restarts it asynchronously. Start/stop—including local controls—intentionally update the RAM `enabled_at_boot` field. Working-configuration copies contain PSKs and must be securely wiped; routine status and the local UI use secret-free snapshots.
|
||||
`wifi_manager` is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP deadlines, profile failover, AP policy, retries/backoff, next-profile requests, and the mDNS announcement lifecycle. `mdns_service` initializes the responder at most once after a validated STA `GOT_IP`; the managed component's own event handlers withdraw and restore the STA announcement across transient connectivity changes, while the project tracks whether announcement is currently expected. Initialization failure is latched rather than retried because partial upstream low-memory initialization is not safely recoverable; mDNS failure is nonfatal. It also reconciles against authoritative driver/netif state so dropped events do not permanently wedge policy. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save. Edits to disabled station profiles are staged in RAM without restarting the radio; enabling/disabling a profile or changing enabled station/AP policy restarts it asynchronously. Start/stop—including local controls—intentionally update the RAM `enabled_at_boot` field.
|
||||
|
||||
A dedicated config-writer mutex serializes complete working-config writers. The typed browser editor copies the credential-bearing config with its exact nonzero generation, modifies and validates the copy, and commits only through compare-and-swap; Save holds the same writer serialization through NVS and persists only the expected generation. Generation mismatch or exhaustion fails closed. Credential-bearing copies are securely wiped, browser reads return only per-profile/AP `secret_set` booleans, and routine status/local UI use secret-free snapshots.
|
||||
|
||||
Persistent namespaces/blobs include:
|
||||
|
||||
@@ -231,7 +187,7 @@ Configuration modules generally choose RAM defaults without erasing incompatible
|
||||
|
||||
When button GPIO initialization succeeds, `local_status_ui` starts a firmware-lifetime low-priority task that polls/debounces buttons, renders copied public snapshots, implements aging/wake behavior, and invokes a constrained set of public service APIs for local controls. It collects snapshots before opening a display frame, so service/broker locks are not held across I2C. It never parses CLI output, becomes a broker client, edits credentials, or assigns a writer; emergency action can only release the expected current writer.
|
||||
|
||||
The task can run with an absent OLED, and a fresh button press can request one bounded panel reprobe after successful I2C bus setup. Failed I2C bus creation is not recoverable through that path. The `display` configuration commands depend on the UI task. Long confirmation holds protect disruptive local actions, and stuck buttons are quarantined.
|
||||
The task can run with an absent OLED, and a fresh button press can request one bounded panel reprobe after successful I2C bus setup. Failed I2C bus creation is not recoverable through that path. The `display` configuration commands and typed `/api/admin/display` aging operations depend on the UI task; web Apply/Save/Load/Defaults/Reset reuse the local-UI validation and persistence contracts and share `admin_command_gate` with console display writers so each complete RAM/NVS operation is serialized. Long confirmation holds protect disruptive local actions, and stuck buttons are quarantined.
|
||||
|
||||
Hardware diagnostics are synchronous console commands. RS-232 tests own the physical port exclusively and restore safe GPIO state; OLED tests reuse the display service rather than taking independent I2C ownership.
|
||||
|
||||
@@ -239,15 +195,9 @@ Hardware diagnostics are synchronous console commands. RS-232 tests own the phys
|
||||
|
||||
- Broker, USB, web-transport, Wi-Fi, and SSH owner tasks are firmware-lifetime tasks; the local-UI task is also firmware-lifetime when button initialization allowed it to start. Stopping a service generally stops its runtime/listener, not the owner task.
|
||||
- Bounded queues, stream buffers, work bursts, and drop counters are part of slow-client and watchdog isolation.
|
||||
- Transport slot generations and account authentication generations solve different stale-reference problems; preserve both.
|
||||
- Transport slot generations, account authentication generations/stable IDs, Wi-Fi working-config generations, and HTTPS lifecycle generations solve different stale-reference problems; preserve each domain's checks.
|
||||
- Library/hardware ownership is centralized: serial task owns UART1 while running, display service owns I2C/framebuffer, the SSH owner task owns post-initialization wolfSSH runtime calls, and the console dispatcher owns `esp_console_run()`.
|
||||
- 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.
|
||||
- Password authentication performs PBKDF2 outside the user-database mutex and revalidates afterward. Typed user mutations are serialized by `user_admin_service` plus `admin_command_gate`; typed display Apply/Save/Load/Defaults/Reset also use that gate with console display writers. Some password mutation paths derive verifiers while holding the mutation lock, so 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.
|
||||
- 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: `docs/legacy_credential_removal.md`.
|
||||
|
||||
+30
-62
@@ -58,51 +58,18 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
|
||||
## Web and WebSocket serial
|
||||
|
||||
- **Current 8D.12/8D.13:** `web_network_settings.{c,h}` owns optional admin-only GET `/api/settings/network` and GET/POST `/api/settings/network-operation`; `web_ui.c` supplies Network, UTF-8/hex SSID editing and explicit transient-secret/connection controls. `wifi_manager` owns generation-checked secret-free snapshots/patch/save/stored-only load and radio transitions; `mdns_service` owns independent conditional hostname persistence, with manager reannouncement. Existing dispatcher receives IDs only. 768-byte request/2,048-byte snapshot/128-byte result, one slot/one-second timer with 30-second queued expiry plus scheduling latency; no hard cancellation. 27 handlers/six sockets, no task/stack/queue/schema growth. Backend/cookie PASS, UI agent 97+renderer/CSP and review PASS, lifecycle agent21 PASS; final parent build/tests and target/resource validation pending. Full contract/exclusions/checklist: `docs/phase8d12_13_implementation.md`. Both phases user-authorized together; no 8D.14/M3/sign-off claim. Older next-phase statements below are historical.
|
||||
|
||||
- **8D.11:** `web_account_settings.{c,h}` extends Accounts with fingerprint-only POST `/api/settings/accounts/keys` and key-add/key-delete/key-clear on the existing operation endpoint/dispatcher. `user_database.{c,h}` owns zero-wait target-checked snapshots and canonical conditional key mutations. `web_ui.c` handles confirmations, sparse stable indices and self-revocation uncertainty. 24 handlers, six sockets; no new task/stack/queue depth. Host-tested/build-verified, target pending. Contracts/tests/checklist: `docs/phase8d11_implementation.md`.
|
||||
|
||||
**Responsibility:** serve authenticated HTTPS UI/API, issue WebSocket tickets, and adapt browser serial sessions to broker clients.
|
||||
|
||||
- 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 limits and target checklist: `docs/https_idle_cleanup.md`.
|
||||
- Opt-in admission diagnostics: `src/web_diagnostics.{c,h}`, `tests/web_diagnostics/run.py`. Public synchronous HTTPS create/close callbacks publish six post-TLS connection records; four ticket/upgrade wrappers feed a 32-entry numeric ring. UART0/admin SSH `web diagnostics enable|disable|show|clear`; no queue/task/cleanup override or capacity change. Full bounds, SDK semantics and preaccept/TLS blind spots: `docs/phase8d11_implementation.md`.
|
||||
- Legacy removal **user-signed-off 2026-09-08** (unchanged certificate fingerprint, preexisting users usable, full-mix evidence): `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`.
|
||||
- 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}`.
|
||||
- Files: `src/web_server.{h,c}`, `src/web_session.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_admin_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}`
|
||||
- Security files: `src/web_security.{h,c}`
|
||||
- Asset files: authored/generated boundary in `src/web_assets_data.{h,c}`, `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py`
|
||||
- Interfaces: web init/start/stop/snapshots; HTTP handlers; ticket mint/consume; attach/detach; targeted session revocation
|
||||
- Interfaces: generation-tagged web init/start/stop/TLS refresh and snapshots; HTTP handlers including `/api/admin/users`, `/api/admin/wifi-config`, and `/api/admin/display`; ticket mint/consume; attach/detach/finalize; targeted session revocation
|
||||
- Called by: startup, ESP-IDF HTTPS server, user administration revocation, console/local UI
|
||||
- Dependencies: user database, secure random, broker, successful Wi-Fi manager initialization at boot, mbedTLS/HTTPS server; actual network reachability is an operational prerequisite, not an initializer invariant
|
||||
- Flow: `browser -> HTTPS login/cookie session -> CSRF-protected ticket -> cookie/Origin/ticket admission -> WebSocket -> web transport -> broker`
|
||||
- Ownership: HTTPD owns socket send/close work; transport task owns broker mediation; two fixed WebSocket slots and four outstanding tickets.
|
||||
- Security constraints: Basic/cache removed; four absolute one-hour cookie sessions revalidate principal currentness. Four pre-login challenges (120 s), five credential attempts/60 s globally, no live session/challenge/ticket eviction. Origin/CSRF required for mutations; Origin/cookie/ticket before upgrade. Disconnect pauses reconnect but retains login; Sign out invalidates its session. Authored loader changes must update their hard-coded CSP hashes atomically.
|
||||
- Session-store boundary: admitted HTTPS start initializes records; auth-init failure gates HTTPS. Failed start/accepted stop disables and wipes state. Tickets/slots require nonzero non-reused session IDs; session/account/global revocation invalidates store records before socket cleanup. RNG/SHA/database calls run outside short portMUX sections; ID/expiry/epoch checks reject stale work. Run `python3 tests/web_session_store/run.py` and its `--serial` integration mode.
|
||||
- 8D.3 HTTP policy: `web_cookie_auth` owns public login/challenge/login POST/session/logout routes and protected-route checks; `web_auth_parse` handles bounded values/JSON. `web_httpd_adapter` alone reads private IDF 5.5.0 header scratch, rejects duplicate fields, defers 101 until transport admission and wipes consumed scratch while preserving right-aligned pending bytes. No SDK patch. `src/CMakeLists.txt` supplies private includes and compiles HTTPD warning/debug logs out. Test with `python3 tests/web_cookie_auth/run.py` and `python3 tests/web_auth_parse/run.py`.
|
||||
- 8D.3 UI: `src/web_login_ui.{c,h}` serves standalone `/login`; `web_ui.c` validates session before serial connect/restore and handles logout/401 safely. Both scripts hash-bound, auth documents/app no-store. Tests: `python3 tests/web_login_ui/run.py` and `python3 tests/web_ui_session/run.py`. Live cutover host-tested/build-verified, M1 validated by user sign-off (numeric reserves open): `docs/phase8d3_implementation.md`.
|
||||
- Flow: `browser -> HTTPS login session -> exact-session ticket -> serial WebSocket -> broker`; admin sessions may separately use `admin ticket -> admin WebSocket -> canonical dispatcher` without joining the broker.
|
||||
- Ownership: HTTPD owns socket send/close work; separate permanent web tasks own serial broker mediation and browser-admin console I/O; there are two serial slots/four serial tickets and one admin slot/two admin tickets. HTTPS lifecycle transitions are serialized separately from state snapshots, carry a lifecycle generation, and retain failed-stop/finalizer ownership for retry. The admin transport disables new HTTPD calls during detach and tracks calls already in progress.
|
||||
- Security constraints: eight opaque browser sessions retain digest-only tokens and copied current principals, with at most two sessions per account. Mutations require strict same-origin and session-bound CSRF checks. Typed URL-form bodies decode in their own storage and are limited to 512 bytes/10 unique fields. User and Wi-Fi editors use optimistic generations; user edits also bind stable user IDs, while Wi-Fi reads expose only `secret_set` flags. Terminal-mode switching never closes the serial socket or releases its writer lease; the combined control explicitly connects/disconnects only serial. Changes to the authored inline loader must update its hard-coded CSP hash in the same change.
|
||||
- Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
|
||||
- 8D.6 UI: `web_ui.c` adds admin-only Serial/Admin selection and explicit admin open/close through existing endpoints. Serial socket/client/lease survives mode switches; hidden output drains into independent 5,000-line/64 KiB-pending terminals with visible browser-drop counts. Selected keyboard only; logout/expiry/pagehide closes both with handler cleanup. Session identity changes require a clean document before adopting the view; same-session restore retains hidden-until-validated buffers. Fit readiness retries are bounded to three and cache only success. Focused `tests/web_ui_session/run.py` has 17 groups plus toolbar-order/CSP checks. 8D.6 is user-validated; telemetry, evidence limits and 8D.7 handoff are in `docs/phase8d6_implementation.md`. Numeric reserves remain open; no 8D.7 restriction change.
|
||||
- **8D.8–8D.10 target sign-off (2026-09-08):** User reports thorough implemented Serial/account settings tests, supplies settled boot/full-mix telemetry and signs implemented work off. Covers both 8D.10 slices and 8D.9 UX. Supersedes target-pending/exclusion status in historical summaries below; exact scope/evidence/counters/limits: `docs/phase8d10_implementation.md`. No unreported checklist passes, reserve approval or M3 completion. Next 8D.11 only on separate request; no source change from sign-off.
|
||||
- 8D.8: `web_ui.c` adds admin-only Settings/Serial without socket/lease changes. `web_server.c` exposes optional admin-only bodyless `GET /api/settings/serial`, eight working serial values, 256-byte response, no writes/NVS. `serial_service_get_snapshot()` is a zero-wait consistent config/running copy. `web_httpd_register_optional_get()` stages both new-route allocations before table publication (installed IDF public registration leaves a dangling descriptor on name-allocation failure); only Settings uses this startup/exact-GET adapter. 17 URI slots, six sockets/no LRU, no new task. Tests: cookie auth `--settings` (5 groups), UI (21 groups), lifecycle (12 groups). Implemented/build-verified, target/signoff pending; exact accounting and inherited registration-audit followup: `docs/phase8d8_implementation.md`. M2 remains signed off; no 8D.9.
|
||||
|
||||
- 8D.9: `web_serial_settings.{c,h}` owns strict 256-byte typed mutation admission and one session-bound pending/result slot. Existing `admin_ssh_console` dispatcher consumes only an ID, revalidates currentness/dequeue deadline and calls canonical serial APIs. `web_server.c` adds optional GET/POST `/api/settings/serial-operation` (19 handlers total); `web_cookie_auth_require_json()` retains Origin/CSRF/admin policy, private optional registration supports exact GET/POST. UI adds explicit framing/lifecycle/persistence with automatic completion checks (1 s, at most 10 GETs/15 s overall), refresh on known terminal results and manual uncertainty recovery without socket/lease changes. Settings stay visible/stale while pending; only Reset confirms NVS overwrite; selecting the current view is a no-op. `/api/status` uses a consistent zero-wait serial snapshot (`running:null` when unavailable). Tests: cookie `--serial-settings` (10 groups), `--settings` (6), UI (35 after UX refinement), console boundary and lifecycle (13). Build verified, target/signoff pending; bounds and failure contracts: `docs/phase8d9_implementation.md`. Supersedes 8D.8's no-8D.9 status above.
|
||||
|
||||
- 8D.10 first slice: `web_account_settings.{c,h}` owns compact admin account list and one session-bound other-account role/delete operation slot. `user_database_get_accounts()` is a zero-wait key/secret-free projection; `*_current()` role/delete wrappers compare target ID/auth generation under the canonical mutation lock. Existing dispatcher routes IDs; successful calls target-revoke web/SSH. Optional GET `/api/settings/accounts`, GET/POST `/api/settings/account-operation` raise handlers to 22, sockets/tasks/stacks/queue depth unchanged. UI Accounts subview preserves terminal/lease semantics, confirms mutations and auto-checks/refreshes with manual uncertainty recovery. Tests: cookie `--accounts` (5), canonical accounts, dispatcher, lifecycle (14), UI (41 + CSP). Target pending; create/password/generated-secret/self changes remain next slice, 8D.10 incomplete. Record: `docs/phase8d10_implementation.md`.
|
||||
|
||||
- **Current 8D.10 slice 2 (supersedes first-slice exclusions above):** `web_account_settings.{c,h}` adds create/password/self and separate bodyless POST `/api/settings/accounts/generate-password`; `user_database_set_password_current()` shares mutation-lock target checks and canonical commit logic, `user_database_generate_password_value()` generates without mutation. 768-byte/four-receive admission, 96-byte secret-free results; one-second periodic timer cancels/wipes queued non-executing credentials after 30 seconds plus scheduling latency, while dispatcher wipes executing locals on return. Generation has no retained retrieval; UI uses 60-second context-bound acknowledgement before separate submission. Self revocation can deny result retrieval; 401/disconnect is uncertain. Browser-shell restrictions unchanged. Missing generated-route registration found in review is fixed: independent optional endpoint, 23 handlers, failure isolation/restart coverage. Implementation complete, host-tested/build-verified; target/signoff pending. Parent PASS canonical accounts/boundary, parser 294, cookie accounts 9/shared and serial-settings 10, transport 25/tickets 12, store/serial and diff check; UI agent PASS 57/CSP, route agent lifecycle 15. Parent build 25.61 s, 95,908 B RAM / 1,694,237 B flash (+80/+9,880 vs slice 1; +200/+25,400 vs final 8D.9 UX). Timer runtime costs/stack margins remain unmeasured; no 8D.11. Exact evidence attribution: `docs/phase8d10_implementation.md`.
|
||||
|
||||
### Browser admin backend (8D.5)
|
||||
|
||||
- **8D.7 current status (2026-09-07): implemented scope validated; M2 explicitly signed off by the user ("Jupp, sign M2 off").** Supersedes M2-open/target-pending/continuation statements in the historical slices below; accepted M2 does not require revalidation. User verified certificate rotation and web start/stop via UART0/SSH admin/web admin, restarting after browser stop via another route; full mix without broker drops up to 230400 baud after external adapter correction is user-reported. Intermittent supported two serial + one admin admission failures, recently not recurring, are accepted nonblocking, not fixed. Browser self/generated/key/legacy-credential and other owner command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Numeric memory reserves/stack margins remain unapproved; no full parity or individual unreported checklist passes. Next: separately requested 8D.8 read-only settings entry and Serial page; sign-off alone authorizes no implementation. Evidence: `docs/phase8d7_implementation.md`.
|
||||
|
||||
- 8D.7 third account slice: `admin_ssh_console` shares parsed browser other-account policy with `user_console`; interactive add/password and forced delete/role now allowed, self/generated/key/bootstrap/recovery still blocked. Post-prompt/pre-DB-API currentness is operation admission, not cancellation of admitted derivation/commit. Existing target-only notifications follow success. Review has no actionable findings; `python3 tests/admin_console_boundary/accounts.py` adds deterministic handler/database failure and stale-next-operation regressions. Target/M2 pending; see `docs/phase8d7_implementation.md`.
|
||||
|
||||
- 8D.7 second slice: exact parsed browser `web certificate rotate --force`; `admin_ssh_console.{c,h}` supplies the typed request union/owner `dispatcher_actions` mask, bounded drain/200 ms handoff to the existing 12 KiB dispatcher, persistent pending gate and revalidated executing-slot reservation. `web_console.c` schedules; `web_admin_transport.c` revalidates then calls transactional `web_security_rotate_certificate()` → `web_server_stop()` → `web_server_start()`, short-circuiting errors and retaining ownership on failed stop. SSH/UART0 unchanged. No tasks/depth/routes/assets/stacks added; target stack margins unknown. Boundary `run.py` includes `certificate.c`; lifecycle/policy and transport 25/tickets 12 host groups pass as reported. Credential/account then other owner slices remain; user authorized stacking, not target/M2 sign-off. See `docs/phase8d7_implementation.md`.
|
||||
- 8D.7 first-slice history: browser `reboot`/`web stop` defer via `admin_ssh_console` control task; WEB owner revalidates cookie/principal/token before lifecycle calls. Pending console input is discarded (incoming-frame disposition latched before receive). `web_console.c` defers stop only for browser origin; other restrictions remain. Tests additionally include `python3 tests/admin_console_boundary/lifecycle.py`; handoff: `docs/phase8d7_implementation.md`. No 8D.7/M2 acceptance yet.
|
||||
|
||||
- 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. No UI entry or broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled, 16 URI handlers.
|
||||
- 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.
|
||||
- Tests: `python3 tests/web_admin_transport/run.py --tickets`, `python3 tests/web_admin_transport/server_lifecycle.py`, `python3 tests/web_cookie_auth/run.py --admin`; manual smoke client/procedure in `tests/web_admin_transport/README.md` and `docs/phase8d5_implementation.md`. Final shutdown fix is host-tested and build-verified by the parent's sequential final `pio run`; target validation remains pending.
|
||||
|
||||
## SSH
|
||||
|
||||
@@ -111,7 +78,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
- Files: `src/ssh_transport.{h,c}`, `src/ssh_security.{h,c}`, `src/ssh_console.{h,c}`
|
||||
- Interfaces: init/start/stop, session snapshots/disconnect/revocation, host-key replacement, counters
|
||||
- Called by: startup, network clients, user revocation, console/local UI
|
||||
- Dependencies: user database, broker, admin SSH console, secure random, wolfSSH/wolfSSL; boot start gate requires Wi-Fi and SSH security/runtime readiness, independently of HTTPS identity readiness (verified in `main.c` after accepted legacy cleanup).
|
||||
- Dependencies: user database, broker, admin SSH console, secure random, wolfSSH/wolfSSL; current boot start gate also depends on `web_security` readiness
|
||||
- Flow: role `user` -> broker; role `admin` -> `admin_ssh_console`
|
||||
- Ownership: after caller-side library initialization, one task pinned to core 1 owns runtime wolfSSH contexts/sessions; two fixed generation-tagged slots.
|
||||
- Security constraint: an interactive shell request is required; exec and subsystems are rejected, and no project file-transfer or forwarding route exists. PTY is not explicitly required.
|
||||
@@ -120,48 +87,47 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
|
||||
**Responsibility:** persist bounded accounts, verify passwords/SSH keys, issue secret-free principals, and enforce account invariants.
|
||||
|
||||
- Files: `src/user_database.{h,c}`, `src/user_console.{h,c}`; `src/admin_command_gate.{h,c}` is currently a narrow recursive wrapper used only by the `user` command handler, not the global command serializer
|
||||
- Interfaces: credential-independent init/empty recovery, authenticate, principal-currentness, account/password/role/key mutations, snapshots
|
||||
- Called by: web and SSH authentication/currentness checks and console administration
|
||||
- Dependencies: NVS, secure random, mbedTLS cryptography; after a committed command-layer mutation, best-effort web/SSH revocation calls supplement authoritative transport currentness checks
|
||||
- Ownership: database mutex protects the internal live record and PSRAM-preferred transactional candidate; password authentication runs PBKDF2 outside the mutex and revalidates afterward, while mutation locking must be checked per operation.
|
||||
- Authorization: UART0 establishes the first administrator through normal `user add` and exclusively owns unavailable-database recovery to empty (healthy database refused); current admins may use admin SSH for other commands unless handler policy denies them. HTTPS serial/status permits both roles; administration requires `admin`.
|
||||
- Files: `src/user_database.{h,c}`, `src/user_admin_service.{h,c}`, `src/user_console.{h,c}`, `src/admin_command_gate.{h,c}`
|
||||
- Interfaces: init/migration/recovery, authenticate, principal-currentness, account/password/role/key mutations, optimistic mutation results, snapshots
|
||||
- Called by: web and SSH authentication/currentness checks, `/api/admin/users`, and console administration
|
||||
- Dependencies: NVS, secure random, mbedTLS cryptography, web/SSH targeted revocation
|
||||
- Ownership: database mutex protects the internal live record and PSRAM-preferred transactional candidate; password authentication runs PBKDF2 outside the mutex and revalidates afterward. `user_admin_service` is the shared typed mutation boundary for web and console paths: its recursive `admin_command_gate` region serializes snapshot expectation checks plus commit, then performs best-effort web and SSH revocation after a committed change.
|
||||
- Authorization: UART0 exclusively owns initial administrator bootstrap and unavailable-database recovery. Both roles may use browser serial; only current admins may use guided admin APIs, the browser Admin shell, or admin SSH, subject to handler policy.
|
||||
- Constraint: final administrator cannot be deleted or demoted; transport principals must be rechecked after mutations.
|
||||
|
||||
## Administration console infrastructure
|
||||
|
||||
**Responsibility:** provide one canonical command registry and serialized execution for UART0 and admin SSH.
|
||||
**Responsibility:** provide one canonical command registry and serialized execution for UART0, admin SSH, and the browser Admin shell.
|
||||
|
||||
- Files: `src/admin_ssh_console.{h,c}`, `src/console_input.{h,c}`, `src/console_completion.{h,c}`, `src/system_console.{h,c}`, `src/network_console.{h,c}` and all `*_console.{h,c}` modules
|
||||
- Entry points: `admin_ssh_console_init()`, `admin_ssh_console_start_uart_frontend()`, command registration functions
|
||||
- Called by: startup, UART0 frontend, role-`admin` SSH transport
|
||||
- Files: `src/admin_ssh_console.{h,c}`, `src/console_input.{h,c}`, `src/console_completion.{h,c}`, `src/system_console.{h,c}` and all `*_console.{h,c}` modules
|
||||
- Entry points: `admin_ssh_console_init()`, `admin_ssh_console_start_uart_frontend()`, `admin_ssh_console_open()`, command registration functions
|
||||
- Called by: startup, UART0 frontend, role-`admin` SSH transport, browser admin transport
|
||||
- Dependencies: ESP-IDF console/linenoise, all command handlers, user-principal currentness
|
||||
- Flow: `UART0/admin SSH -> bounded request queue -> one dispatcher -> esp_console_run()`
|
||||
- Flow: `UART0/admin SSH/browser admin -> bounded request queue -> one dispatcher -> esp_console_run()`
|
||||
- Ownership: dispatcher is sole `esp_console_run()` caller; the SSH owner exclusively performs post-initialization wolfSSH runtime calls.
|
||||
- Lifecycle: remote session tokens include slot generation; fixed output/history/prompt state is wiped immediately on idle close or after an executing handler returns. Admin SSH `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort output draining.
|
||||
- Constraint: one slow command or prompt serializes all administration. Admin SSH is unavailable until command registration and UART frontend creation complete; supported deferred actions wait only for a bounded application-buffer drain heuristic.
|
||||
- 8D.4/8D.5 boundary: `admin_ssh_console_open_owned()` retains explicit-index admission; runtime SSH and browser owners use `admin_ssh_console_open_available()` for the same two slots. Copied transport-qualified identity and immutable firmware-lifetime currentness/drain/lifecycle adapters; SSH publishes its allocated console index separately from its physical SSH slot. Owners handle liveness/output; dispatcher and prompt waits additionally require owner currentness (250 ms polling plus check/scheduling latency). SSH publishes locked principal copies; consumed console output is wiped. Completion scratch is nonblockingly serialized. Browser unsupported lifecycle/account mutations are rejected before execution. Focused host command: `python3 tests/admin_console_boundary/run.py`.
|
||||
- Lifecycle: remote session tokens include frontend identity and slot generation; fixed output/history/prompt state is wiped immediately on idle close or after an executing handler returns. Remote `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort output draining.
|
||||
- Constraint: one slow command or prompt serializes all administration. Remote admin-console admission is unavailable until command registration and UART frontend creation complete; supported deferred actions wait only for a bounded application-buffer drain heuristic.
|
||||
|
||||
## Wi-Fi
|
||||
|
||||
**Responsibility:** persist station/AP policy and own asynchronous ESP-NETIF/Wi-Fi state transitions.
|
||||
|
||||
- Files: `src/wifi_config.{h,c}`, `src/wifi_manager.{h,c}`, `src/wifi_console.{h,c}`, `src/mdns_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_console.{h,c}`, `src/network_console.{h,c}`
|
||||
- Interfaces: config defaults/validate/load/save; manager init/start/stop/apply/reconnect/next-profile/snapshot
|
||||
- Called by: startup, console, local UI, ESP event callbacks; typed Network settings uses secret-free zero-wait projections and dispatcher-owned canonical conditional mutations (8D.12/8D.13).
|
||||
- Interfaces: config defaults/validate/load/save; manager init/start/stop/apply/reconnect/next-profile/snapshot; versioned working-config copy, compare-and-swap, and exact-generation save
|
||||
- Called by: startup, console, local UI, typed web handlers, ESP event callbacks
|
||||
- Dependencies: secure random for default AP password, NVS, ESP-NETIF/Wi-Fi/events, Espressif mDNS, lwIP diagnostics
|
||||
- Lifecycle: permanent manager task and bounded queue; callbacks enqueue compact events only.
|
||||
- Constraint: application NVS is authoritative (`WIFI_STORAGE_RAM`); working edits are not persisted until save. Start/stop, including local controls, intentionally update the RAM `enabled_at_boot` field. Working-config copies contain PSKs and must be tightly scoped and wiped; routine status/local UI must use secret-free snapshots.
|
||||
- Constraint: application NVS is authoritative (`WIFI_STORAGE_RAM`); working edits are not persisted until save. Start/stop, including local controls, intentionally update the RAM `enabled_at_boot` field. Browser edits compare a nonzero working-config generation, and browser Save persists exactly that generation; stale or exhausted generations fail closed. Working-config copies contain PSKs and must be tightly scoped and wiped; web reads expose only `secret_set` flags, and routine status/local UI must use secret-free snapshots.
|
||||
|
||||
## Local display and controls
|
||||
|
||||
**Responsibility:** own OLED I2C/framebuffer operations and present status plus constrained button actions.
|
||||
|
||||
- Files: `src/local_display.{h,c}`, `src/local_status_ui.{h,c}`, `src/local_boot_animation.{h,c}`, `src/local_ui_config.{h,c}`, `src/local_ui_console.{h,c}`
|
||||
- Files: `src/local_display.{h,c}`, `src/local_status_ui.{h,c}`, `src/local_boot_animation.{h,c}`, `src/local_ui_config.{h,c}`, `src/local_ui_console.{h,c}`; shared writer serialization uses `src/admin_command_gate.{h,c}`
|
||||
- Interfaces: display init/frame/draw/commit/snapshot; UI start/activity/config; versioned NVS settings
|
||||
- Called by: startup, local UI task, diagnostics, display console
|
||||
- Called by: startup, local UI task, diagnostics, display console, and typed `/api/admin/display` handlers
|
||||
- Dependencies: copied snapshots/public APIs from serial, broker, USB, Wi-Fi, web, SSH
|
||||
- Ownership: `local_display` solely owns I2C0 and framebuffer mutex; a frame belongs to its initiating task.
|
||||
- Ownership: `local_display` solely owns I2C0 and framebuffer mutex; a frame belongs to its initiating task. Typed web Apply/Save/Load/Defaults/Reset and console display writers share `admin_command_gate`, serializing each complete working-config or persistence operation.
|
||||
- Lifecycle: the low-priority task is firmware-lifetime only if button GPIO initialization succeeds; it still runs with an absent panel so a press can reprobe after successful I2C bus setup. Failed bus creation is not recoverable by that reprobe, and `display` configuration commands depend on the UI task.
|
||||
- Constraint: collect service snapshots before I2C; local UI never joins broker or handles secrets.
|
||||
|
||||
@@ -189,8 +155,10 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
| Change HTTPS endpoints/authentication | `web_server.*`, `web_security.*`, `user_database.*` |
|
||||
| Change SSH login or role routing | `ssh_transport.*`, `ssh_security.*`, `user_database.*` |
|
||||
| Add or change a command | relevant `*_console.c`, `console_completion.c`, `admin_ssh_console.c` policy/deferred handling |
|
||||
| Change account roles/passwords/keys | `user_database.*`, `user_console.c`, transport revocation APIs |
|
||||
| Change Wi-Fi policy or profile persistence | `wifi_manager.*`, `wifi_config.*`, `wifi_console.c` |
|
||||
| Change account roles/passwords/keys | `user_admin_service.*`, `user_database.*`, `user_console.c`, `/api/admin/users` handlers, transport revocation APIs |
|
||||
| Change Wi-Fi policy or profile persistence | `wifi_manager.*`, `wifi_config.*`, `wifi_console.c`, `/api/admin/wifi-config` handlers |
|
||||
| Change guided display aging | `local_ui_config.*`, `local_status_ui.*`, `/api/admin/display` handlers, `web_ui.c` |
|
||||
| Change HTTPS stop/restart or TLS-material refresh | `web_server.*`, `web_console.c`, `web_admin_transport.*`, deferred control in `admin_ssh_console.*` |
|
||||
| Change station mDNS hostname or persistence | `mdns_service.*`, `mdns_config.*`, `mdns_console.c`, then `wifi_manager.c` |
|
||||
| Change OLED rendering or buttons | `local_status_ui.c`, `local_display.*`, `local_ui_config.*` |
|
||||
| Change board GPIO or electrical tests | `board_pins.h`, hardware test module, `docs/wiring.md` |
|
||||
|
||||
+16
-167
@@ -4,115 +4,12 @@ This file is working memory. Update it during active work and before handoff; do
|
||||
|
||||
## Development state
|
||||
|
||||
- **Settings presentation unified (2026-09-08), user-requested visual refinement:** Accounts/Network now use Serial-style 600px label/value definition lists, shared form styling, compact muted help, consistent Refresh/result labels and action grouping. Public-key textarea/generated-password fields styled; checkboxes intrinsic-width. Safe DOM text replaces preformatted summaries; readable ASCII SSID/hex fallback retained, pre-wrap preserves significant spaces (review finding fixed with rendered-width regression). IDs/events/auth/mutations/secret cleanup/terminal ownership unchanged; no backend/assets changes. Parent UI100 behavior groups plus HTML/renderer/CSP and headless Chromium geometry/whitespace checks at320/600/1200px PASS; pio run PASS23.69s, 99,548 B RAM / 1,744,325 B flash (+0/+1,744 vs preceding ASCII-summary build). Diff check PASS. Fixtures/browser layout checks are not target visual sign-off or live Wi-Fi validation. No upload/erase/commit.
|
||||
|
||||
- **8D.12/8D.13 implemented together by user authorization (2026-09-08); host-tested/build-verified, target pending:** Backend and admin Network UI deliver 8D.12 nonsecret STA/AP/profile/mDNS edits and explicit persistence, then 8D.13 secret replacement/disabled-STA clear and connection controls. Exact API, byte SSID/UTF-8+hex UI, owner/generation/persistence/uncertainty contracts: `docs/phase8d12_13_implementation.md`. One 768-byte request, 2,048-byte snapshot, 128-byte result; one login-bound slot and one-second ESP timer, 30-second non-executing expiry plus scheduling latency, not hard cancellation. Existing dispatcher IDs/manager owner; accepted != online. Wi-Fi Load stored-only, no reset/default-secret/export; mDNS separate generation/Set/Save/Load/Defaults/reannouncement. 27 handlers/six sockets, no stack/task/queue/schema growth; staged optional route failures preserve unrelated routes. Reported backend/cookie Network PASS; backend P3 queue-drop-counter fix complete; UI agent97+renderer/CSP/review PASS; lifecycle agent21 PASS. Parent final reruns PASS Network five production-path groups, cookie Network five+shared/accounts/serial-settings/admin, console boundary/canonical accounts, lifecycle21, UI97+CSP, idle18+guards, transport25/tickets12, store--serial, diagnostics12+guard and diff check. Independent reviews no remaining actionable findings. Parent pio run PASS24.99s, 99,548 B RAM / 1,742,437 B flash (+288/+36,656 vs accepted legacy cleanup). Earlier pre-final-UI integration build emitted nonfatal FATFS_PRINT_FLOAT config warning; no unrelated config edits. Host owner paths use radio/scheduler/storage doubles, not real network validation. Timer heap, memory floors, HTTPD/dispatcher margins, live Wi-Fi/mDNS/DNS/trust and target checklist remain pending. Profile editor is not explicit-index connection selection: only canonical Next profile. UART0/USB recovery, danger confirmations, no same-response delivery guarantee and unchanged browser-shell restrictions documented. No 8D.14, full M3, target acceptance or reserve approval. Documentation agent touched only authorized docs, no source/tests/assets/build/device/commands; also corrected stale SSH-to-HTTPS startup dependency against `main.c` from accepted legacy cleanup. Older wait-for-8D.12 statements below are superseded, not earlier scoped sign-offs.
|
||||
|
||||
- **Legacy-credential cleanup signed off (2026-09-08):** User explicitly accepts cleanup, confirms unchanged HTTPS certificate fingerprint and continued use of preexisting test users, supplementing the clean full-mix telemetry below. Supersedes prior target-pending/fingerprint-unconfirmed status for this cleanup; idle-cleanup scoped acceptance stands. User suspects two boot auth failures were stale pre-flash sessions in two browser tabs; plausible, not traced/confirmed. Exact acceptance and limits: `docs/legacy_credential_removal.md`. No unreported blank provisioning/recovery/fault-injection/soak/all-key checks, numeric reserve approval or broader phase/M3 acceptance inferred. Documentation only; no source/config/build/test/device/assets/commit action.
|
||||
|
||||
- **Post-legacy-removal target evidence (2026-09-08):** User provides 60-second boot/full-mix telemetry and reports substantial traffic. Five accounts/two admins; two SSH public-key sessions and two browser password logins operational. USB writer16, SSH observer9, web observers10/11, both admin consoles active at 230400 8N1 RTS/CTS DTR active. Serial WS connect2/disconnect0, admin1/0, all supplied web send/queue/protocol/close and SSH handshake/auth/IO failure counters zero; tickets all consumed/no expiry. Two web auth failures already present at boot unchanged under load, zero invalid login credentials; request origin unknown. Boot internal/DMA/PSRAM free66,488/58,732/8,246,148 B; loaded31,820/24,064/8,112,076 B, minima15,740/7,984/8,074,196 B, largest20,480/20,480/7,995,392 B. SSH stack minimum-free18,476 boot/16,284 loaded B. Exact counters/evidence limits in `docs/legacy_credential_removal.md`. No certificate fingerprint comparison, blank provisioning/recovery, all-key verification, exact revision/duration or explicit sign-off supplied; browser RX0 and no broker drop counters prevent all-route/bidirectional/lossless claims. No source/build/device action; documents only.
|
||||
|
||||
- **Legacy credential removal (2026-09-08), implemented/host-tested/build-verified; target pending:** New database and TLS module contracts inspected. Missing user storage commits empty; normal UART0 `user add <username> admin [--generate]` provisions the first administrator. Unavailable-only UART0 recovery rebuilds empty and refuses healthy storage. Existing valid user v1 bytes stay unchanged; private derived `v1_admin_marker`, no public bootstrap/migration/sync API. HTTPS private v1 reader migrates `web_sec/material` 1392 → TLS-only v2 1340 bytes, retaining exact DER/fingerprint/generation, committing before publication; failures fail closed without fallback replacement. Legacy credential commands removed; generated user passwords and TLS rotation retained; reset is TLS-only. Older v1-only firmware cannot read v2; logical NVS replacement is not secure flash wiping, and no factory erase is required. Startup integration complete; SSH gate independent of HTTPS identity, obsolete completion/policy entries removed. Parent security15, canonical accounts, policy/startup64-gate combinations, console lifecycle, cookie accounts, idle18+guards and build/diff checks PASS. Integration agent reports broad auth/transport/lifecycle/UI regression PASS. Review no actionable findings; final agent-added sparse populated v1 reload/authorization regression PASS, test-only extension. Parent final production build 3.93 s, 99,260 B RAM / 1,705,781 B flash (−56/−3,200 vs idle-cleanup baseline). Host NVS doubles do not prove flash/power-loss behavior; no independently captured old-device TLS fixture or hardware validation. Record: `docs/legacy_credential_removal.md`.
|
||||
|
||||
- **Scoped user report — HTTPS idle cleanup worked (2026-09-08):** User accepted that the cleanup worked. This supersedes the no-target-acceptance wording for that specific behavior below, not the historical measurements. No additional soak duration, detailed checklist passes, resource reserve approval, broader 8D.11/M3 sign-off or legacy-removal target validation was supplied.
|
||||
|
||||
- **User-authorized bounded ordinary HTTPS idle cleanup complete (2026-09-08), host-tested/build-verified; target pending:** Preserved existing 8D.11 key/diagnostic implementation (initial Git status was clean). User capture: post-TLS occupancy 6/6, ordinary4/serial2/admin0, ordinary connection ages 50–74 s; admin ticket returned in 14 ms, no TLS/upgrade observed and ticket unconsumed. Installed IDF stops selecting listener at capacity with LRU disabled. `web_httpd_idle.{c,h}` plus sole-private-boundary sweep in `web_httpd_adapter`: six owner-only rows, 15-second observed-idle window (three five-second status polls), one-second ESP timer/at most one queued owner probe, current fd shutdown (no reusable-pointer queued close), all-route SDK request-plus-purge completion marker and input readiness/WS/async checks, TLS-create fd reset, submit fence/nonwrapping restart generations. Diagnostics does not gate policy; socket/timeout/LRU/task/stack/asset settings unchanged. Implementation `pio run` PASS 57.55 s, final confirmation PASS 3.13 s; **99,316 B RAM / 1,708,981 B flash (+160/+1,384 versus diagnostic baseline)**. Rows144 B/module static167 B before placement; timer32 B internal heap before overhead; sweep local frame80 B, runtime margins unmeasured. Final idle **18 + SDK guards**, server **18**, diagnostics **12+1** PASS; cookie accounts/admin/settings/serial-settings, admin transport25/tickets12, store/serial, UI68+CSP and canonical console/account/key/lifecycle/policy suites PASS; diff check PASS. Cookie test-double missing `<stdint.h>` fixed and all variants rerun. Exact contracts/audit/tests/resources/target checklist: `docs/https_idle_cleanup.md`. Ordinary ages are connection ages, not proved idle duration. No hard wall-clock/admission guarantee: owner-blocking handshake/parser/send delays cleanup; continuously active ordinary sockets are not evicted. Reported queue errors retry, but accepted-and-lost nonblocking UDP work stays one reserved probe until successful stop/restart (tested), rather than accumulating unsafe delayed work. Failed stop remains detached/owned until retry. No hardware/upload/erase/commit, target acceptance or reserve approval; next is the documented target reproduction/soak, not capacity changes.
|
||||
|
||||
- **Authorized 8D.11 admission diagnostic slice complete (2026-09-08), host-tested/build-verified; target pending:** `web_diagnostics.{c,h}` uses public synchronous HTTPS create/close callbacks and four ticket/upgrade wrappers, six fixed metadata slots and a 32-record opt-in numeric ring. Console `web diagnostics enable|disable|show|clear` via UART0/admin SSH; browser policy unchanged. No close/open override, queue probe/task, capacity/timeout/log-level/assets/SDK change. Post-TLS owner-published occupancy only; TLS failures/in-progress handshakes and preaccept backlog remain invisible. Final `pio run` PASS **10.30 s, 99,156 B RAM / 1,707,597 B flash**, **+3,080/+3,912 B** versus recorded key-slice baseline. Ring/table target symbols 2,816/192 B; diagnostic handler/record/TLS callback/show local frames 144/144/160/528 B excluding callees, runtime margins unmeasured. PASS diagnostic **12+1**, server lifecycle **16**, canonical console lifecycle/policy/boundary, canonical account/key transactions, cookie/admin and cookie/accounts, admin transport **25**/tickets **12**, browser **68 plus renderer/CSP**, store/serial and diff check. Exact files/commands/contracts and reproduction checklist in `docs/phase8d11_implementation.md`. Existing key work preserved; no device/commit/upload/erase, timeout diagnosis, target acceptance or reserve approval claimed. Next is user target capture, not capacity changes or 8D.12.
|
||||
|
||||
- **8D.11 partial target evidence (2026-09-08):** User supplied 60-second fresh-boot/full-mix telemetry; no functional/sign-off claim. Full mix eventually established (two SSH public-key sessions, SSH writer + USB/two web observers, both admins), but last browser admission again required retries. Three TLS errors `-0x0050` verified against installed mbedTLS as NET_CONN_RESET, not allocation failure; cause unknown. Loaded internal/DMA free 32,596/24,840 B, lifetime minima 5,468/532 B, largest 22,528 B; per-region minima are conservative/non-simultaneous. Settled free is similar to prior 8D.10, minima much lower; no causal regression attribution. SSH stack minimum-free 18,468 boot/16,276 loaded B. Web send failure/close 1, expired tickets 3; SSH handshake/auth failures zero; no broker drop counters. Exact memory/counters/evidence limits in `docs/phase8d11_implementation.md`. Previously accepted admission issue recurred, not fixed; investigate timing/socket occupancy/allocation correlation before capacity changes. Key algorithms/new UI provenance, mutation checklist, HTTPD/dispatcher margins, cleanup/soak and target sign-off remain pending. Documentation-only update; no source/build/device action.
|
||||
|
||||
- **8D.11 implemented (2026-09-08), host-tested/build-verified; target sign-off pending:** Accounts now supports fingerprint listing and bounded Ed25519/P256 public-key import/delete/clear. Conditional database wrappers check target identity under the canonical lock; existing dispatcher/account slot and target-only revocation remain. Optional POST `/api/settings/accounts/keys` brings handler budget to 24, six sockets/tasks/stacks/queue depth unchanged. Sparse stable key slots are supported; review finding fixed and regressed. Parent canonical accounts, cookie accounts, UI, lifecycle (16), build and diff checks passed; final agent UI extension passes 68 groups plus CSP. Final production build 14.85 s, 96,076 B RAM / 1,703,685 B flash (+168/+9,448 vs 8D.10). Exact API, evidence attribution and pending target checklist: `docs/phase8d11_implementation.md`. No device/assets/commit action, reserve approval, full M3 claim or 8D.12 work. Supersedes the older wait-for-8D.11 instruction below, not accepted 8D.8–8D.10/M2.
|
||||
|
||||
- **8D.8–8D.10 implemented scope signed off by user (2026-09-08):** User provides settled boot/full-mix telemetry and reports thorough Serial parameter display/settings and user/account testing: “Implemented work can be signed off.” Accepts 8D.8, 8D.9 including UX refinement, and both implemented 8D.10 slices, superseding pending-signoff/target-blocker statements below without inventing individual checklist passes. Current source/handoff confirms credential/self workflows and generated route complete. Exact flashed revision/browser/durations/reboot/fault-injection details not separately supplied. Full mix at **230400 8N1 RTS/CTS, DTR active**: SSH writer8, USB observer9, web observers26/11, SSH admin and web admin, two cookie sessions; no broker drop counters supplied. Boot internal/DMA/PSRAM free **69,928/62,172/8,246,368 B**, loaded final **32,556/24,800/8,087,656 B**, loaded minima **19,228/11,472/8,065,444 B**, largest **23,552/23,552/7,995,392 B**. SSH minimum-free stack **18,476 boot / 16,284 loaded B**. One SSH handshake failure/broker revocation, one WebSocket send failure/close and one invalid login retained without diagnosis. All six loaded samples, counters, scope and evidence limits: `docs/phase8d10_implementation.md`. Numeric reserves, HTTPD/dispatcher stack margins, cleanup/soak evidence and earlier accepted admission issue remain followups, not reopening sign-off. Browser-shell restrictions/UART0 recovery and M2 stand; no full M3 claim. **Wait for separately requested 8D.11.** This update documentation-only, no new build/test/device/source/config/asset/commit action; preserve existing user `platformio.ini` edit.
|
||||
|
||||
The following implementation entries predate target sign-off and retain historical build/test attribution; their target-pending status is superseded above.
|
||||
|
||||
- **Current 8D.10 slice 2 complete, host-tested/build-verified (2026-09-08); target/signoff pending, not target accepted:** Create/password/generated-value/self workflows use the existing dispatcher slot, 768-byte/four-receive admission and canonical mutation-lock identity checks. One-second periodic timer cancels/wipes non-executing credentials at 30 seconds plus scheduling latency; admitted work is not cancelled and locals wipe after return. Generation is separate before commit, with no retained retrieval; UI 60-second lifetime/context-bound acknowledgement and best-effort secret clearing. Immediate self revocation can lose POST/results: 401/disconnect is uncertain, inspect after relogin before retry. Browser-shell restrictions unchanged. Review's only finding, missing generated-route registration, is fixed as an independent optional endpoint with failure isolation/restart coverage: **23 handlers**, six sockets/no LRU unchanged. Parent PASS canonical `accounts.py`, boundary `run.py`, parser **294**, cookie `--accounts` **9 plus shared**, `--serial-settings` **10**, transport **25**/tickets **12**, store `--serial` and diff check. UI agent **57 plus CSP** (four added beyond 53); route agent lifecycle **15** pass. These UI/lifecycle results are agent-attributed, not claims of the parent's additional reruns. Parent `pio run` **PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash**, **+80/+9,880** vs slice 1 and **+200/+25,400** vs final 8D.9 UX. New timer runtime costs, heap reserves and stack margins remain unmeasured/unapproved. Record/checklist: `docs/phase8d10_implementation.md`; slice 1 below remains historical. This update is documentation-only; no source/test/build action by this documentation agent. No sanitizer validation, device/assets/commit/8D.11 action, full 8D.10 target signoff or prior-phase signoff/reserve approval inferred.
|
||||
|
||||
The following slice 1 entry is explicit historical evidence; its unavailable/next-slice statements and build/counts do not describe current slice 2.
|
||||
|
||||
- **8D.10 first slice complete (2026-09-08), host-tested/build-verified; target pending, phase incomplete:** User requested 8D.10; selected plan's pre-edit list/role/delete versus create/password split. `web_account_settings.{c,h}` provides admin-only compact list and other-account role/delete on one session-bound slot, executed by the existing dispatcher. Zero-wait `user_database_get_accounts()` and mutation-lock identity checks preserve canonical final-admin/NVS semantics and reject stale/recreated targets; successful calls target-revoke web/SSH. Three optional routes, 22 handler budget, six sockets/no LRU, unchanged tasks/stacks/queue depth. Accounts subview has confirmations, bounded auto-completion/refresh (10 GETs/15 s), uncertainty recovery and navigation/session fencing without broker lease effects. Final parent cookie `--accounts` (5 groups/shared auth), canonical accounts, console boundary, server lifecycle (14), UI (41 + CSP), transport/tickets and store/serial pass; prior settings/serial/admin/policy/lifecycle reruns also pass. Final `pio run` **25.00 s / 95,828 B RAM / 1,684,357 B flash**, **+120 / +15,520 B** vs final 8D.9 UX; diff check PASS. Exact evidence/contracts/target checklist: `docs/phase8d10_implementation.md`. No independent review/sanitizer/device/assets/commit action or reserve/prior-phase signoff. Next is second 8D.10 slice: create/password, one-time generated secrets and safe own-account changes; these remain unavailable. No 8D.11 work or full 8D.10 completion.
|
||||
|
||||
- **8D.9 UX refinement (2026-09-08), host-tested/build-verified; target pending:** User reported successful Apply required awkward manual Check Result then Refresh and approved automatic flow/removing routine popups. `web_ui.c` now keeps settings visible/stale while pending, checks acknowledged operations every 1 s up to 10 GET attempts/15 s overall, then refreshes working values while preserving outcome. Errors/exhaustion/lost ack use manual recovery; no POST retry, no resume after navigation. Only Reset retains a saved-NVS overwrite confirmation; inline discard semantics remain. Current-view selection is a no-op so repeated Settings clicks cannot cancel work. Parent UI **35 groups + renderer/CSP**, `pio run` **10.91 s / 95,708 B RAM / 1,668,837 B flash**, diff check PASS; **0 / +2,112 B** vs original 8D.9. No backend/assets/device/commit action or new signoff. Details/evidence limits in `docs/phase8d9_implementation.md`; target automatic-completion, failure/manual-recovery and full-mix latency checks pending.
|
||||
|
||||
- **8D.9 continuation complete (2026-09-07), implemented / reviewed / host-tested / build-verified; target/signoff pending:** Preserved inherited implementation; typed Apply/Start/Stop/Save/Load/Defaults/Reset uses one session-bound slot and the existing dispatcher queue, 256-byte JSON POST and 96-byte explicit GET results. No new tasks/stacks/queue depth or broker lease semantics; 19 URI handlers, six sockets/no LRU. Fixed persistent uncertain-result warnings and consistent zero-wait `/api/status` serial state (`running:null` when unavailable). Final parent Serial 10, Settings/status 6, UI 27/CSP, console boundary and lifecycle 13 groups pass; additional transport/tickets/store/admin/account/lifecycle regressions passed during review. Final `pio run` 23.73 s, **95,708 B RAM / 1,666,725 B flash**, **+128 / +12,196 B** vs 8D.8. Deadline is dequeue admission only; blocked dispatcher retains pending slot, admitted NVS work may finish after logout, completed results can be replaced. Exact contracts/tests/limits and pending target checklist: `docs/phase8d9_implementation.md`. No target/device/asset/commit action, reserve approval or 8D.8/8D.9 signoff. Stop before separately requested 8D.10; M2 acceptance and existing deferred restrictions/admission followups stand.
|
||||
|
||||
- **8D.9 original handoff (2026-09-07; superseded by completion above):** User explicitly requests end-to-end Serial typed edits/persistence despite pending 8D.8 target validation. This is continuation authorization, not 8D.8 target signoff. Plan: verify canonical serial and concurrency contracts; implement a single bounded session-bound operation slot on the existing dispatcher with nonblocking HTTP admission/result reads; add explicit Serial controls and focused regressions; finite build, diff review and accurate resource/target handoff. No later domains/popovers/generated assets/device/commit actions. Existing modification to `docs/phase8d8_implementation.md` belongs to parent/user and is preserved. M2 acceptance, deferred restrictions, unresolved admission and unapproved memory/stack followups stand.
|
||||
|
||||
- **8D.8 separately authorized, implemented / host-tested / build-verified (2026-09-07), target/signoff pending:** Admin-only Settings/Serial in the existing document, bodyless server-authorized `GET /api/settings/serial`, eight typed working values with 256-byte response bound. Nonblocking serial-state snapshot avoids blocking HTTPD on console reconfiguration; no mutations/NVS/broker/terminal lifecycle side effects. Selection preserves both sockets/lease and hidden output; explicit refresh, safe errors, cancellation/identity/expiry and concurrent-reconnect fencing. One optional URI (17 total), six sockets/no LRU unchanged. Private IDF adapter stages new-route descriptor/name before publication to avoid installed public registration's dangling entry on strdup failure; only the new Settings route uses it, broader inherited registration audit remains a followup. Final `pio run` **24.31 s, 95,580 B RAM / 1,654,529 B flash**, **0 / +5,952 B** vs recorded 8D.7; **+1,048 / +54,556 B** vs 8D.0. Target snapshot/descriptor 36/24 B; requested route/table heap +49 B before overhead, handler local frame 416 B excluding callees, no stack/task/queue/capacity increase. UI **21**, Settings **5**, lifecycle **12**, transport **25**/tickets **12** and auth/store/login/parser/console/policy regressions pass; diff check passes. No device, sanitizer, generated-asset or commit action; inline loader/CSP hashes unchanged and verified. Task/todowrite tools unavailable; implementer review fixes rerun, no independent-agent review claim. See `docs/phase8d8_implementation.md` for exact tests/resources/limits/pending target checklist. **M2 signoff, deferred restrictions, accepted unresolved admission issue and unapproved memory/stack followups stand. Stop before separately requested 8D.9; no new-phase signoff inferred.** This supersedes the older next-8D.8/wait-for-request instructions below, not their signoff/evidence.
|
||||
|
||||
- **M2 explicitly signed off by the user (2026-09-07):** After 8D.7 implemented-scope validation and discussion of read-only settings next, the user says "Jupp, sign M2 off". This supersedes all earlier M2-open statements and continuation instructions below; accepted M2 does not require revalidation or imply full browser command parity. Browser self-target/generated-password/key/legacy-credential and other owner-specific command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Intermittent supported two serial + one admin web admission failures are accepted nonblocking, not fixed or diagnosed. Numeric memory reserves and stack margins remain unapproved follow-ups, not blockers reopening M2. **Next: 8D.8 read-only settings entry and Serial page, only when separately requested; this sign-off alone authorizes no implementation.** Evidence/history: `docs/phase8d7_implementation.md`. Documentation only; no source/tests/build/device/commit action.
|
||||
|
||||
Earlier development entries below are historical; the latest M2 sign-off supersedes their pending status and next-work instructions, not their evidence.
|
||||
|
||||
- **8D.7 validated by explicit user sign-off (2026-09-07), implemented scope only:** User explicitly says "Ok, mark 8D.7 as validated." Supersedes historical target-pending/acceptance-blocking and continuation instructions below for the implemented stop/reboot, certificate and other-account slices. User reports thorough testing, verified certificate rotation and web start/stop with lifecycle via UART0/SSH admin/web admin (restart after browser stop via another route), and full mix without broker drops up to **230400 baud** after correcting external adapter baud. Intermittent supported two serial + one admin WebSocket admission failures have recently not recurred and are accepted nonblocking, not fixed or diagnosed. Preserve investigation/telemetry below. No separately reported reboot-specific or individual mutation/injection results; do not invent checklist passes. Self/generated/key/legacy-credential and other owner parity remain deferred and restricted; bootstrap/recovery remain UART0-only. Numeric reserves/stack margins and **M2 acceptance remain open**; no full-parity claim. See `docs/phase8d7_implementation.md`. Documentation-only sign-off; no new implementation authorized. Wait for a separate request.
|
||||
|
||||
Earlier development entries below are historical; the latest sign-off supersedes their pending status and next-work instructions, not their evidence.
|
||||
|
||||
- **8D.7 target admission investigation (2026-09-07):** User reports 60-second clean boot, then intermittent browser-admin timeouts with SSH writer/admin, USB and two web observers; removing one observer allows admin, final observer eventually reconnects. Boot internal/DMA/PSRAM free 70,860/63,104/8,246,176 B; loaded free 33,428/25,672/8,084,100 B, minima 5,280/344/8,058,464 B, largest 23,552/23,552/7,995,392 B. Boot SSH stack minimum-free 18,472 B. Exact revision/browser arrangement/loaded duration unknown. TLS -0x0050 is reset, -0x7280 EOF, not allocation errors (installed SDK verified). Leading unproven hypothesis: six shared HTTPD slots occupied by WebSockets plus retained ordinary HTTP connections; full IDF server stops accepting, recv timeout does not expire idle slots. Synchronous handshakes and memory/global socket pressure may contribute; TLS already allocates externally. Need failed-stage browser timings, count-only HTTPD occupancy/allocation-failure correlation and paused-traffic comparison. No runtime change/build/device operation; record in `docs/phase8d7_implementation.md`. Full-mix reliability and 8D.7/M2 acceptance remain open; do not attribute to account slice or claim OOM without evidence.
|
||||
|
||||
- **8D.7 third account slice review completed (2026-09-07), target pending:** Inherited uncommitted code/tests enable browser other-account interactive add/password and forced delete/role; self/generated/key/bootstrap/recovery remain blocked. Independent production/test reviews found no actionable findings; prior precommit-liveness finding withdrawn. Contract is post-prompt, pre-database-API operation-admission currentness: admitted work may commit and target-revoke after disconnect/expiry, subsequent stale work rejects. Documentation now records uncertain-result recovery and deterministic-test limits. Boundary/accounts/policy (review agent), lifecycle, transport 25/tickets 12 and store/serial (parent) pass. Parent incremental `pio run` PASS 3.35 s, **95,580 B RAM / 1,648,577 B flash**, 0/+516 B versus certificate slice. No production edits in continuation, device operation, sanitizer pass, asset regeneration or commit. See `docs/phase8d7_implementation.md`. Supersedes older all-account-blocked/next-account notes; remaining owner/credential parity, target checks, numeric reserves and 8D.7/M2 acceptance remain pending. No next slice started.
|
||||
|
||||
- **8D.7 second certificate slice implemented / host-tested / build-verified, target pending:** Exact parsed browser `web certificate rotate --force` is enabled. Typed deferred-request union preserves queue capacity; immutable owner `dispatcher_actions` transfers certificate work after bounded drain/200 ms delay to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending-input gate survives handoff; token/principal/session revalidation and executing-slot reservation fence stale work/self-detach reuse. WEB uses transactional certificate commit → stop → start, returning on early errors and retaining ownership on failed stop. SSH/UART0 unchanged; other account/network/credential/SSH mutations remain blocked. No new tasks/depth/routes/assets/stacks. Parent final `pio run` PASS **26.32 s, 95,580 B RAM / 1,648,061 B flash**: **0 / +1,036 B** vs first slice, **0 / +1,572 B** vs 8D.6, **+1,048 / +48,088 B** vs 8D.0. Parent final rerun passes boundary (`run.py` including `certificate.c`), lifecycle, policy, transport **25**/tickets **12**, server **11**, cookie/admin, store/serial, UI **17** plus renderer/CSP and diff checks; independent reviewer has no actionable findings. Sanitizers unavailable (missing libasan/libubsan); no hardware validation. Owner mask/local scratch and target stack margins remain unmeasured; host sizeof is not target proof. Drain/acknowledgement is bounded, not execution latency or peer receipt. **User explicitly authorized continuing to stack the next bounded slice, not target sign-off. Next: credential/account, then other owner slices; 8D.7/M2 acceptance and numeric reserves pending.** See `docs/phase8d7_implementation.md` for separate slice histories and certificate force/trust/relogin/USB/SSH/failure checklist. Supersedes the first-slice next-step/continuation-pending instruction below. Production/tests/docs updated and parent build/regressions executed; no device operation, asset regeneration or commit.
|
||||
|
||||
- **8D.7 first increment implemented / host-tested / build-verified (2026-09-06), target pending:** User requests starting 8D.7. Scope split enables only browser `reboot` and `web stop` through existing bounded deferred control; WEB revalidates session/principal/token before lifecycle APIs. Adds browser-origin query and pending snapshot flag; HTTPD discards deferred input, latching disposition before receive. Review fixed second-frame-before-poll and cancellation-during-receive races; final reviewer confirms correction. Canonical handler/console/policy, transport **23**/tickets **12**, lifecycle **11**, cookie/admin, store/serial and UI **17** suites pass. Final `pio run` **12.44 s, 95,580 B RAM / 1,647,025 B flash**, **0 / +536 B** vs 8D.6. No tasks/routes/capacity/assets/UI/credential changes or device operations. `docs/phase8d7_implementation.md` holds behavior, restrictions, validation limits/resources and disruptive target checklist. **8D.7/M2 incomplete; next slice remains safe HTTPS identity/certificate handling**, then remaining owner/account restrictions. Obtain target regression or explicit user decision before stacking runtime changes. `web stop` intentionally closes both browser routes; restart via UART0/admin SSH. Prior 8D.6 sign-off stands; internal/DMA minima **6,516 / 1,580 B** and numeric reserves remain follow-ups.
|
||||
|
||||
- **8D.6 validated by explicit user sign-off (2026-09-06):** After 60-second boot/full-mix/partial-cleanup telemetry, user confirms all discussed remaining checks and requests wrap-up. Closes 8D.6 including Open admin and toolbar-order fixes, superseding older target-pending notes below. Evidence in `docs/phase8d6_implementation.md`: full mix at **230400 baud**, browser admin + admin SSH, two web serial/SSH/USB broker clients; web24 sole writer. Loaded internal/DMA/PSRAM free **33,900 / 26,144 / 8,087,468 B**, minima **6,516 / 1,580 / 8,063,428 B**, largest **24,576 / 24,576 / 7,995,392 B**; SSH stack minimum-free **16,280 B**. Partial cleanup retains serial, internal/DMA largest returns to **31,744 B**. One SSH handshake failure and rejected input byte remain unattributed. Exact revision/browser/counts/soak duration not separately supplied; no invented detailed execution or reserve approval. Numeric reserves and low lifetime minima remain follow-ups, not blockers reopening signed-off 8D.6. Last toolbar build/tests: **7.60 s, 95,580 B RAM / 1,646,489 B flash**, 17 UI groups/CSP/diff pass. This handoff changes documentation only. **Wait for separately requested 8D.7 — bounded web-shell lifecycle parity and M2 acceptance.** Preserve current restrictions until their safe owner paths are implemented; no settings/M2 completion or new implementation is implied.
|
||||
|
||||
- **8D.6 Open admin bug fix (2026-09-06), target retest pending:** Confirmed frontend/backend ticket mismatch: `openAdmin()` required 32 URL-safe characters (serial format), while admin issuance returns 64 lowercase hex characters. Valid POST responses therefore threw before WebSocket construction with the user's exact generic message. Fixed only the admin validator; split the previously shared serial/admin test fixture, assert exact admin query URL and reject malformed/serial-format tickets without closing serial. Correct-format fixture reproduced pre-fix failure; final production-renderer/CSP + 17 UI groups, cookie/admin endpoint integration, transport 19/tickets 12 and lifecycle 11 pass. These are separate frontend and backend harnesses, not real-browser/on-wire integration. CSP loader hash matches the reported `o6St...` policy; rejected UUID/index.js scripts' origin is unverified, not grounds to relax CSP. Final bounded `pio run` passed in **7.96 s**, **95,580 B RAM / 1,646,489 B flash**, unchanged sizes. Diff check passes. No assets, upload, commit, backend/admission changes or target pass. Short bug record in `docs/phase8d6_implementation.md`; parent independent review and user target retest pending.
|
||||
|
||||
- **8D.6 final re-review complete (2026-09-06):** Independent reviewer confirmed session-identity isolation and bounded fit-readiness fixes and their regressions, with no remaining actionable findings in the reviewed changes. Final build/test results below stand; no further source change or build for this documentation update. Browser/target validation and numeric reserves remain pending, not phase sign-off or M2 completion. Stop before 8D.7 until separately requested after the validation decision.
|
||||
|
||||
- **8D.6 independent review fixes implemented / host-tested / build-verified (2026-09-06), target pending:** Fixed both P2 findings without unrelated scope. Before session adoption, compare retained username/role/session-stable CSRF; mismatch hides old terminals, closes both routes/fences work and replaces the document at `/`, including live admin and logout paths. Pagehide keeps buffers hidden until same-session validation; unchanged identities retain scrollback. Fit caches bounds only after valid measurement/success, with three bounded readiness retries, teardown cancellation and generation fencing. Expanded UI harness passes **16 groups**; login eight groups, cookie/admin integration, parser 268 and store/serial reruns pass. Final finite `pio run` after all fixes: **21.35 s, 95,580 B RAM / 1,646,489 B flash**; review delta **0 / +1,376 B**, versus 8D.5 **0 / +9,216 B**, versus 8D.0 **+1,048 / +46,516 B**. `git diff --check` passes. See `docs/phase8d6_implementation.md` for exact regression cases and unchanged pending target checklist. Parent reviews fixes only; no target pass, reserve approval, sign-off or M2 completion. Existing worktree edits preserved; no server/protocol/assets/8D.7/upload/commit action. Supersedes initial 8D.6 counts/build below.
|
||||
|
||||
- **8D.6 implemented / host-tested / build-verified (2026-09-06), target pending:** Admin-only Serial/Admin selector in authored `web_ui.c`, explicit Open/Close admin using existing protected endpoints, lazy separate 5,000-line terminals with 64 KiB pending writes each and visible browser-drop counts. Switching preserves serial socket/client/lease and drains hidden output; selected input only, writer controls visible in both. Admin closure is isolated; logout/expiry/pagehide cancel both with listener cleanup and stale-generation fencing. Existing 8D.5 restrictions/server/capacities unchanged; no assets/upload/commit. 13 UI groups, eight login groups, cookie/admin integration, parser 268, store/serial, admin transport 19/tickets 12, server lifecycle 11 and console boundary suites pass. Final finite `pio run`: **22.22 s, 95,580 B RAM / 1,645,113 B flash**, **0 / +7,840 B** versus 8D.5, **+1,048 / +45,140 B** versus 8D.0. Diff check passes. See `docs/phase8d6_implementation.md` for exact contracts, test limits/resource accounting and pending desktop/mobile/prompt/full-mix/soak checklist. Parent independent review pending; no target execution, numeric reserve approval, 8D.6 sign-off or M2 completion. Prior 8D.5 validation stands. Stop before separately requested 8D.7. This supersedes older planned-selector notes below.
|
||||
|
||||
- **8D.5 validated by explicit user sign-off (2026-09-06):** User supplied settled cold-boot telemetry and reports successful **15-minute full-client-mix active-use soak at 230400 baud**, with a few broker drops under extremely fast/dmesg output, then explicitly closes 8D.5. Supersedes older pending/incomplete notes below. Boot internal/DMA/PSRAM free **70,876 / 63,120 / 8,246,360 B**, minima **59,560 / 51,804 / 8,242,140 B**, largest **31,744 / 31,744 / 8,126,464 B**; SSH stack minimum-free **18,472 B**. Admin initialized/attached with ESP_OK and 167/240/1,552 B static/ticket/payload accounting; no active clients at boot. Full mix/soak is user-reported, not evidenced by the idle snapshot; exact drop count/client, flashed revision and loaded/post-soak/cleanup telemetry not supplied. See `docs/phase8d5_implementation.md`. These limits do not reopen sign-off or imply zero-drop operation. Numeric reserves/runtime socket cost remain open. No new build/device/source action for sign-off. Wait for separate **8D.6** request; M2 not yet complete.
|
||||
|
||||
- **8D.5 backend implemented / host-tested / build-verified (2026-09-06), target pending:** Preserved interrupted tickets/transport/server/shared-console/SSH/policy/revocation work and inherited test tooling. One admin socket, two tickets, existing two shared console slots, 1,552 B PSRAM-only payload, 20 ms ESP timer/no new task, six HTTPS sockets/no LRU eviction, 16 URI handlers. Added real cookie/store/ticket/transport endpoint integration tests. Final IDF lifecycle review found queued `httpd_sess_trigger_close` retains a reusable socket-slot pointer; changed the admin path to HTTPD-owned `shutdown`, with HTTPD read cleanup and retry/reuse regressions. Transport 19 groups + tickets 12, server lifecycle 11, combined endpoints, both console suites, store/serial, cookie/parser (268), login UI (8), serial UI (9) all pass; diff check passes. **Parent reports sequential final `pio run` after the shutdown fix passed in 23.55 s at 95,580 B RAM / 1,637,273 B flash: +416/+9,224 versus prerequisite, +496/+10,100 versus 8D.4, +1,048/+37,300 versus 8D.0.** This supersedes the continuation's earlier pre-fix build; history remains in the implementation record. Parent's final independent security integration reviewer reported no actionable findings. Target ELF payload/slot/ticket sizes 1,552/80/96 B, ticket state+lock 240 B, transport static symbols 167 B before padding, ESP timer internal allocation 32 B before heap overhead. See `docs/phase8d5_implementation.md` for exact commands, inherited-versus-final fixes, resource accounting, restrictions and manual target/client checklist. Client `--help` only exercised in the continuation; this final evidence update changed documentation only, with no source/tests/build/device/network/asset/UI/upload/commit action. **Stop before 8D.6; target acceptance, runtime socket cost, numeric reserves and M2 remain open.** This supersedes the prerequisite-only pause below.
|
||||
|
||||
- **8D.5 prerequisite resumed / host-tested / build-verified (2026-09-06), target pending:** User requested continuation. Preserved existing uncommitted dispatcher/owner-currentness and prompt-cleanup changes in the console/SSH adapter; extended tests to production SSH snapshot/principal publication and wiping. Both console suites pass; independent production review found no actionable defects. `pio run` passes in **38.46 s**, **95,164 B RAM / 1,628,049 B flash** (**+80 / +876 B** versus recorded 8D.4); map confirms 80 B for two principal copies. No new tasks/routes/sockets/UI or backend yet. See `docs/phase8d5_implementation.md` for contract, scope split, test limits and target checklist. **8D.5/M2 incomplete.** Obtain target regression or explicit user decision before stacking live backend work. Exact next increment is still 8D.5 tickets/HTTPD-owned admin transport/protected admission/revocation/restrictions and integration checks, not 8D.6. Previous 8D.4/M1 sign-offs stand; numeric reserves remain open. This supersedes older wait-for-8D.5-request notes below.
|
||||
|
||||
- **8D.4 validated by explicit user sign-off (2026-09-06):** User confirms successful empty Enter and soak testing and explicitly closes Phase 8D.4. This supersedes older pending/in-progress notes below, including the 8D.3 empty-Enter target retest. Boot/full-client-mix evidence and sign-off are in `docs/phase8d4_implementation.md`. Exact soak duration/revision, post-soak/cleanup telemetry and other detailed checklist results were not supplied; these are evidence limitations, not blockers to the user-approved closure or claims of execution. Numeric reserves remain open. No new build/device operation for sign-off. Wait for a separate **8D.5** request; no browser admin backend or M2 completion is claimed.
|
||||
|
||||
- **8D.4 target validation in progress (2026-09-06):** User supplied clean-boot/full-client-mix UART0 evidence, recorded in `docs/phase8d4_implementation.md`. Loaded internal/DMA/PSRAM free **34,632 / 26,876 / 8,089,284 B**, minima **20,648 / 12,892 / 8,077,516 B**, largest **25,600 / 25,600 / 7,995,392 B**; SSH stack minimum-free **16,280 B** (boot **18,472 B**). Four broker clients at 115200 baud: SSH writer, USB and two web observers; user/admin SSH and both browser roles admitted successfully, no reported I/O/transport failures. One cumulative SSH broker revocation retained without diagnosis. Exact flashed revision/duration not supplied. Console-specific regressions, lifecycle/soak/cleanup and explicit phase sign-off remain pending; numeric reserves remain open. No 8D.5 request or implementation.
|
||||
|
||||
- **8D.4 implemented / host-tested / build-verified (2026-09-06), target pending:** User separately requested the small console boundary. Added transport-qualified identity and immutable output-drain/lifecycle owner callbacks in `admin_ssh_console`, retained the SSH admission API in `ssh_transport`, and kept two shared slots, one dispatcher and all capacities/stacks. Shared completion scratch now has nonblocking ownership; occupied-slot replacement and deferred input fail closed. `python3 tests/admin_console_boundary/run.py` and existing policy suite pass; independent review found no defects. `pio run` passes in **43.61 s**, **95,084 B RAM / 1,627,173 B flash** (**-424 / +1,448 B** versus recorded latest 8D.3). See `docs/phase8d4_implementation.md` for contracts, host-test limits, accounting and target checklist. No browser admin routes, uploads or 8D.5 work. Prior M1 sign-off stands; numeric reserves and 8D.4 target regression remain pending. Stop for validation/user decision before separately requested 8D.5.
|
||||
|
||||
- **8D.3/M1 completed by explicit user sign-off (2026-09-06):** User closes M1 after post-soak evidence, superseding all older pending/blocked/in-progress statements below. Four broker clients remain active at 115200 baud (SSH writer; USB/two web observers), both SSH routes and two cookie sessions active. Post-soak internal/DMA/PSRAM free **38,656 / 30,900 / 8,138,320 B**, minima **13,756 / 6,000 / 8,072,744 B**, largest **25,600 / 25,600 / 7,995,392 B**; SSH stack minimum-free **16,288 B**. Minima/largest unchanged from earlier loaded sample; no reported transport failures. Record/evidence limits in `docs/phase8d3_implementation.md`. Sample is still connected, not disconnected cleanup; exact soak duration/revision not supplied. Unrecorded detailed checks do not reopen signed-off M1 or become claims of execution. Numeric reserves remain open; empty-Enter fix target retest remains unrecorded separately. **Wait for a separate 8D.4 request; no M2 implementation performed.**
|
||||
|
||||
- **8D.3 both-role target login confirmed / mixed-client evidence (2026-09-05):** User reports successful HTTPS user+admin login after Origin fix; previous login blocker is resolved. Settled internal/DMA/PSRAM free **71,204 / 63,448 / 8,247,744 B**. Mixed load free **33,868 / 26,112 / 8,089,060 B**, minima **13,756 / 6,000 / 8,072,744 B**, largest **25,600 / 25,600 / 7,995,392 B**; SSH stack minimum-free **16,288 B**. 115200 baud, four broker clients (SSH sole writer, USB + two web observers), user/admin SSH active. No reported web transport or SSH I/O errors; 6 login attempts/3 invalid credentials/1 logout, zero security rejections. Two identical loaded heap samples are not a soak/leak or reserve proof. Full details/provenance in `docs/phase8d3_implementation.md`. **M1 validation in progress, not signed off.**
|
||||
- **Admin SSH empty Enter fix (2026-09-05):** User's empty line was classified as UART0-restricted because `remote_command_allowed` required argc>0. Changed only helper classification to allow empty input through normal quiet IDF handling; currentness and physical-only commands remain protected. New `python3 tests/admin_ssh_policy/run.py` passes 15 cases using production helper/installed parser. Build passes **21.04 s**, **95,508 B RAM / 1,625,725 B flash** (+20 B flash). Not uploaded or target-tested. Ask for empty Enter/normal-command smoke on next flash; no 8D.4 refactor.
|
||||
|
||||
- **8D.3 Origin-null fix (2026-09-05), target retest pending:** User confirmed challenge200/login403 with `Origin: null`, same-origin Fetch Metadata and pre-login cookie; post-attempt counters show 0 password attempts and 7 security rejections. Root cause is non-CORS fetch POST under no-referrer. Login fetch now uses `mode:'cors'`; app helper uses cors for POST tickets/logout, unchanged GET mode. Same-origin credentials/fixed paths/redirect denial/CSP/no-referrer and strict server Origin/CSRF remain intact. Login CSP hash updated atomically. Both UI suites and cookie-policy suite pass; build **95,508 B RAM / 1,625,705 B flash**, **14.30 s** (+16 B flash). No upload; actual Firefox Origin header/login/serial/logout retest and M1 acceptance still pending. See implementation record; supersedes the speculative diagnosis below.
|
||||
|
||||
- **8D.3 target login blocker (2026-09-05):** User supplied clean-boot/60-second settled telemetry, then reports both user/admin login rejected with the page's HTTP-403 message. Record in `docs/phase8d3_implementation.md`: internal free/min/largest 69,004/66,752/31,744 B; DMA 61,248/58,996/31,744 B; PSRAM 8,223,116/8,218,204/8,126,464 B; SSH stack minimum-free 18,464 B; no active sessions/broker clients, UART stopped. Boot counters precede login attempts; no post-attempt result yet. Exact flashed revision not supplied. Production login-renderer/CSP test passes. Browser warnings name other script hashes (possibly injected scripts), plus denied favicon/file URL; do not relax CSP based on these alone. Need failed endpoint/status/error code and nonsecret Origin/Sec-Fetch-Site. Investigation hypothesis: same-origin fetch mode plus no-referrer policy may serialize POST Origin as null; confirm wire headers before changing request policy. **M1 blocked, not signed off.** No corrective firmware change for this report yet.
|
||||
|
||||
- **8D.3 live cutover implemented / host-tested / build-verified (2026-09-05), M1 target pending:** Resumed another agent's uncommitted completed server/browser implementation; preserved it, verified installed IDF header/upgrade semantics, and fixed right-aligned pending-buffer cleanup with an actual-IDF-reader regression. Cookie login/logout is live, Basic/cache removed. Final build **95,508 B RAM / 1,625,689 B flash** (+248 / +23,764 B versus 8D.2). See `docs/phase8d3_implementation.md`. This supersedes older inert/planned statements below. **Stop for M1 target/browser sign-off before 8D.4; numeric reserve gates remain open.**
|
||||
|
||||
- **8D.3 inert login renderer completed (2026-09-05):** User requested continuation after the parser split. Added standalone `web_login_ui.{c,h}` plus production-renderer/Node tests; no live route or Basic-auth change. Build and focused suites pass; 8D.3/M1 remains incomplete. Remaining work is the atomic server/app cutover, then mandatory browser/target validation. See active task below.
|
||||
|
||||
- **8D.3 preparatory parser split (2026-09-05):** User requested continuation. Per the plan's 600–800-line scope review, selected inert private request parsing before the larger atomic login/logout cutover. `src/web_auth_parse.{c,h}` and focused host tests added; no live HTTP callers or authentication changes. See active task below. 8D.3/M1 is **not complete**; Basic remains active. Prior 8D.2 user sign-off stands; numeric reserves remain open.
|
||||
|
||||
- **8D.2 validated by user sign-off (2026-09-05):** User explicitly closed 8D.2 after post-flash boot and full-client-mix samples. This supersedes older target-pending/in-progress statements below. Evidence and limits are in `docs/phase8d2_implementation.md`; unrecorded regression details are not claims of execution or blockers to the signed-off checkpoint. Numeric reserves remain open. Wait for a separate 8D.3 request.
|
||||
|
||||
- **8D.2 target samples received:** User reports post-flash clean boot and full client mix at 115200 baud, recorded in `docs/phase8d2_implementation.md`. Loaded free internal/DMA/PSRAM **39,200 / 31,444 / 8,138,284 B**, minima **18,784 / 11,028 / 8,109,712 B**, SSH stack minimum-free **16,296 B**. Four broker clients confirmed, SSH writer; no reported SSH I/O or web failures. One cumulative SSH broker revocation and an initial console usage error retained without diagnosis. Target validation is in progress, not signed off; lifecycle/revocation/soak/cleanup evidence remains pending. No 8D.3 request.
|
||||
|
||||
- **8D.2 implemented / host-tested / build-verified (2026-09-05), target regression pending:** User requested 8D.2 only after 8D.0/8D.1 sign-off, with numeric reserve gates still pending. Session-bound serial tickets/slots, session-specific cleanup, and account/global store invalidation are implemented; Basic remains the only public auth route. This supersedes older next-task and dormant-ticket-binding statements below. See `docs/phase8d2_implementation.md`. Stop before 8D.3.
|
||||
|
||||
- **8D.1 validated by user sign-off (2026-09-05):** Following boot/full-client-mix samples, the user reports a long-lasting command with output at full **115200-baud line speed and no dropped broker packets**, and explicitly closes 8D.1 validation. This supersedes older in-progress/not-target-verified statements below. Detailed duration/command/byte totals were not supplied; do not invent them or reopen the signed-off phase solely for unrecorded regression details. Numeric reserve gates remain open; cookie authentication remains dormant/planned. Stop before 8D.2 until requested. See `docs/phase8d_baseline.md` for evidence and sign-off.
|
||||
|
||||
- **8D.1 target validation in progress (2026-09-05):** User supplied fresh-boot (UART stopped) and full-client-mix (115200 baud, SSH writer, admin SSH, USB and two web observers) samples, recorded separately in `docs/phase8d_baseline.md`. Loaded internal/DMA/PSRAM free bytes **41,420 / 33,664 / 8,162,824**, minima **19,384 / 11,628 / 8,115,028**; SSH stack minimum-free **16,288 B**. No reported SSH I/O or web failures; one cumulative SSH broker revocation retained without diagnosis. These supersede older statements below that no target samples exist, but are not full 8D.1 sign-off. Exact changed-firmware hash, lifecycle/HTTPS restart and soak/cleanup coverage remain pending. No continuation to 8D.2 requested.
|
||||
|
||||
- **8D.0 sign-off (2026-09-05):** User marked the baseline validated and identified tested source as latest checked-in revision `d8999cd4a96e477fabd392ced02d810c3cd22d0f`. Current samples and authoritative sign-off are in `docs/phase8d_baseline.md`; they supersede older baseline measurements/provenance notes below. User attributes SSH I/O errors to out-of-spec **460400-baud** testing, distinct from transcript UART configuration **460800**. Numeric reserve approval and 8D.1 post-change target validation remain pending; do not reopen 8D.0 solely for unrecorded checklist details or claim those checks were executed.
|
||||
|
||||
Based on checked-in source plus `README.md` and `docs/roadmap.md`:
|
||||
Based on the current uncommitted worktree plus `README.md` and `docs/roadmap.md`:
|
||||
|
||||
- Hardware characterization, serial service, session broker, USB CDC, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and local display/control are implemented and documented as target-hardware validated.
|
||||
- Phase 8A role-based user storage/UART0 administration and Phase 8B role-aware HTTPS/SSH authentication and targeted revocation are documented as target-hardware validated.
|
||||
- Phase 8C admin SSH is implemented in source, uses the shared `esp_console` registry, and has passed target-hardware validation.
|
||||
- Phase 8D.0–8D.6 and M1 are validated by user sign-off; 8D.7 implemented scope is user-validated and M2 explicitly signed off on 2026-09-07. Browser administration retains deferred parity restrictions; numeric reserves/stack margins remain unapproved. Follow `docs/phase8d_plan.md`: next is separately requested 8D.8 read-only settings entry and Serial page, with no implementation authorized by sign-off alone. Changing terminal modes must preserve the browser serial broker client and any writer lease. The roadmap retains the full end-state requirements.
|
||||
- Phase 8D is in progress. Explicit browser sessions, exact-session serial/admin WebSocket binding, one browser frontend for the canonical admin dispatcher, typed serial controls, guided user CRUD/roles/passwords/authorized keys, generation-safe Wi-Fi profile/AP/secret editing and save, display-aging operations serialized with console writers through `admin_command_gate`, contextual client/status popovers, atomic writer transfer, and serialized retry-safe HTTPS lifecycle handling are implemented in the current worktree. All current Phase 8D target-hardware validation remains pending.
|
||||
- Security/production hardening, OTA, BLE evaluation, advanced networking, and optional filesystem features remain future roadmap work.
|
||||
- Reserved OTA, coredump, NVS-key, and storage partitions do not imply those runtime features are implemented.
|
||||
|
||||
@@ -123,86 +20,38 @@ Based on checked-in source plus `README.md` and `docs/roadmap.md`:
|
||||
- The 5,360-byte transactional user-database candidate now prefers PSRAM with internal fallback while the live database remains internal. Candidate contents are wiped after each transaction and wiped/freed on initialization or recovery failure.
|
||||
- UART and admin-SSH completion formatter buffers were reduced from 2 KiB to 1 KiB each; current worst-case output is 890 bytes and overflow remains fail-closed.
|
||||
- Linked RAM fell from 99,508 to 92,188 bytes (7,320 bytes). PSRAM placement of serial payloads additionally removes about 24 KiB of normal internal-heap pressure on the target.
|
||||
- `pio run` passes. A preliminary target run reports significantly more free memory and stable, improved operation after these changes. This is useful evidence but not completion of Phase 8C validation.
|
||||
- The reviewed mDNS-enabled build uses 94,532 bytes of linked static RAM, 2,344 bytes above the earlier 92,188-byte baseline, and 1,599,765 bytes of flash. Minimizing the managed component saved 112 bytes of linked RAM and about 5.9 KiB flash versus the first mDNS build. Its 4 KiB task stack remains internal, while checked-in settings move general mDNS allocations to PSRAM and disable unused browse, component CLI, AP/ETH, and multiple-instance features. Runtime heap impact still requires target measurement.
|
||||
- Remaining targeted checks include stored/migrated/recovered user-database mutations, USB enumeration, HTTPS start/stop failure recovery where injectable, SSH initialization/login, completion display, and sustained multi-transport serial traffic while checking `memory` telemetry.
|
||||
- The final current Phase 8D build footprint is 119,860 bytes linked static RAM and 1,715,753 bytes flash. Runtime heap/stack/socket headroom requires target measurement.
|
||||
|
||||
## Clearly incomplete or transitional areas
|
||||
|
||||
- Phase 8C hardware validation passed, including route separation, shared command serialization, history/completion, prompts, output backpressure, revocation during queued work, deferred SSH lifecycle/reboot actions, and full concurrent transport operation. At 460800 baud with SSH and WebSocket clients in parallel, substantial packet drops and slow display controls were observed under load, without memory exhaustion; no baud-rate reduction is planned.
|
||||
- Current HTTPS UI retains status/serial for both roles and exposes an admin-only selector in 8D.6. The 8D.7 policy permits bounded other-account mutations, deferred stop/reboot and certificate rotation; unsupported self/generated/key/legacy-credential and other owner-specific paths remain restricted.
|
||||
- Browser authentication uses cookie login/logout without Basic fallback. M1 and 8D.4–8D.6 are signed off; 8D.7 implemented scope is user-validated and M2 explicitly signed off on 2026-09-07. Full parity is deferred, numeric reserves/stack margins remain unapproved, and no new implementation is authorized.
|
||||
- NVS encryption, secure boot/flash encryption review, production certificate/provisioning policy, and OTA are not implemented. HTTPS login has a bounded global five-verifications/60-second throttle, not comprehensive cross-transport DoS protection.
|
||||
- The browser Settings dialog covers complete serial framing/lifecycle/persistence controls, Wi-Fi lifecycle/profile rotation, guided user/password/role/authorized-key management, generation-safe Wi-Fi profile/AP/secret editing and exact-generation save, and display-aging apply/save/load/defaults/reset. Broader service/session controls, network diagnostics, security/danger operations, and unusual hardware/debug commands remain shell-only; do not describe them as guided forms.
|
||||
- The four-entry source-address login limiter is deliberately bounded and modest: oldest-entry replacement prevents limiter-induced global denial of service, but distributed/rotating-source attacks remain Phase 9 hardening work.
|
||||
- NVS encryption, secure boot/flash encryption review, comprehensive authentication lockout/rate limiting beyond the bounded browser-login limiter, production certificate/provisioning policy, and OTA are not implemented.
|
||||
|
||||
## Known inconsistencies
|
||||
|
||||
These observations should be checked when touching the relevant area; they are not automatically bugs requiring unrelated cleanup.
|
||||
|
||||
- Some source comments still call shared commands UART0-only or call the current local status/control task read-only.
|
||||
- Legacy-removal module contracts now make `USER_DATABASE_LOAD_EMPTY` a successful persisted-empty initialization result; older migration/sentinel observations are superseded. Final startup integration/build evidence is pending.
|
||||
- `USER_DATABASE_LOAD_EMPTY` is only an initialization/failure sentinel at the checked-in revision: every successful `user_database_init()` path returns `STORED` or `MIGRATED_LEGACY`, so `main.c`'s successful "new empty" log branch is unreachable.
|
||||
- SSH startup is currently gated on successful `web_security` initialization even though SSH uses separate host-key material. **Needs verification:** whether this coupling is intentional recovery policy or an accidental startup dependency.
|
||||
|
||||
## Items to verify in future work
|
||||
|
||||
- Confirm task-local Newlib standard-stream behavior if ESP-IDF/Newlib configuration changes; admin SSH command output relies on dispatcher-task stream redirection.
|
||||
- Re-audit the private HTTPD adapter on SDK changes (including same-version patches): parsed-header layout, right-aligned pending data, explicit handshake/frame installation and log suppression. Host tests do not establish real socket behavior.
|
||||
- If HTTPD changes from one handler task to concurrent request execution, protect or eliminate the shared 4 KiB typed-response scratch and audit all handler-local single-owner assumptions.
|
||||
|
||||
## Completed Task - Phase 8D.3 Live Authentication Cutover
|
||||
## Active Task
|
||||
|
||||
- **Inherited implementation:** `web_cookie_auth.{c,h}`, `web_httpd_adapter.{c,h}`, parser optional-cookie validation, server/console/transport integration, browser app/session recovery, and focused suites. No Basic authorization/cache remains. Four one-hour sessions, four 120-second pre-login challenges, five password checks per 60-second window, four non-evicting 30-second tickets/two serial sockets. Fourteen URI slots, unchanged six HTTPS sockets and application task/stack/queue capacities. No generated assets or SDK files changed.
|
||||
- **HTTPD decision:** Other agent chose an isolated **private-IDF adapter**, not the previously proposed SDK patch. Verified first-only header getters, append-only pointer-backed Set-Cookie (six-header successful login), auto-101-before-handler flow and private frame installation against installed 5.5.0. Serial URI uses ordinary GET until authenticated ticket/currentness/broker admission, then explicit handshake. Exact version guard requires re-audit on update; not a source-hash guarantee. HTTPD logs above ERROR compiled out to avoid secrets/ticket queries. Durable boundary recorded in architecture/design decisions and implementation record.
|
||||
- **Fix in this continuation:** Pending HTTPD bytes are right-aligned. Inherited wipe preserved the wrong end, risking pipelined HTTP/early-frame corruption. Fixed consumed-prefix wipe and tested 0–128 pending lengths plus partial reads using extracted installed `httpd_recv_pending`. Kept all other inherited source work intact.
|
||||
- **Validation:** `python3 tests/web_cookie_auth/run.py`, parser 268-case suite, login UI eight Node groups, serial app nine Node groups, and store `--serial` integration mode pass. Cookie suite also executes store tests and extracts installed header getter/setter/pending-reader functions; handshake/network/tasks remain doubled. Final `pio run` passed **17.62 s**, **95,508 B RAM / 1,625,689 B flash**; +248/+23,764 B versus 8D.2, +976/+25,716 B versus recorded 8D.0. Auth symbols 637 B before placement padding (including 576 B challenges); Basic cache/key removal offsets much of it. No runtime reserve or stack margin inferred.
|
||||
- **Handoff (updated at sign-off):** `docs/phase8d3_implementation.md` contains policy, source verification, accounting, user-provided login/mixed-client/post-soak evidence and explicit **M1 closure on 2026-09-06**. No agent device operation or new build was performed to record sign-off. Earlier missing-check notes are evidence limitations/regression coverage, not blockers to this closure. Numeric reserves remain open. Next implementation is **8D.4 only when separately requested**; preserve prior sign-offs and the distinction between connected post-soak data and disconnected cleanup.
|
||||
|
||||
## Previous Task - Phase 8D.3 Inert Login Rendering
|
||||
|
||||
- **Scope:** Second permitted preparatory split after scope review of remaining challenge/throttle/HTTP-route/application work. Added only `src/web_login_ui.{c,h}`, CMake registration, `tests/web_login_ui/` and documentation. Standalone renderer has no live HTTP caller or URI registration; Basic cache/auth, existing app and serial protocol, capacities, task stacks and generated assets are unchanged. No upload, erase, commit or branch change.
|
||||
- **Behavior when integrated:** No fetch on page load; explicit Sign in obtains challenge with `X-Login-Bootstrap: 1`, then POSTs JSON with CSRF. Same-origin credentials/mode, no-store fetch, redirect rejection, 512-byte response bound and UTF-8 field/body limits. Generic safe-text errors, bounded Retry-After display/manual backoff, no automatic credential retry, fixed success navigation to `/`. Inputs disabled while pending; password fields/references cleared best-effort, attempt aborted on every exit, 15-second deadline, pagehide/pageshow generation guards. No localStorage/cookie access or logging; JavaScript/browser memory cannot be securely wiped.
|
||||
- **Policy/resources:** Standalone 7,387-byte HTML document plus terminator in its object; no protected asset dependencies or template interpolation. Five additional headers (no-store, nosniff, no-referrer, frame denial, matching SHA-256 CSP), below existing eight-header limit. Rendering itself allocates no module heap/session/task. Unused renderer leaves final linked RAM/flash **95,260 / 1,601,925 B**, unchanged versus 8D.2 and parser split. Future linked/read-only data, HTTPD send/TLS and runtime stack/heap costs remain cutover measurements; no runtime reserve approval implied.
|
||||
- **Validation:** `python3 tests/web_login_ui/run.py` passes production-C NULL/header/send-failure checks, exact rendered CSP hash and eight Node VM behavior groups. `python3 tests/web_auth_parse/run.py` passes 268 cases; existing store and `--serial` host modes pass. Independent review caught/fixed unread error-body lifetime and re-entered password retention; regression tests cover abort signals/modeled streams and pending-input cleanup. Final `pio run` passed in **8.25 seconds**, unchanged sizes. `git diff --check` passed. No real browser/CSP enforcement/bfcache, HTTPD sockets, TLS or target exercise.
|
||||
- **Next exact work:** Continue **8D.3 atomic live cutover**, not more login rendering or 8D.4. Reuse prepared parser/renderer/store/serial binding. Implement bounded challenge/throttle and complete cookie/session/CSRF/Origin policy, replace Basic for every app/asset/status/ticket/upgrade route together, add `/login`, challenge/login/session/logout routes, and integrate existing application's session validation/logout/401 recovery without changing explicit serial reconnect. Retain no-live-eviction ticket policy cutover, fail-closed initialization and account/session-specific revocation. Duplicate-header detection and two Set-Cookie behavior must be verified against actual IDF HTTPD, not assumed. Keep response header/route/body budgets explicit. Then M1 target/browser pause and resource evidence; no M2 work.
|
||||
- **Remaining gates:** Full M1 checklist in plan/baseline applies only once live: both roles/fresh and former Basic profiles, direct IP/mDNS, challenge/session/capacity/throttle/Origin/CSRF errors, logout/account switch/expiry/reboot/revocation isolation, repeated serial cycles, UART0/USB/user+admin SSH and soak/cleanup memory. Numeric reserves remain pending; 8D.2 user sign-off stands.
|
||||
|
||||
## Previous Task - Phase 8D.3 Inert Request Parsing
|
||||
|
||||
- **Scope:** Private allocation-free helpers for canonical Host/Origin comparison, unique named 64-lowercase-hex cookie extraction, and strict login JSON. CMake registration plus `tests/web_auth_parse/`; no live route, UI, store, transport, task, socket, stack-size or generated-asset change. Split before coding because full challenge/throttle/routes/UI/tests cutover exceeds the work-unit guideline. No commit, upload or erase.
|
||||
- **Contracts:** Exact bounded byte spans; canonical origin max 128 bytes after optional default-port normalization, ASCII DNS/IPv4 authorities only (IPv6 rejected), mandatory matching HTTPS Origin. Cookie header max 1024 bytes, strict unquoted values even for unrelated cookies. Login body max 512 bytes, exactly username/password strings, decoded limits 16/64 bytes; proper UTF-8/JSON escape/surrogate validation. Unknown/duplicate fields and NUL rejected. Database policy remains authoritative. Failures clear complete outputs; successful credentials and original body require caller wiping. No dynamic allocation or persistent state.
|
||||
- **Validation:** Focused production-C host suite, existing store and `--serial` modes passed. Independent review found no memory-safety defect; default-port maximum-length normalization was fixed and regression cases added. Final rerun/build results recorded in the plan. Host tests are not real HTTPD/browser, sanitizer or target validation.
|
||||
- **Resources:** Build reports **95,260 B linked RAM / 1,601,925 B flash**, unchanged from 8D.2. Unused helpers are not live firmware paths; their future linked flash and request-stack cost must be measured at cutover. No new runtime allocation, task, route, queue or socket. Numeric reserve gates remain pending.
|
||||
- **Next exact work:** Continue 8D.3, not 8D.4: wire the complete challenge/session/login/logout/CSRF/Origin policy and browser recovery atomically, or first take the permitted inert login rendering split if the remaining change still exceeds scope. Never expose partly protected cookie routes. Integration must enforce duplicate HTTP header rejection, bounded complete body reads, content type/method/Fetch Metadata checks, and wipe request buffers on every exit. Bootstrap's permitted missing-Origin GET requires separately validated Host handling, not weakening mutation/upgrade Origin checks. Keep account validation tied to existing database capacities. Remove Basic only with the complete security cutover, then stop for mandatory M1 browser/target sign-off.
|
||||
- **Target checks:** No hardware execution for this inert split. On the live cutover run the full M1 checklist in `docs/phase8d_plan.md` and `docs/phase8d_baseline.md`: both roles, usable errors/logout/account switching, expiry/reboot/revocation/isolation, direct IP/mDNS, capacity/throttle/CSRF, serial reconnect, UART0/USB/user+admin SSH and repeat-cycle/soak memory evidence. No browser-authentication success is claimed here.
|
||||
|
||||
## Previous Task - Phase 8D.2 Serial Session Binding
|
||||
|
||||
- **Checkpoint/scope:** Clean `93eef0e67641f2672c56692a7785e50f31cf236d`; only `web_serial_transport.{c,h}`, `web_session_store.{c,h}`, one Basic ticket call in `web_server.c`, focused host tests and documentation changed. No upload/erase/commit or generated assets. Existing `user_console` and legacy-sync callers already reach the extended revocation hook.
|
||||
- **Implementation:** Non-reused 64-bit originating IDs in four tickets/two slots, zero explicitly Basic-only. Matching-ID upgrade and exact principal binding, expiry/currentness at sensitive boundaries and existing periodic owner checks. Session-specific invalidation/cleanup, account-name/all-session invalidation even if serial init failed, reserved-slot revocation and non-wrapping ticket publication epoch. No new task, heap allocation, route, capacity or stack-size change; Basic cache/currentness retained.
|
||||
- **Validation:** `python3 tests/web_session_store/run.py` and `python3 tests/web_session_store/run.py --serial` pass using OpenSSL SHA-256 and deterministic dependency interleavings. Integration exercises production transport private steps with the real store, not real HTTPD sockets/tasks. Expanded checks cover isolation, mismatched identity fields, expiry/lost notifications/DB failure, close-queue retry, slot reuse and mint/admission races. Optional `--serial --sanitize` cannot link because this host lacks ASan/UBSan runtime libraries and static archives; not a pass.
|
||||
- **Build/resources:** Final source `pio run` passed in 7.92 seconds at **95,260 B RAM / 1,601,925 B flash**, **+56 / +1,420 B** versus 8D.1 and **+728 / +1,952 B** versus 8D.0. Target symbol/DWARF: ticket 96 B x 4 (+32 B array), slot 1,664 B x 2 (+16 B array), new epoch 8 B; store remains 664 B. Bound resolver frame 192 B excluding callees; no runtime stack/heap reserve claim.
|
||||
- **Target/handoff:** `pio device list` sees device CDC and USB serial adapter, but this diff was not flashed or exercised on target. Follow `docs/phase8d2_implementation.md` for two-role Basic five-cycle regressions, mutation isolation, five HTTPS restarts, USB/user+admin SSH/UART0 smoke, 15-minute 115200-baud full mix and 60-second cleanup/memory evidence. Numeric floors remain pending. Cookie-route session logout/expiry/origin validation remains dormant until separately requested 8D.3; obtain target sign-off or explicit user decision before stacking it. No later phase work performed.
|
||||
|
||||
## Previous Task - Phase 8D.1 Internal Session Primitives
|
||||
|
||||
- **Objective:** Phase 8D.1 only. User explicitly requested continuation with remaining reserve/validation gates pending, then supplied a second pre-change full-client-mix sample. No browser auth cutover or continuation to 8D.2.
|
||||
- **Context:** The user reports that the earlier experimental implementation was moved to a separate branch and `devel` rolled back after repeated agent interruptions, a roughly +10k/-1k-line diff, broken browser login, and severe memory pressure. These symptoms have not been independently diagnosed. Do not import that branch wholesale.
|
||||
- **Changes completed:** Added `src/web_session_store.{h,c}`, CMake registration and small admitted-HTTPS-start/stop hooks in `web_server.c`. Four static digest-only sessions, copied principals, separate CSRF/origin state, absolute expiry, generation-safe IDs/epochs, invalidation/prune and count-only snapshot APIs. Database/crypto outside portMUX, post-call revalidation. No issuing HTTP callers, ticket/socket/revocation binding, new routes/tasks, UI change or regenerated assets. Added focused `tests/web_session_store/` runner and updated baseline/plan/architecture/code map.
|
||||
- **Baseline:** Clean `devel` at `af89dd1bd96cdd97d8d57eee7a29f68e3874506b`, PlatformIO espressif32 6.12.0 / IDF 5.5.0, N16R8 release. `pio run` passed in 36.08 seconds: 94,532 B linked RAM and 1,599,973 B flash (historical RAM unchanged, flash +208 B, cause not diagnosed). `pio device list` returned no ports. No agent upload or target/browser test was performed. The user subsequently supplied a runtime heap baseline; stack measurements remain pending.
|
||||
- **User-provided runtime memory baseline (2026-09-05):** Administrative SSH transcript with one browser serial writer (broker 8), one public-key user SSH observer (broker 9), and one public-key admin SSH session; web 1/2, SSH 2/2, zero tickets, no USB broker client listed. Heap bytes in free/minimum-free/largest-block order: internal 8-bit **39,668 / 13,876 / 18,432**; internal DMA **31,912 / 6,120 / 18,432**; PSRAM **8,138,380 / 8,107,324 / 7,995,392**. Accepted as the runtime baseline, not reserve floors or full-workload validation. Flashed revision, baud, duration, traffic and stack margins were not supplied. See `docs/phase8d_baseline.md` for provenance and remaining checks.
|
||||
- **Follow-up pre-change full-client-mix sample:** Two web sessions (broker 8 writer, 10 observer), USB broker 11 observer, user SSH broker 9 observer and admin SSH; four broker clients, SSH 2/2, web 2/2, no tickets. Internal 8-bit **41,868 / 13,876 / 18,432 B**; DMA **34,112 / 6,120 / 18,432 B**; PSRAM **8,162,784 / 8,107,324 / 7,995,392 B** (free/minimum/largest). Cumulative web/SSH traffic with no reported transport I/O/protocol/queue failures; one web auth failure and two rejected SSH RX bytes retained without diagnosis. This is not timed-soak or byte-integrity evidence; see baseline record.
|
||||
- **8D.1 validation/accounting:** Started at clean `02fdeee3453654680c11096c9a6224c26233eced`. `python3 tests/web_session_store/run.py` passes with OpenSSL SHA-256, deterministic dependency doubles and injected callback races; no hardware or real multicore scheduling claim. Final `pio run` passed in 8.90 seconds: **95,204 B RAM (+672 B)**, **1,600,505 B flash (+532 B)**. Target object/DWARF: record 152 B × four, state 656 B + lock 8 B = **664 B**, no module heap/new task/socket/route cost, Basic cache retained. Only init/stop currently survive firmware linker GC; full primitive flash/stack costs become relevant when future callers are added. Independent review found/fixed store reinit by rejected concurrent start. Post-change target run pending.
|
||||
- **Planned contract decisions:** Four one-hour absolute sessions without idle/sliding renewal, four 120-second pre-login CSRF challenges, four 30-second serial tickets, no live-record eviction and no Basic compatibility after cutover. Strict same-origin mutation/upgrade checks, session-specific logout versus account-wide revocation, bounded global login throttle. These are functional choices, not measured memory budgets or implemented behavior.
|
||||
- **Next:** Stop at 8D.1. Obtain post-change Basic browser/serial/USB/user+admin SSH/UART0 regression and comparable `memory`/counters/`ssh status` evidence; five serial disconnect/reconnect cycles per role and HTTPS stop/start checks. Timed soak/cleanup and reserve floors remain pending. Next implementation is 8D.2 session-specific ticket/socket binding, only after checkpoint or explicit user decision; M1/M2 pauses remain. Host tests cover dormant primitives, not browser logout/currentness integration. ID/epoch exhaustion, collision rejection and private memory wiping are source-reviewed only.
|
||||
- **Risks / things to remember:** Six HTTPD client slots include WebSockets; HTTPD infrastructure plus HTTPS and SSH can consume twelve of sixteen descriptors before other services/transients. Existing LRU purge is enabled and must not evict retained serial to admit M2 admin sockets. Nine current route slots have no spare capacity; the proposed M1 contract adds five. Cookie sessions, tickets, sockets and request scratch are separate costs. Only SSH exposes stack margin today. Numeric runtime floors and per-chunk/cumulative budgets remain pending, not guessed.
|
||||
|
||||
## Previous task — mDNS (target checks still pending)
|
||||
|
||||
- **Objective:** Announce a configurable `sak-<suffix>.local` hostname through mDNS when Wi-Fi STA has an IPv4 address, without changing the Wi-Fi NVS blob schema.
|
||||
- **Relevant files:** `src/mdns_config.{c,h}`, `src/mdns_service.{c,h}`, `src/mdns_console.{c,h}`, `src/wifi_manager.{c,h}`, `src/main.c`, `src/CMakeLists.txt`, `src/idf_component.yml`, `dependencies.lock`, completion and command documentation.
|
||||
- **Findings:** `wifi_manager` already serializes all meaningful STA transitions through its permanent task; callbacks only enqueue events. This is the appropriate lifecycle owner for mDNS, while a separate configuration module preserves the existing `wifi_app/config` wire format.
|
||||
- **Decision:** Persist a fixed v1 record under `mdns_cfg/config`, separate from Wi-Fi configuration. Defaults derive a safe lower-case hexadecimal suffix from the STA MAC. The manager initializes mDNS at most once after validating `IP_EVENT_STA_GOT_IP`; the managed component's own handlers withdraw/restore the STA responder across connectivity changes, and online hostname changes use `mdns_hostname_set()` without teardown. Initialization failure is latched instead of retried because the resolved upstream 1.12.0 component has an unsafe partial low-memory initialization path. mDNS errors cannot fail Wi-Fi, UART0, UART1, or native USB.
|
||||
- **Changes completed:** Added the `espressif/mdns` managed dependency (resolved to 1.12.0 on IDF 5.5), mDNS config/service/console modules, `mdns status|suffix|save|load|defaults|reset`, completion, CMake integration, and command/architecture documentation. Minimized the component to STA-only responder use, moved general allocations to PSRAM, retained the internal task stack, and removed reconnect-time free/reinit churn. Final `pio run` passes at 94,532 bytes linked RAM and 1,599,765 bytes flash.
|
||||
- **Remaining work:** Target-hardware verification: associate a station and resolve the default `sak-<mac>.local`; change/save/load a suffix and confirm live reannouncement plus reboot persistence; stop Wi-Fi or remove the STA lease and confirm the record withdraws. Verify serial, native USB, and UART0 remain available if mDNS initialization fails.
|
||||
- **Risks / things to remember:** Hostnames are STA-only and are intentionally not announced by fallback AP mode. NVS changes to `mdns_cfg/config` are independent of the unchanged `wifi_app/config` blob. mDNS remains allocated after first successful initialization (including its internal 4 KiB task stack) to avoid fragmentation and unsafe repeated initialization; measure free/minimum/largest internal heap and mDNS stack margin during reconnect stress.
|
||||
- **Objective:** Finish documentation and target-hardware validation for the current Phase 8D integrated web-administration implementation without weakening broker, recovery, command-dispatch, concurrency, or secret-handling invariants.
|
||||
- **Relevant files:** `src/user_admin_service.{c,h}`, `src/web_session.{c,h}`, `src/web_server.{c,h}`, `src/web_serial_transport.{c,h}`, `src/web_admin_transport.{c,h}`, `src/web_ui.{c,h}`, `src/admin_ssh_console.{c,h}`, `src/wifi_manager.{c,h}`, `src/local_ui_config.{c,h}`, `src/local_ui_console.c`, `src/local_status_ui.{c,h}`, `src/ssh_transport.c`, `src/session_broker.{c,h}`, and Phase 8 documentation.
|
||||
- **Findings:** User web mutations need both database generation and stable user ID, while Wi-Fi edits/save need one exact working-config generation. Revocation is post-commit best effort, with principal currentness authoritative. HTTPD stop/finalizer failures require retained ownership and retry rather than unsafe reuse. Browser stale-conflict handling must clear secrets and reload without replay.
|
||||
- **Decisions made:** Route typed user mutations through `user_admin_service` and serialize them with `admin_command_gate`. Serialize typed display Apply/Save/Load/Defaults/Reset with console display writers through the same gate. Use full-config Wi-Fi compare-and-swap and exact-generation save, expose only `secret_set`, and fail closed on generation conflict/exhaustion. Serialize HTTPS lifecycle with generation-tagged intent and require post-material TLS refresh. Keep service/session controls, network diagnostics, security/danger operations, and unusual hardware/debug commands in the Admin shell rather than adding guided forms.
|
||||
- **Changes completed:** In addition to the browser-session/admin-shell/serial/client foundation, implemented guided user CRUD/roles/entered and one-time generated passwords/authorized Ed25519 and P-256 key add/remove, typed Wi-Fi profile/AP/secret editing and save, typed display aging serialized with console writers, secret clearing/stale reload behavior, centralized post-commit revocation, bounded 512-byte/10-field in-place form parsing, and serialized retry-safe HTTPS teardown/TLS refresh. The final current build footprint is 119,860 bytes linked static RAM and 1,715,753 bytes flash.
|
||||
- **Remaining work:** Run every current Phase 8D target-hardware procedure in `docs/user_administration_tests.md`; none is yet recorded as passed.
|
||||
- **Risks / things to remember:** Measure internal free/minimum/largest blocks and web-admin task stack margin under maximum HTTPS/SSH/serial concurrency. Exercise revocation-hook failure, Wi-Fi/user stale editors, self-revocation/final-admin protection, duplicate-key scope, secret clearing, display validation, failed HTTPD stop, and retryable post-stop finalization. The login limiter is bounded defense-in-depth, not strong distributed attack protection. Do not move browser admin through the broker or auto-release its preserved serial writer lease.
|
||||
|
||||
### Handoff template
|
||||
|
||||
|
||||
@@ -2,14 +2,6 @@
|
||||
|
||||
Only constraints supported by implementation or current project documentation belong here. When original rationale is unknown, the entry describes the observable constraint without inventing intent.
|
||||
|
||||
## Typed Network edits preserve manager ownership and current secret bytes
|
||||
|
||||
**Decision (8D.12/8D.13):** `web_network_settings` admits bounded typed operations into one login-bound slot; the existing dispatcher carries IDs only and calls canonical generation-checked Wi-Fi/mDNS APIs. HTTPD reads only zero-wait secret-free projections. Wi-Fi mutex-local compare/merge/validation preserves omitted PSKs and prevents stale edits undoing CLI/local changes; queue admission precedes RAM publication. Save stabilizes selected bytes under the mutex; browser Wi-Fi Load reads stored configuration only, never generates fallback AP secrets. mDNS uses its own generation and reports RAM-applied/reannouncement-not-queued separately.
|
||||
|
||||
**Consequence:** Keep SSIDs byte-reversible (UTF-8 text must first become bytes; arbitrary existing bytes require hex), password omission/Replace/disabled-STA Clear distinct, and AP clear denied even while off. No default/reset/secret-export route or explicit-index connection selection: only canonical Next. One-second timer/30-second queued expiry plus scheduling latency is not hard cancellation of admitted work. `accepted` is owner admission, not online or verified DNS; acknowledgements may be lost before network disruption. Recovery/confirmation and no automatic mutation replay are correctness requirements, not UI polish. Optional route failures must not gate unrelated services; browser-shell policy remains separate. No task/stack/queue/schema expansion; new timer/slot/buffer costs still require target heap and HTTPD/dispatcher margin evidence.
|
||||
|
||||
**Relevant files and full contract:** `src/web_network_settings.{c,h}`, `src/wifi_manager.{c,h}`, `src/mdns_service.{c,h}`, `src/web_ui.c`, `docs/phase8d12_13_implementation.md`.
|
||||
|
||||
## One broker mediates all production serial transports
|
||||
|
||||
**Decision:** USB CDC, WebSocket, and role-`user` SSH access UART1 through `session_broker`; transports do not independently own the serial service.
|
||||
@@ -42,23 +34,21 @@ Only constraints supported by implementation or current project documentation be
|
||||
|
||||
## Resource IDs are generation-safe
|
||||
|
||||
**Decision:** Broker clients, SSH/WebSocket slots, queued admin work, and user principals carry generations or random stable IDs to reject stale references and slot reuse.
|
||||
**Decision:** Broker clients, SSH/WebSocket slots, queued admin work, user principals/accounts, Wi-Fi working configuration, and HTTPS lifecycle intent carry domain-specific generations or random stable IDs to reject stale references, slot reuse, and lost updates.
|
||||
|
||||
**Rationale/evidence:** Broker IDs encode slot generation; transports track slot generations; admin tokens include session/slot generation; user principal currentness includes account ID and authentication generation.
|
||||
**Rationale/evidence:** Broker IDs encode slot generation; transports track slot generations; admin tokens include session/slot generation; user principal currentness includes account ID and authentication generation; user/Wi-Fi web editors carry optimistic generations; HTTPS snapshots expose lifecycle generation.
|
||||
|
||||
**Consequence for future changes:** Preserve transport-slot generations and account-authentication generations as distinct concepts. Validate tokens immediately before side effects and discard late work after disconnect/reuse/revocation.
|
||||
**Consequence for future changes:** Preserve transport-slot, account-authentication/database, Wi-Fi working-config, and HTTPS lifecycle generations as distinct concepts. Validate tokens immediately before side effects and discard late work after disconnect/reuse/revocation. Existing-account web mutations must also compare stable user ID so deletion/recreation of the same username cannot retarget stale work.
|
||||
|
||||
Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs in serial tickets/slots. 8D.3 rejects zero IDs; Basic authentication/cache are removed. Session-specific cleanup must not become account-wide cleanup; account-name notification intentionally covers deletion/recreation. Invalidate cookie records before requesting transport cleanup, and retain authoritative session/principal checks when notification fails. The transport epoch cancels in-flight ticket publication without taking store and transport locks together.
|
||||
|
||||
**Relevant files:** `src/session_broker.{h,c}`, `src/ssh_transport.c`, `src/web_serial_transport.c`, `src/admin_ssh_console.c`, `src/user_database.{h,c}`
|
||||
**Relevant files:** `src/session_broker.{h,c}`, `src/ssh_transport.c`, `src/web_serial_transport.c`, `src/admin_ssh_console.c`, `src/user_database.{h,c}`, `src/user_admin_service.{h,c}`, `src/wifi_manager.{h,c}`, `src/web_server.{h,c}`
|
||||
|
||||
## UART0 is the physical recovery authority
|
||||
|
||||
**Decision:** UART0 remains independent of UART1 and networking. The first administrator is created with normal `user add` on UART0; explicit unavailable-user-database recovery to empty is UART0-only and refuses healthy storage. No bootstrap command/API remains.
|
||||
**Decision:** UART0 remains independent of UART1 and networking. Initial administrator bootstrap and explicit unavailable-user-database recovery are restricted to UART0.
|
||||
|
||||
**Rationale/evidence:** `main.c` configures UART0 separately; command policy and user handlers deny these operations remotely. README/roadmap identify UART0 as the trusted recovery console.
|
||||
|
||||
**Consequence for future changes:** Network failures or credential corruption must not remove UART0 recovery. Do not expose unauthenticated first-admin provisioning or recovery through web or admin SSH without an explicit security redesign.
|
||||
**Consequence for future changes:** Network failures or credential corruption must not remove UART0 recovery. Do not expose bootstrap/recovery through web or admin SSH without an explicit security redesign.
|
||||
|
||||
**Relevant files:** `src/main.c`, `src/admin_ssh_console.c`, `src/user_console.c`, `docs/roadmap.md`
|
||||
|
||||
@@ -90,18 +80,10 @@ Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs
|
||||
|
||||
**Consequence for future changes:** Actions that would invalidate their own SSH transport should integrate with deferred control when acknowledgement preservation matters. Prevent new input while an action is pending, keep the wait bounded, and do not describe it as guaranteed delivery.
|
||||
|
||||
Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutable owner adapter on the existing control task, outside console locks. Tokens include a transport namespace; owners revalidate full identity and marshal to their transport APIs. `SELF_CLOSE` targets the invoking frontend while existing SSH action meanings remain unchanged. Unsupported actions must fail before side effects. The two console slots remain a shared bounded pool, with no hypothetical browser capacity allocated. The 8D.5 prerequisite additionally requires owner currentness on the dispatcher, outside console locks, before commands and during prompts; account currentness alone cannot establish originating browser-session liveness. Recheck token identity after external validation, reject revoked submitted replies, and wipe consumed output. Polling is not a hard cancellation deadline and cannot roll back arbitrary handlers; owners retain admission/input/output/lifecycle responsibilities.
|
||||
|
||||
**8D.7 first slice:** WEB also supports reboot and explicit HTTPS stop on the existing control task, with originating-session/principal validation after drain/delay. Stop is service-wide, not admin-socket-only; serial isolation applies to selector/SELF_CLOSE, not explicit HTTPS shutdown. Buffered input observed during deferral is wiped, including an incoming frame whose payload read races cancellation. Keep unsupported identity/credential/network paths blocked until separately implemented; no new executor or delivery guarantee.
|
||||
|
||||
**8D.7 second certificate slice:** Exact parsed browser `web certificate rotate --force` schedules a typed action, not command replay. Use the existing request-queue union and immutable owner `dispatcher_actions` mask to hand off after drain/200 ms to the existing 12 KiB dispatcher: crypto/NVS must not run on the 4 KiB control stack. Preserve queue capacity, pending-input gating through execution, token/principal/session revalidation and executing-slot reservation across self-detach. Zero mask preserves legacy SSH execution. WEB revalidates before transactional certificate commit → stop → start; early errors short-circuit and failed stop retains HTTPD ownership without start. Lifecycle failure after commit does not restore the old identity. Browser trust/relogin and UART0/SSH recovery are explicit operational consequences; USB/SSH are not stopped. Bounded acknowledgement/drain is neither an execution deadline nor receipt proof. No stack-size/task/route expansion; owner mask and local scratch still need target accounting/high-water evidence, not host sizeof assumptions. Other credential/account/network/SSH mutations remain blocked pending bounded owner slices.
|
||||
|
||||
**Relevant files:** `src/admin_ssh_console.c`, `src/system_console.c`, `src/ssh_console.c`, `src/ssh_transport.c`, `src/web_admin_transport.c`, `src/web_console.c`
|
||||
**Relevant files:** `src/admin_ssh_console.c`, `src/system_console.c`, `src/ssh_console.c`, `src/ssh_transport.c`
|
||||
|
||||
## Authentication uses copied principals and fail-safe currentness checks
|
||||
|
||||
**8D.5 web owner extension:** Browser and runtime SSH admission allocate from the same two console slots; a physical SSH slot is not a console index. WEB supports owner-relative self-close only and rejects unsupported network/lifecycle/account mutations at parsed command policy before execution. One web-admin socket and two tickets do not increase six-socket HTTPD capacity; disable LRU rather than evict retained serial clients. The optional owner uses one PSRAM-only payload and ESP timer scheduling, not a new task. HTTPD alone sends/shuts down its verified current fd. Do not use IDF's queued raw-`sock_db *` close from admin polling: free/reuse before that work executes could close a replacement. Detach must fence queue submissions before HTTPD stop; retire queued markers only after successful stop, retaining ownership across failures. No browser UI or generic HTTP command runner is part of this boundary.
|
||||
|
||||
**Decision:** Network sessions retain secret-free copied principals. Account mutations invalidate generations/IDs; after commit, the command layer requests best-effort targeted transport revocation, while ongoing currentness checks are authoritative.
|
||||
|
||||
**Rationale/evidence:** `user_database` issues principals without secrets; web/SSH check currentness during admission and active sessions. Mutating console paths call transport revocation hooks.
|
||||
@@ -110,37 +92,75 @@ Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutab
|
||||
|
||||
**Relevant files:** `src/user_database.{h,c}`, `src/user_console.c`, `src/web_server.c`, `src/web_serial_transport.c`, `src/ssh_transport.c`
|
||||
|
||||
## Typed serial mutations share the administration dispatcher
|
||||
## Browser authentication uses bounded explicit sessions
|
||||
|
||||
**8D.9:** HTTPD performs bounded typed admission/result reads only; serial reconfiguration and NVS execute on the existing dispatcher so CLI commands cannot interleave. One global pending slot rejects concurrent work; copied session/principal plus non-reused ID fence stale queued work. A 30-second deadline is checked on dequeue, not a cancellation timer or execution limit. Admitted mutations may finish after revocation; completed results can be replaced. Keep explicit uncertain-outcome recovery and never automatically retry mutations. Apply/Defaults are RAM-only, Save persists working device state rather than browser drafts, and Reset follows canonical apply/persist/best-effort-rollback ordering. Navigation preserves broker clients/writer lease, while explicit serial reconfiguration can discard serial-service pending data. No generic command runner/job history is exposed. See `src/web_serial_settings.{c,h}` and `docs/phase8d9_implementation.md`.
|
||||
**Decision:** Browser access uses a same-origin login/logout flow and a fixed RAM session table rather than HTTP Basic. Raw opaque tokens exist only in host-only secure cookies; firmware storage retains token digests, copied principals, monotonic expiry, and generation-safe slot identity. State-changing requests require a session-bound CSRF token and exact Origin validation.
|
||||
|
||||
## Typed account selection is checked inside the database mutation lock
|
||||
**Rationale/evidence:** Explicit logout and account switching cannot reliably invalidate browser-managed HTTP Basic credentials. Exact browser-session references also allow logout of one session without revoking another session for the same account.
|
||||
|
||||
**8D.11:** Apply the same conditional target identity contract to authorized-key add/delete/clear, sharing canonical CLI validation/commit paths. Expose fingerprint metadata only through a zero-wait snapshot; never return stored key blobs. Treat key indices as stable, potentially sparse slots, not response-array positions. Listing uses protected JSON POST to reuse bounded target admission, not a new query parser. Public-key import is bounded to 384 decoded text bytes within the existing 768-byte body, with canonical blob/curve validation on the existing dispatcher. Target revocation/self uncertainty and browser-shell restrictions are unchanged. See `docs/phase8d11_implementation.md`.
|
||||
**Consequence for future changes:** Preserve digest-only storage, the two-per-account/eight-global capacity, exact-session WebSocket binding, and current-principal checks. Do not expose tokens, CSRF values, ticket values, or internal references in logs/snapshots. New mutation endpoints must use the body-backed in-place URL-form parser's 512-byte/10-unique-field bounds, parse closed schemas, and revalidate the exact admin session immediately before typed side effects. Browser failure/close paths must clear entered and generated secret material rather than replaying it after a reload.
|
||||
|
||||
**Current 8D.10 slice 2:** Extend conditional identity checks to password replacement; create uses canonical duplicate/capacity/commit policy. Keep generation separate from commit: the protected bodyless generated-value POST returns one transient value, performs no mutation and retains no retrieval state. Browser saved acknowledgement is context-bound UX, not delivery proof or server authorization. Queued credentials require a one-second periodic timer to cancel/wipe non-executing work at the 30-second deadline plus scheduling latency; execution copies then wipes shared inputs, with local wiping after admitted database work returns. Neither timer nor logout cancels admitted commits. Self password/role/delete uses immediate canonical target revocation, not deferred acknowledgement: 401/disconnect is uncertain and requires relogin/inspection before any explicit retry. Generation is independently optionally registered, preserving failure isolation and restart behavior at 23 handlers. No shell restriction change, secret result/history, new executor or 8D.11 work. Implementation is complete, host-tested/build-verified, not target accepted; timer runtime costs remain unmeasured. See contracts, build and attributed test evidence in `docs/phase8d10_implementation.md`.
|
||||
**Relevant files:** `src/web_session.{h,c}`, `src/web_server.c`, `src/web_serial_transport.c`, `src/web_admin_transport.c`
|
||||
|
||||
The following first-slice exclusions are historical and superseded by slice 2:
|
||||
## Browser admin shell is separate from browser serial
|
||||
|
||||
**8D.10 first slice:** Accounts HTTPD routes expose a compact zero-wait list without password/key data and submit role/delete IDs to the existing dispatcher. Do not use the larger blocking CLI snapshot on HTTPD. Initiating-session currentness is checked before operation admission; target username/account ID/auth generation is compared under the database lock before candidate staging. Conditional and CLI mutations share invariant/commit logic. Notify only the target's web/SSH sessions after successful calls; notification failure does not undo persistence. Separate bounded Serial/Accounts slots do not create another executor. Completed results remain replaceable, no mutation auto-retry, and navigation is not cancellation. Self-target and create/password/generated-secret delivery are intentionally excluded until the next slice defines safe delivery/reconnect semantics. `src/web_account_settings.{c,h}`, `src/user_database.{c,h}`, `docs/phase8d10_implementation.md`.
|
||||
**Decision:** An administrator may keep the browser serial WebSocket alive while independently opening one admin-only WebSocket frontend for the canonical command dispatcher. Terminal switching changes only browser visibility and focus; the admin route never becomes a broker client.
|
||||
|
||||
## Browser authentication has a narrow version-pinned HTTPD boundary
|
||||
**Rationale/evidence:** This preserves a browser-held writer lease while giving full canonical administrative command parity without a generic HTTP command endpoint.
|
||||
|
||||
**8D.8 read-only settings:** Reuse bodyless GET cookie/current-admin policy and the existing bounded browser API/errors; no CSRF mutation semantics on a read. Obtain working serial config/running atomically with a zero-wait existing serial mutex, never block HTTPD on stop/reconfiguration or inspect NVS. Navigation changes view/input only, preserving both terminal sockets/lease/output; Settings session validation must not supersede serial-admission checks. One optional exact-GET URI raises only handler capacity to 17. The private adapter stages both descriptor/name allocations before publishing, avoiding the installed 5.5.0 public registration's freed table pointer on strdup failure. Serialized startup/exact matcher only, normal HTTPD allocation/free ownership; re-audit this boundary on SDK changes. Existing public registration callers are not refactored by this phase.
|
||||
**Consequence for future changes:** Do not multiplex admin command bytes into `/ws/serial`, and do not close or release the serial route as a side effect of mode switching, settings navigation, or popover display. HTTPD remains the owner of WebSocket send/close calls; the web-admin task only queues bounded work. Self-affecting HTTPS commands must use deferred drain control.
|
||||
|
||||
**8D.6 terminal separation:** Browser selection never reconnects serial or requests/releases a writer lease. Hidden connected terminals continue draining with separate bounded scrollback/pending writes and visible browser-drop accounting; only selected keyboard input is sent. Admin admission/reopen is explicit, close is isolated, and logout/expiry/pagehide tears down both routes. Keep the two page-lifetime input subscriptions stable across switches and remove socket callbacks on close. UI role hiding complements, never replaces, backend authorization. Existing unsupported WEB lifecycle/account-command restrictions remain for 8D.7.
|
||||
**Relevant files:** `src/web_admin_transport.{h,c}`, `src/admin_ssh_console.{h,c}`, `src/web_ui.c`, `src/web_server.c`
|
||||
|
||||
**Decision:** 8D.3 uses `web_cookie_auth` plus digest-only session/challenge stores, mandatory Origin/CSRF mutations and no live session/challenge/ticket eviction. Four one-hour absolute sessions deliberately interrupt long serial connections at expiry. No Basic compatibility path remains.
|
||||
## Browser writer transfer is atomic and generation-safe
|
||||
|
||||
**Browser Origin serialization:** Authentication POST fetches use `mode: 'cors'` while retaining fixed same-origin URLs, `credentials: 'same-origin'`, redirect rejection and CSP `connect-src 'self'`. Under `no-referrer`, non-CORS POST mode can serialize Origin as `null` (confirmed in Firefox during M1 testing). Do not fix that by accepting null server-side or weakening CSP/referrer policy; no cross-origin server permission is added.
|
||||
**Decision:** Guided writer assignment compares the expected current writer and validates the exact generation-safe target under the broker mutex before making one atomic ownership change.
|
||||
|
||||
**HTTPD boundary:** `web_httpd_adapter` alone includes private ESP-IDF 5.5.0 structures. Public request getters expose only the first field, so the adapter validates bounded parsed headers/rejects duplicates. `/ws/serial` is an ordinary GET until authenticated transport admission explicitly sends 101 and installs the frame handler; automatic HTTPD upgrades happen before URI handlers. Preserve right-aligned unread pending bytes when wiping request memory. Two Set-Cookie calls append pointer-backed fields, whose distinct buffers must survive through send. HTTPD logs above ERROR are compiled out to prevent header/ticket exposure.
|
||||
**Rationale/evidence:** A browser dialog can become stale while open. Unconditional force assignment could overwrite a newer legitimate lease or release ownership when its target disconnected.
|
||||
|
||||
**Ordinary HTTPS idle lifecycle:** Keep six sockets and LRU disabled. `web_httpd_idle` queues at most one owner sweep each second; `web_httpd_adapter` alone reads the installed successful request-plus-purge completion marker. Fifteen seconds of observed ordinary idle (three normal five-second browser polls), current SDK WS/async exemption, pending/readable-input checks and synchronous TLS-create fd invalidation authorize current-owner `shutdown`, never queued `sock_db *` close. Do not use response events, diagnostic wrappers or connection age as the completion/idle boundary. All ordinary response work must finish synchronously or retain the SDK async exemption. Submission fencing precedes HTTPD stop; only successful stop retires queued state and admits a nonreused generation. No tracing dependency, forced per-response close, hard request deadline, arbitrary admission eviction or capacity increase. A reported queue failure retries; accepted-but-lost UDP work stays reserved until successful restart rather than accumulating potentially delayed probes. Exact safety/liveness limits and target checklist: `docs/https_idle_cleanup.md`.
|
||||
**Consequence for future changes:** Use `session_broker_compare_exchange_writer()` for stale UI/API transfers. Opening or hovering a writer control must never mutate ownership, and target/current conflicts must leave the current lease unchanged.
|
||||
|
||||
**Consequence:** The version guard is not a source-hash guarantee. Re-audit layout, scratch/pending ownership, logging and handshake/frame dispatch on SDK changes; do not scatter private accesses through application code or assume host doubles prove real socket behavior. No SDK patch is currently applied. See `docs/phase8d3_implementation.md` for verification and target gates.
|
||||
**Relevant files:** `src/session_broker.{h,c}`, `src/web_server.c`, `src/web_ui.c`
|
||||
|
||||
**Relevant files:** `src/web_cookie_auth.{c,h}`, `src/web_session_store.{c,h}`, `src/web_httpd_adapter.{c,h}`, `src/web_server.c`, `src/web_serial_transport.c`, `src/CMakeLists.txt`.
|
||||
## User mutations have one serialized typed boundary
|
||||
|
||||
**Decision:** `user_admin_service` owns typed account/password/role/key mutations for console and web callers. Its recursive `admin_command_gate` critical region includes the optimistic snapshot check and database commit; a committed mutation is followed by independent best-effort web and SSH revocation attempts.
|
||||
|
||||
**Rationale/evidence:** Browser requests can race one another and canonical shell commands. Database generation plus stable user ID reject stale editors and username delete/recreate races, while the shared gate prevents caller-specific check-then-mutate interleaving. Revocation cannot be made atomic with the NVS commit, so principal currentness remains authoritative.
|
||||
|
||||
**Consequence for future changes:** Route new ordinary user mutations through this service instead of calling `user_database` directly. Do not roll back or report a committed mutation as failed solely because a transport notification failed. Preserve final-admin checks, remote self-generated-password restrictions, secret wiping, and generation/user-ID conflict reporting.
|
||||
|
||||
**Relevant files:** `src/user_admin_service.{h,c}`, `src/user_database.{h,c}`, `src/user_console.c`, `src/admin_command_gate.{h,c}`, `src/web_server.c`, `src/ssh_transport.c`
|
||||
|
||||
## Display configuration writers share the administration gate
|
||||
|
||||
**Decision:** Typed browser display Apply/Save/Load/Defaults/Reset operations and console display-writer commands serialize through the recursive `admin_command_gate` for the complete RAM and persistence operation.
|
||||
|
||||
**Rationale/evidence:** Browser handlers and canonical console frontends can mutate the same local-UI working configuration and NVS record concurrently. Serializing only individual lower-level calls could allow interleaved apply/save/load/reset sequences and inconsistent final state.
|
||||
|
||||
**Consequence for future changes:** Keep all new display configuration writers under the same gate, including any read-modify-write and rollback sequence. Do not hold the gate for read-only status or rendering work, and do not conflate it with the display framebuffer/I2C mutex.
|
||||
|
||||
**Relevant files:** `src/web_server.c`, `src/local_ui_console.c`, `src/local_ui_config.{h,c}`, `src/local_status_ui.{h,c}`, `src/admin_command_gate.{h,c}`
|
||||
|
||||
## Guided Wi-Fi editing uses exact-generation compare-and-swap
|
||||
|
||||
**Decision:** Browser Wi-Fi reads return configuration metadata plus `secret_set` flags, never PSKs. Each typed edit applies a complete validated working-config copy only if its expected nonzero generation remains current, and Save persists exactly the expected generation under the same writer serialization.
|
||||
|
||||
**Rationale/evidence:** Multiple browser editors, console changes, and lifecycle controls can update RAM configuration concurrently. A conventional read/modify/write or copy-then-save sequence could overwrite a newer secret or persist a generation the user never reviewed.
|
||||
|
||||
**Consequence for future changes:** Keep credential-bearing copies tightly scoped and wiped. Add config writers under the writer mutex and advance generation without wraparound; mismatch or exhaustion must fail closed. Stale browser forms must reload without replay and clear entered secrets.
|
||||
|
||||
**Relevant files:** `src/wifi_manager.{h,c}`, `src/wifi_config.{h,c}`, `src/wifi_console.c`, `src/web_server.c`, `src/web_ui.c`
|
||||
|
||||
## HTTPS lifecycle and post-material refresh are serialized
|
||||
|
||||
**Decision:** HTTPS start, stop, and TLS refresh share a lifecycle mutex and generation-tagged desired-running intent. Certificate/material replacement always proceeds to a TLS refresh. Teardown disables new admin-transport HTTPD calls, tracks calls already in progress, retains a server whose stop failed, and keeps incomplete post-stop finalization pending for retry before another start.
|
||||
|
||||
**Rationale/evidence:** Browser-shell commands can tear down their own transport while console commands race a restart or replace persisted TLS material. HTTPD-owned queued work must finish before its handle or the admin transport's static state can be reused.
|
||||
|
||||
**Consequence for future changes:** Do not start a second server around a retained handle, bypass lifecycle serialization, or make post-material refresh optional after persistence. A newer explicit lifecycle intent must win over an older refresh. Finalize timed-out detach state only after HTTPD destruction, and retry a failed finalizer before attaching a replacement server.
|
||||
|
||||
**Relevant files:** `src/web_server.{h,c}`, `src/web_console.c`, `src/web_admin_transport.{h,c}`, `src/admin_ssh_console.{h,c}`
|
||||
|
||||
## Security material and configuration use bounded, versioned NVS records
|
||||
|
||||
@@ -148,17 +168,17 @@ The following first-slice exclusions are historical and superseded by slice 2:
|
||||
|
||||
**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 `docs/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. Pre-bootstrap legacy credential rotation spans `web_sec/material` and `user_db/database`, is not cross-namespace atomic, and relies on boot reconciliation after interruption.
|
||||
|
||||
**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`
|
||||
|
||||
## NVS is persistence, not a physical security boundary
|
||||
|
||||
**Decision:** The current firmware stores Wi-Fi credentials and TLS/SSH private keys in unencrypted application NVS. The reserved NVS-key partition does not enable encryption.
|
||||
**Decision:** The current firmware stores Wi-Fi credentials, recovery credentials, and TLS/SSH private keys in unencrypted application NVS. The reserved NVS-key partition does not enable encryption.
|
||||
|
||||
**Rationale/evidence:** `partitions.csv`, README security notes, and current code show no NVS-encryption setup. Original rationale for deferring encryption is outside the implementation; the observable limitation is explicit.
|
||||
|
||||
**Consequence for future changes:** Do not claim resistance to flash extraction. Logical NVS replacement can leave old plaintext credentials in flash and is not secure erasure; no factory erase is required by this cleanup. Older v1-only firmware cannot read v2 HTTPS material. Avoid increasing stored secret exposure. Enabling encryption requires migration/recovery planning, not just changing the partition table.
|
||||
**Consequence for future changes:** Do not claim resistance to flash extraction. Avoid increasing stored secret exposure. Enabling encryption requires migration/recovery planning, not just changing the partition table.
|
||||
|
||||
**Relevant files:** `partitions.csv`, `README.md`, `src/web_security.c`, `src/ssh_security.c`, `src/wifi_config.c`
|
||||
|
||||
|
||||
+29
-19
@@ -1,8 +1,6 @@
|
||||
# Command reference
|
||||
|
||||
UART0 and authenticated `admin` SSH sessions use the same registered command implementations through one serialized dispatcher. Admin SSH exposes the full operational registry, including interactive prompts, network diagnostics, reboot, and HTTPS/SSH material mutation. Create the first administrator through normal `user add <username> admin` on physical UART0; explicit recovery of an unavailable database is UART0-only. Admin SSH also rejects generating a replacement password for its own account so the one-time value cannot be lost when the session is revoked. Run `help` for root commands and `<group> help` for a group summary. Configuration changes are RAM-only unless explicitly saved.
|
||||
|
||||
Browser-admin uses the same dispatcher with temporary 8D.7 restrictions. It now supports deferred **`reboot`** and **`web stop`** in addition to admin-only `exit`. The control task waits up to ten seconds for application output drain plus 200 ms; acknowledgement delivery is best effort, not confirmed receipt. Input observed during deferral is discarded. `web stop` closes both browser routes; restart HTTPS via UART0/admin SSH `web start` and sign in again. Reboot affects all transports and loses RAM-only changes. The second slice also permits exact parsed **`web certificate rotate --force`** (equivalent quoting accepted, no missing force or extra arguments). After drain/delay it queues a typed action on the existing dispatcher; pending input remains discarded until completion. This does not bound dispatcher wait or certificate/NVS/lifecycle execution time. Certificate commit precedes HTTPS stop/start: early generation/persistence errors skip restart, stop failure retains ownership and skips start, and later lifecycle failure does not undo the new persisted identity. Success closes both browser routes and any web writer lease; verify/trust the new certificate through a trusted channel and sign in again with unchanged account credentials. USB/UART0/SSH remain usable; use UART0/admin SSH for stop/start recovery if needed. Other `web` forms besides `web status`, `web stop` and this exact rotation remain blocked (including certificate info and reset; legacy credential commands are removed). User mutations, Wi-Fi/mDNS mutations and restricted SSH lifecycle commands remain unavailable from browser admin. See `phase8d7_implementation.md` for the exact remaining policy and target-validation status.
|
||||
UART0, authenticated `admin` SSH sessions, and the browser Admin shell use the same registered command implementations through one serialized dispatcher. The remote shells expose the operational registry, including interactive prompts, recovery-secret display, network diagnostics, reboot, and HTTPS/SSH material mutation. Initial administrator bootstrap and explicit recovery of an unavailable user database remain physically bound to UART0. A remote administrator cannot generate a replacement password for its own account, so the one-time value cannot be lost when the session is revoked. Run `help` for root commands and `<group> help` for a group summary. Configuration changes are RAM-only unless explicitly saved.
|
||||
|
||||
## System
|
||||
|
||||
@@ -10,17 +8,19 @@ Browser-admin uses the same dispatcher with temporary 8D.7 restrictions. It now
|
||||
|---|---|
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `exit` | Close the current administrative SSH session after its acknowledgement drains; unavailable on UART0. Ctrl+D on an empty admin SSH command line does the same. |
|
||||
|
||||
## Role-based users
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `user status` / `user list` | Show database generation, capacity, administrator count, and all secret-free account summaries. |
|
||||
| `user status` / `user list` | Show database generation, capacity, administrator/bootstrap state, and all secret-free account summaries. |
|
||||
| `user show <username>` | Show one account's role, ID, authentication generation, and SSH-key fingerprints. |
|
||||
| `user bootstrap` | Set and confirm the `admin` password without echo, then promote the migrated account to `admin`. |
|
||||
| `user bootstrap --generate` | Bootstrap `admin` with a generated 24-character password displayed once. |
|
||||
| `user add <username> <user|admin>` | Create an account using a bounded no-echo password and confirmation prompt. |
|
||||
| `user add <username> <user|admin> --generate` | Create an account with a generated password displayed once. |
|
||||
| `user delete <username> --force` | Delete an account; the final administrator is protected. |
|
||||
| `user delete <username> --force` | Delete an account; the pre-bootstrap migrated `admin` and final administrator are protected. |
|
||||
| `user role <username> <user|admin> --force` | Change a role; the final administrator cannot be demoted. |
|
||||
| `user password <username>` | Set and confirm a new password without echo. |
|
||||
| `user password <username> --generate` | Replace a password with a generated value displayed once. |
|
||||
@@ -28,13 +28,15 @@ Browser-admin uses the same dispatcher with temporary 8D.7 restrictions. It now
|
||||
| `user key add <username> <type> <base64>` | Import a key non-interactively; intended for authenticated admin SSH and also accepted on UART0. |
|
||||
| `user key delete <username> <0..2> --force` | Delete one key by the index shown by `user show`. |
|
||||
| `user key clear <username> --force` | Delete all public keys for an account. |
|
||||
| `user recover --force` | When normal user-database initialization failed, explicitly replace only its blob with an empty database; UART0-only, refuses a healthy database. |
|
||||
| `user recover --force` | When normal user-database initialization failed, explicitly replace its blob from the current legacy network credential. |
|
||||
|
||||
Usernames must match `[a-z][a-z0-9_-]{0,15}`. Passwords contain 12–64 printable ASCII characters. The fixed database supports eight users and three SSH keys per user; initial key types are `ssh-ed25519` and `ecdsa-sha2-nistp256`. A key may be assigned to multiple accounts but cannot be duplicated within one account. Password verifiers, salts, raw key blobs, and passwords are absent from ordinary status output. `Ctrl-C` cancels a password or key prompt, and generated passwords are shown once.
|
||||
|
||||
Missing `user_db/database` storage is committed empty. On UART0 run `user add <username> admin`, optionally with `--generate`, to create the first administrator. There is no bootstrap command, imported shared credential, or synchronization with HTTPS material. Existing valid v1 user databases load unchanged, including previously migrated role-`user` accounts; no account is silently promoted.
|
||||
On the first Phase 8A boot, the old shared `admin` credential is imported as a role-`user` account, not silently granted administrator rights. Run `user bootstrap` from physical UART0 to establish the administrator. Phase 8B now authenticates HTTPS and SSH passwords through this database and enables stored SSH public keys. Before bootstrap, `web credentials rotate --force` and `web reset --force` synchronize the migrated verifier; after bootstrap, that legacy credential is recovery-only and does not authenticate or alter role-based users.
|
||||
|
||||
`user recover --force` is destructive and succeeds only while the database is unavailable. It rebuilds only the user blob empty, importing no credentials; then create an administrator with normal `user add` on UART0. It refuses a healthy initialized database, including a healthy empty one, and does not erase unrelated NVS data. Successful password, role, key and delete operations invalidate only that username's outstanding tickets and active web/SSH sessions; unrelated users remain connected.
|
||||
`user recover --force` is a destructive physical recovery operation and succeeds only while the database is unavailable. It replaces the user blob with one role-`user` account derived from the current legacy credential; run `user bootstrap` afterward. It does not erase unrelated NVS data. Successful password, role, key, bootstrap, and delete operations invalidate only that username's outstanding WebSocket tickets and active WebSocket/SSH sessions; unrelated users remain connected.
|
||||
|
||||
The administrator-only Settings dialog uses `/api/admin/users` for guided account create/list/edit/delete, role changes, entered or one-time generated passwords, and authorized Ed25519/P-256 key add/remove. Mutations include the displayed database generation and stable user ID, so a stale editor is rejected and reloaded instead of targeting a deleted/recreated account. CLI and browser mutations share `user_admin_service`; it serializes mutations with `admin_command_gate`, commits first, and then requests best-effort web and SSH revocation. Transport currentness checks remain authoritative if notification is incomplete. Entered keys/passwords and generated-password output are cleared from the dialog on close or failure.
|
||||
|
||||
## Local display
|
||||
|
||||
@@ -46,7 +48,7 @@ Missing `user_db/database` storage is committed empty. On UART0 run `user add <u
|
||||
| `display save` / `display load` | Save the working aging settings to NVS or load them. |
|
||||
| `display defaults` / `display reset` | Apply 300/600-second defaults in RAM, or apply and persist them. |
|
||||
|
||||
When both transitions are enabled, `off-seconds` must be greater than `dim-seconds`. Applying settings counts as local UI activity. At normal boot, an initialized OLED shows a bounded five-second identity animation before the status UI begins; it scrolls the device name in yellow and draws the compact upright-terminal logo in blue. A missing OLED remains nonfatal; after reconnecting it safely, one new button press requests a bounded reprobe and is consumed without navigating.
|
||||
When both transitions are enabled, `off-seconds` must be greater than `dim-seconds`. Applying settings counts as local UI activity. The administrator-only Settings dialog exposes the same typed Apply/Save/Load/Defaults/Reset behavior through `/api/admin/display`; all five operations are serialized with console display writers through `admin_command_gate`, and invalid aging combinations are rejected without applying them. At normal boot, an initialized OLED shows a bounded five-second identity animation before the status UI begins; it scrolls the device name in yellow and draws the compact upright-terminal logo in blue. A missing OLED remains nonfatal; after reconnecting it safely, one new button press requests a bounded reprobe and is consumed without navigating.
|
||||
|
||||
## Serial service
|
||||
|
||||
@@ -106,7 +108,9 @@ Opening `/dev/ttyACM*` with DTR asserted creates the `usb-cdc` broker client, st
|
||||
| `wifi nslookup <host>` | Resolve and display unique IPv4/IPv6 addresses. |
|
||||
| `wifi traceroute <host> [max-hops]` | Run IPv4 ICMP traceroute with up to 30 hops. |
|
||||
|
||||
`ping`, `nslookup`, and `traceroute` are root aliases. The four station-profile slots use lower priority values first. Edits to a disabled profile's SSID, priority, security mode, or secret are staged in RAM and do not interrupt the current Wi-Fi connection. Enabling or disabling a profile, changing an enabled profile, or changing AP policy/configuration applies the new radio policy and may reconnect Wi-Fi. Use `wifi save` to persist working changes. Passwords are not displayed by ordinary status output.
|
||||
`ping`, `nslookup`, and `traceroute` are root aliases. The four station-profile slots use lower priority values first. Edits to a disabled profile's SSID, priority, security mode, or secret are staged in RAM and do not interrupt the current Wi-Fi connection. Enabling or disabling a profile, changing an enabled profile, or changing AP policy/configuration applies the new radio policy and may reconnect Wi-Fi. Use `wifi save` to persist working changes.
|
||||
|
||||
The administrator-only Settings dialog uses `/api/admin/wifi-config` for typed station-profile, AP policy/SSID/channel, and write-only secret edits. Reads return only `secret_set` flags. Every mutation compares the exact working-configuration generation, and Save persists only that same generation; stale or exhausted generations fail closed without applying or saving another editor's state. On a conflict, the browser clears entered secrets and reloads current values without replaying the request.
|
||||
|
||||
## mDNS
|
||||
|
||||
@@ -124,14 +128,22 @@ When the Wi-Fi station receives an IPv4 address, the Wi-Fi manager announces `sa
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `web` / `web help` | Show web-service command usage. |
|
||||
| `web status` | Show HTTPS and WebSocket state. |
|
||||
| `web status` | Show HTTPS, browser-session, serial-WebSocket, and admin-WebSocket state. |
|
||||
| `web start` / `web stop` | Start or stop HTTPS service. |
|
||||
| `web counters` / `web clear-counters` | Show or clear web counters. |
|
||||
| `web credentials show` | Display the legacy migration/recovery credential on UART0 or an authenticated remote admin shell; it is not a role-based network login. |
|
||||
| `web credentials rotate --force` | Replace the legacy recovery credential and synchronize the migrated pre-bootstrap account only. |
|
||||
| `web certificate info` | Display certificate identity and fingerprint. |
|
||||
| `web certificate rotate --force` | Replace the HTTPS certificate and private key. Browser admin defers commit and HTTPS restart; both browser routes close and new certificate trust/relogin is required. UART0/admin SSH behavior is unchanged. |
|
||||
| `web reset --force` | Explicitly replace HTTPS certificate/private key only, including missing, incompatible or damaged material; never changes users. |
|
||||
| `web certificate rotate --force` | Replace the HTTPS certificate and private key. |
|
||||
| `web reset --force` | Explicitly replace missing, incompatible, or damaged legacy credentials and web material. |
|
||||
|
||||
HTTPS listens on port 443 only. Authenticate with any current user-database username/password; both `user` and `admin` roles receive the existing status and browser-terminal interface. The device serves vendored xterm.js without Internet access. Browser sessions use one-time account-bound tickets, binary WebSocket frames, and the broker's one-writer rule. The combined **Connect**/**Disconnect** control closes the current WebSocket and pauses automatic reconnect when active; after a user-paused disconnect, it changes to **Connect** to resume connection attempts. Account mutations revoke only that account's tickets and sessions.
|
||||
HTTPS listens on port 443 only. Sign in with any current user-database username/password through the same-origin login page; explicit logout permits account switching without relying on a browser HTTP-authentication cache. The device stores at most eight opaque eight-hour browser sessions, with at most two retained per account, and sends the raw token only in a host-only secure cookie. State-changing requests require strict Origin and session-bound CSRF validation.
|
||||
|
||||
Both roles receive the offline browser serial terminal. Its one-time ticket and active WebSocket are bound to the exact browser session and obey the broker's one-writer rule. The combined **Connect serial**/**Disconnect serial** control closes only the serial WebSocket and pauses automatic reconnect when active. Account mutations revoke that account's browser sessions, serial/admin tickets, and WebSockets without disturbing unrelated accounts.
|
||||
|
||||
An administrator additionally receives a **Serial terminal**/**Admin shell** selector, typed Serial controls, guided user/password/role/authorized-key management, generation-safe Wi-Fi profile/AP/secret editing and saving, display-aging controls, and contextual Serial, Wi-Fi, broker-client, and writer-transfer popovers. The browser admin shell uses the same bounded editor, history, completion, prompts, serialized dispatcher, and registered command handlers as admin SSH and UART0; it never joins the serial broker. Switching terminal modes only changes visibility and focus: it does not close the serial WebSocket or release its writer lease. Writer transfer requires an explicit confirmation and atomically checks both the expected current writer and generation-safe target ID. Service/session controls beyond the guided serial/Wi-Fi actions, network diagnostics, security/danger operations, and unusual hardware/debug commands remain shell-only. Initial `user bootstrap` and `user recover --force` remain physical-UART0-only.
|
||||
|
||||
Typed mutation endpoints accept only body-backed URL-encoded forms bounded to 512 bytes and 10 unique fields; duplicate, oversized, malformed, stale-session, wrong-origin, and wrong-CSRF requests fail without a side effect. HTTPS start/stop and TLS refresh are serialized and carry a lifecycle generation. Certificate rotation and full material reset always trigger a post-material TLS refresh; a newer explicit lifecycle request takes precedence. Teardown disables transport-owned HTTPD calls, tracks any already in progress, retains an HTTPD handle after stop failure, and retries pending post-stop admin-transport finalization before a later start.
|
||||
|
||||
## SSH serial transport
|
||||
|
||||
@@ -149,11 +161,11 @@ HTTPS listens on port 443 only. Authenticate with any current user-database user
|
||||
|
||||
SSH listens on port 22 and accepts user-database passwords plus stored `ssh-ed25519` and `ecdsa-sha2-nistp256` public keys. wolfSSH verifies key possession after the database authorizes the username/key pair; unsigned key probes do not complete authentication. A `user` receives the broker-backed UART1 serial stream. An `admin` receives the administration shell instead, does not become a broker client, and cannot acquire a UART1 writer lease.
|
||||
|
||||
UART0 and admin SSH submit to one bounded queue, and one dispatcher task is the sole caller of `esp_console_run()`. Consequently, SSH commands execute the canonical UART0 handlers and produce the same status and mutation behavior rather than using a second command implementation. Remote output is routed into the authenticated session's bounded output ring; only the SSH transport task accesses wolfSSH.
|
||||
UART0, admin SSH, and the browser admin shell submit to one bounded queue, and one dispatcher task is the sole caller of `esp_console_run()`. Consequently, remote commands execute the canonical UART0 handlers rather than using separate command implementations. Each remote frontend has generation-safe session identity, exact authorization checks, bounded editor/history/output state, and transport-owned network I/O; only the SSH transport task accesses wolfSSH and only HTTPD performs browser WebSocket sends/closes.
|
||||
|
||||
UART0 and admin SSH use shared whole-line Tab completion. A unique/common prefix expands inline; a Tab that cannot extend an ambiguous prefix prints the matching candidates and redraws the unchanged input line instead of cycling candidates. Admin SSH additionally supports four-entry per-session command history with Up/Down, inline cursor editing with Left/Right, Home/End (including Pos1/Ende terminal sequences), Backspace/Delete, Ctrl-C, and visible or no-echo interactive prompts. Its history is RAM-only, private to the session, and wiped on disconnect. Ping callbacks enqueue bounded typed results so all formatting remains on the dispatcher task.
|
||||
|
||||
`exit`, `reboot`, `ssh stop`, session disconnect, and SSH host-key reset/rotation use bounded deferred control. The firmware waits on a best-effort basis for the administration output ring and transport TX buffer to drain before acting; this is not confirmation that the peer received the acknowledgement. The shell stops accepting another command while such an action is pending. SSH host-key replacement or service stop closes all SSH sessions; reconnect and verify the new fingerprint where applicable. TLS certificate management, Wi-Fi secrets, and interactive user passwords/keys are available to authenticated administrators and must therefore be treated as remotely accessible administrative material. `user recover --force` remains UART0-only; `user bootstrap` and all `web credentials` commands are removed. A connected administrator also cannot generate its own replacement password remotely, preventing the one-time password from being lost during self-revocation. SSH does not provide `exec`, SFTP, SCP, forwarding, or subsystems.
|
||||
`exit`, `reboot`, `ssh stop`, session disconnect, and SSH host-key reset/rotation use bounded deferred control. The firmware waits on a best-effort basis for the administration output ring and transport TX buffer to drain before acting; this is not confirmation that the peer received the acknowledgement. The shell stops accepting another command while such an action is pending. SSH host-key replacement or service stop closes all SSH sessions; reconnect and verify the new fingerprint where applicable. Web recovery credentials/certificates, Wi-Fi secrets, and interactive user passwords/keys are available to authenticated administrators and must therefore be treated as remotely accessible administrative material. `user bootstrap` and `user recover --force` remain UART0-only. A connected administrator also cannot generate its own replacement password remotely, preventing the one-time password from being lost during self-revocation. SSH does not provide `exec`, SFTP, SCP, forwarding, or subsystems.
|
||||
|
||||
## Hardware diagnostics
|
||||
|
||||
@@ -180,5 +192,3 @@ UART0 and admin SSH use shared whole-line Tab completion. A unique/common prefix
|
||||
| `debug buttons test [seconds]` | Run the bounded button event test for 1–30 seconds; the default is 10 seconds. |
|
||||
|
||||
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).
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
# Bounded ordinary HTTPS idle retention
|
||||
|
||||
Implemented 2026-09-08 on the existing 8D.11 key/diagnostic implementation. Host-tested and firmware-build verified. **Subsequent scoped user report:** the user accepted that idle cleanup worked. This supersedes the original target-pending status for that behavior only; no additional soak duration, individual checklist passes, memory reserve approval or broader 8D.11/M3 sign-off is inferred. The implementation evidence below remains historical and unchanged. No agent upload, erase, commit, SDK patch or generated-asset change.
|
||||
|
||||
## Evidence and scope
|
||||
|
||||
User-authorized response to the admission capture: post-TLS occupancy **6/6, ordinary4 / serial2 / admin0**, ordinary connection ages **50–74 seconds**; admin ticket returned in **14 ms**, no subsequent TLS/upgrade observed, ticket unconsumed. These ages measure connection lifetime, not time since the last request. They support investigating retained ordinary sockets, not claiming every captured socket was idle. Installed HTTPD excludes its listening fd from `select` while full with LRU disabled. New connections can therefore wait before TLS or upgrade even when ticket issuance was fast.
|
||||
|
||||
This policy releases **expired idle ordinary HTTPS connections**, not arbitrary ordinary requests to make room. Six sockets, 24 URI handlers, LRU disabled, existing receive/send timeouts (one second), TLS handshake timeout (five seconds), all task stacks/queue depths and browser code remain unchanged. No new task. UART0 recovery, independent USB access, serial broker ownership, tickets, authentication and both WebSocket transports are unchanged.
|
||||
|
||||
## Usage and timeout semantics
|
||||
|
||||
- Automatically enabled for every successfully started HTTPS server, including when diagnostic tracing is disabled and either optional transport is unavailable. No new console command, setting or credential migration.
|
||||
- **15 seconds of observed ordinary idle retention**, checked by a **one-second ESP timer** that requests work on HTTPD. The three existing five-second browser status-poll intervals leave room for normal keepalive reuse instead of a TLS handshake per request.
|
||||
- A new post-TLS connection that has sent no request gets the same full idle window. Time spent establishing TLS does not consume it.
|
||||
- The first owner sweep observing a new connection or a changed successful-request completion marker starts a fresh window. The marker covers every ordinary route, including login, assets, status, typed settings, tickets and keepalive errors that return successfully—not just diagnostic wrappers. Failed requests are deleted by the normal SDK path instead.
|
||||
- HTTPD pending bytes, TLS pending bytes or a readable TCP fd reset the observation window. Negative TLS pending results and `select` errors conservatively reset it too. No bytes are read or discarded by the probe.
|
||||
- At the threshold, after current SDK classification and zero-time readiness checks, HTTPD calls `shutdown(current_fd, SHUT_RDWR)`. A successful shutdown is latched; a failed call retries on the next probe. The SDK's subsequent read/delete path owns socket close, TLS destruction, diagnostic close notification and freeing the slot. The probe never calls `close`, overrides TLS cleanup, or queues a session-close pointer.
|
||||
- **WebSockets (serial and admin), closing WebSockets and async requests are exempt.** The check uses actual SDK flags, not diagnostic metadata. Successful explicit 101/classification and request cleanup finish before the owner can sweep, so there is no ordinary-idle interval during upgrade admission.
|
||||
- Under an available owner and timely successful work delivery, expect 15 seconds plus the initial observation delay (nominally up to one second), the next probe delay (nominally up to one second), and SDK read/cleanup latency. This is a conservative sampled idle policy, **not a strict wall-clock timeout or admission SLA**.
|
||||
|
||||
### Deliberate limitations
|
||||
|
||||
1. Parsing an incomplete request, synchronous response sends, leftover-body purge and TLS handshakes serialize on HTTPD. The probe cannot interrupt them. Existing per-read/per-send timeouts remain; a peer trickling input can extend overall processing beyond one timeout. This change does not provide a slowloris deadline or solve TLS/memory/global-socket pressure.
|
||||
2. Four continuously active ordinary connections plus two WebSockets can still fill all six slots. They are not evicted. Likewise, an admission attempt immediately after fresh saturation can hit the browser's existing 15-second timeout before the conservative window expires; the user may still need to retry. Older idle retained sockets are eligible on the next delivered probe.
|
||||
3. As with any HTTP keepalive timeout, bytes arriving **after** the last readiness check can race a shutdown. Already executing HTTPD requests/responses are protected; future client intent cannot be predicted. A client may need a new connection. No new application-level mutation retry/replay is added. Response completion here means synchronous HTTPD completion, not proof of peer receipt/TCP acknowledgement.
|
||||
4. `httpd_queue_work` with `CONFIG_HTTPD_QUEUE_WORK_BLOCKING` disabled uses loopback UDP. A reported queue error releases the reservation and retries next tick. **A successful send is not an execution acknowledgement**: an accepted-but-lost control message leaves one reservation pending, disables further probes, and requires successful HTTPS stop/restart to restore probing. There is no speculative reservation timeout: it could accumulate delayed callbacks and violate the one-probe bound. This is explicitly regression-tested, not hidden behind a hard retention guarantee. HTTPD blockage or queue loss also cannot be repaired by raising sockets/LRU/timeouts here.
|
||||
5. A failed stop leaves cleanup detached and ownership retained for a later stop retry; it does not restart probes on a partially stopped server. Timer allocation/start failure gates HTTPS start with its error rather than silently starting without the policy. UART0/USB recovery is unaffected.
|
||||
|
||||
## Exact installed SDK audit
|
||||
|
||||
All SDK references below are under `~/.platformio/packages/framework-espidf/components/`; installed framework is ESP-IDF **5.5.0** (`3.50500.0`). Production private access remains solely in `src/web_httpd_adapter.c`, with the existing compile-time version guard extended to require idle-lifecycle re-audit.
|
||||
|
||||
- `esp_http_server/src/httpd_main.c`: `httpd_server` selects the listener only with capacity or LRU enabled. Control work runs **before** current data sessions, then accept. `httpd_process_session` skips async sessions and synchronously runs `httpd_sess_process`; errors delete the session before any subsequent owner work. Accept invokes TLS synchronously through `open_fn`.
|
||||
- `esp_http_server/src/httpd_sess.c`: `httpd_sess_process` calls `httpd_req_new`, then `httpd_req_delete`, and only after both return success assigns `session->lru_counter = ++hd->lru_counter`. This happens even with LRU disabled. New sessions zero the slot, including the marker; the global counter resets when all sessions are deleted. Application calls to `httpd_sess_update_lru_counter` are confined to verified serial-WebSocket send work (`web_serial_transport.c`), which the sweep exempts. This marker is not a timestamp and not an fd-generation token.
|
||||
- `esp_http_server/src/httpd_parse.c`: `httpd_req_new` synchronously parses and invokes the URI handler. `httpd_req_delete` drains any remaining body; cleanup clears `hd_req_aux.sd` and request pointers. A return from a diagnostic handler wrapper or a response-send call is earlier than this boundary. The sweep requires HTTPD's thread identity and no current `hd_req_aux.sd`, and skips `for_async_req`. Current ordinary handlers do not use async requests, out-of-owner sends, or unfinished chunked responses. Re-audit that contract if introduced.
|
||||
- `esp_http_server/include/esp_http_server.h` has no global synchronous post-request-cleanup hook. Its event notifications are not such a hook: `esp_http_server_dispatch_event` posts to the event loop. `HTTP_SERVER_EVENT_SENT_DATA` is emitted by `httpd_resp_send` and per `httpd_resp_send_chunk`, **before** handler return/body purge/cleanup. Send/receive overrides belong to HTTPS and do not expose a safe completion hook. URI matching and error handlers likewise cannot supply an all-route post-cleanup boundary.
|
||||
- `esp_https_server/src/https_server.c`: successful `httpd_ssl_open` stores the transport context and installs TLS send/recv/pending functions before synchronous `HTTPD_SSL_USER_CB_SESS_CREATE`. The application callback invalidates any old row for that fd even if the TLS pointer, socket-slot address and counter value were reused. If fd lookup unexpectedly fails, all observations reset conservatively. The close callback remains the diagnostic observer; SDK destruction remains intact. `httpd_ssl_pending` calls `esp_tls_get_bytes_avail` without consuming data; errors can post an existing SDK error event.
|
||||
- `httpd_sess_trigger_close` resolves fd to a raw reusable `sock_db *`, then queues `httpd_sess_close`. Its zero-counter/LRU guard does not prove the same connection still occupies that slot. **Not used by this policy.** Direct owner shutdown retains the slot until SDK read cleanup and has no deferred fd/pointer argument that could later close a replacement.
|
||||
- `httpd_queue_work` uses `cs_send_to_ctrl_sock` / `sendto` in `src/util/ctrl_sock.c`. The actual generated config leaves `CONFIG_HTTPD_QUEUE_WORK_BLOCKING` undefined/off, selecting the nonblocking queue mode. The idle initializer explicitly rejects builds with that blocking option on. Successful `httpd_stop` waits for `THREAD_STOPPED`, frees HTTPD and ends possible old callback execution; failed stop is not a retirement boundary.
|
||||
|
||||
## Ownership and bounded storage
|
||||
|
||||
`src/web_httpd_idle.{c,h}` owns one persistent ESP timer, six static observation rows, lifecycle gate/generation and queued/submitting flags. It uses no request data, secret, dynamic per-connection allocation, payload buffer, new task or additional socket. Timer callback performs only short metadata locking and at most one queue submission; all private session access, readiness and shutdown run on HTTPD.
|
||||
|
||||
`src/web_server.c` serializes lifecycle. Prepare initializes rows **before** SSL startup; the TLS callback and sweep thereafter share the same owner. Attach publishes a nonzero, nonwrapping `uintptr_t` server generation, passed by value as opaque work argument—not a mutable shared descriptor or raw fd. At most one queued/executing probe is reserved; a separate submitting flag remains set until `httpd_queue_work` returns even if work already finished. That closes the callback-before-submit-return race.
|
||||
|
||||
Detach first prevents submissions, then waits at most one second for any submitting call to return. Fence timeout forbids SSL destruction, retaining the handle for retry. An already executing sweep may finish safely while stop waits for HTTPD. Only successful SDK stop retires a discarded queued reservation; restart gets a new generation even if the server handle is reused. A stale generation cannot sweep or clear a newer reservation. No counter wrap or generic off-owner session-list query is accepted.
|
||||
|
||||
`src/web_httpd_adapter.{c,h}` defines the six-row bound and 15-second policy and performs the version-pinned owner sweep. `src/CMakeLists.txt` adds only the new module. Diagnostics remains unchanged internally; server composes idle identity reset followed by existing diagnostic publication.
|
||||
|
||||
## Validation and resource accounting
|
||||
|
||||
Commands executed successfully:
|
||||
|
||||
```sh
|
||||
python3 tests/web_httpd_idle/run.py
|
||||
python3 tests/web_admin_transport/server_lifecycle.py
|
||||
python3 tests/web_diagnostics/run.py
|
||||
python3 tests/web_cookie_auth/run.py --accounts
|
||||
python3 tests/web_cookie_auth/run.py --admin
|
||||
python3 tests/web_cookie_auth/run.py --settings
|
||||
python3 tests/web_cookie_auth/run.py --serial-settings
|
||||
python3 tests/web_admin_transport/run.py --tickets
|
||||
python3 tests/web_session_store/run.py --serial
|
||||
python3 tests/web_ui_session/run.py
|
||||
python3 tests/admin_console_boundary/run.py
|
||||
python3 tests/admin_console_boundary/accounts.py
|
||||
python3 tests/admin_console_boundary/lifecycle.py
|
||||
python3 tests/admin_ssh_policy/run.py
|
||||
pio run
|
||||
git diff --check
|
||||
```
|
||||
|
||||
- Idle suite: **18 lifecycle groups plus SDK/source guards**. Compiles complete production lifecycle module and production sweep, with installed SDK `httpd_sess_process`/`httpd_req_delete`. Uses real host `socketpair`, readiness, shutdown, EOF and fd reuse; TLS, parsing/response callbacks, timer/queue scheduling and session deletion are deterministic doubles. Tests cover full slots, never-used post-TLS idle, five-second polling, pending/readable/error input, slow synchronous handlers/purge, failed requests, upgrade/async/closing-WS exemption, exact fd/TLS/slot/counter reuse, queue failures, early callback return, submit-fence timeout, failed stop, discarded/stale generations, accepted-but-lost UDP work, diagnostic-independent enforcement and timer initialization/restart/exhaustion. No real TLS/ESP-IDF scheduler or target timing claim.
|
||||
- Server lifecycle **18 groups**, diagnostics **12+1**, admin transport **25** / tickets **12**, browser **68 plus renderer/CSP**, and the listed auth/store/canonical console/account suites pass. The cookie harness required one missing `<stdint.h>` include after its private-layout double was expanded; fixed, all listed variants rerun successfully.
|
||||
- Production build: **57.55 seconds**, **99,316 B RAM / 1,708,981 B flash**, **+160 / +1,384 B** versus the recorded diagnostic baseline (99,156 / 1,707,597). Final no-op confirmation `pio run` also passed in **3.13 seconds** with identical sizes.
|
||||
- Target object symbols: rows **144 B**; module static symbols **167 B total before linker placement/alignment**. Linked RAM delta need not equal the sum of new symbols. One persistent internal-memory ESP timer allocation: installed non-profiled layout **32 B before allocator overhead**. Control UDP packet/mailbox transient allocation is SDK-owned, not included in static RAM. No task/stack/socket/queue-capacity increase.
|
||||
- Target disassembly local frames (exclude callees): sweep **80 B**, idle work/timer **32/32 B**, prepare **64 B**, attach/detach/stopped **32 B each**, TLS idle callback **48 B**, composed TLS callback **32 B**. Actual HTTPD/timer stack margins, heap minima, TLS churn/latency and runtime reserves are **unmeasured/unapproved**.
|
||||
|
||||
## Target checklist — not performed
|
||||
|
||||
1. Start with diagnostic capture disabled: establish two serial WebSockets and ordinary HTTPS fetches. Verify idle ordinary sockets disappear after the observation window while both serial clients/lease remain unchanged. Enable capture only as needed to compare close/open occupancy; ages remain connection ages, not idle timestamps.
|
||||
2. Reproduce the original ordinary4/serial2 full-slot case; wait beyond the idle window, then issue/open admin. Record client ticket/TLS/upgrade timings and occupancy without recording tickets/cookies. Verify no repeat reload loop is needed for already-old idle saturation. Separately test fresh saturation and acknowledge the existing 15-second browser timeout limit.
|
||||
3. Leave status polling active for several minutes: no five-second TLS reconnect churn; both WebSockets and binary serial data/broker isolation survive. Repeat Settings/account-key operations, large assets, login/logout and two browser contexts. Compare with polling paused to distinguish genuinely idle slots.
|
||||
4. Slow incomplete headers/bodies, pipelined requests, slow response readers and slow/failed TLS handshakes: no probe-driven close of an executing response or admitted WebSocket. Record owner delays; do not infer an overall request deadline from the unchanged one-second receive timeout.
|
||||
5. Repeated close/reopen/fd reuse, full-mix stop/start and certificate rotation via supported UART0/SSH/browser lifecycle paths. Confirm UART0 and USB remain available, queued work never affects replacement connections, failed-stop retries retain ownership, and no start allocates a second server.
|
||||
6. Capture settled/loaded/post-cleanup internal/DMA/PSRAM values and HTTPD/ESP-timer minimum-free stack. Soak at the accepted full client mix. Investigate control queue loss separately if probing appears stuck; successful stop/start is the safe recovery, not an eviction/capacity increase.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Legacy credential removal
|
||||
|
||||
## Scope and status — 2026-09-08
|
||||
|
||||
Agreed removal of the obsolete shared web credential, user bootstrap/migration/synchronization paths, and related public APIs/commands. User-generated passwords and HTTPS certificate rotation remain supported. Implementation and integration are complete, host-tested and build-verified; **the user signed off the legacy-credential cleanup on 2026-09-08**. No device operation, factory erase, commit or asset generation was performed.
|
||||
|
||||
Separately, the user reported and accepted that ordinary HTTPS idle cleanup worked. This is a scoped functional report, not an invented soak, individual checklist execution, memory reserve approval, broader 8D.11/M3 sign-off or target validation of this credential removal. Prior measurements remain in [the idle-cleanup record](https_idle_cleanup.md).
|
||||
|
||||
## Target sign-off — 2026-09-08
|
||||
|
||||
User confirms: “the cert fingerprint did not change and I could use the preexisting test users. So that is a sign off”. Accept the legacy-credential/bootstrap cleanup based on this identity/account continuity confirmation and the full-mix telemetry below. This supersedes earlier pending-signoff/fingerprint-unconfirmed statements, not historical test attribution or evidence limits. Ordinary HTTPS idle cleanup retains its prior scoped acceptance. No wider phase/M3 acceptance, individual unreported provisioning/recovery/fault-injection test, all-key coverage, soak duration or numeric reserve approval is inferred.
|
||||
|
||||
User suspects the two boot authentication failures came from the two browser tabs reconnecting with pre-flash stale sessions. This is consistent with two failures already at boot, no further failures under load and zero invalid credential attempts, but remains a user-attributed likely explanation rather than a traced request diagnosis.
|
||||
|
||||
This sign-off update changes documentation only: no source/config changes, build/test rerun, upload, erase, asset generation or commit.
|
||||
|
||||
## User-supplied post-cleanup target evidence — 2026-09-08 (before sign-off confirmation)
|
||||
|
||||
User supplies a fresh-boot sample at 60 seconds and a full-client-mix sample with substantial reported test traffic. This confirms operational password/public-key login and the supported connected mix in this run, not explicit cleanup sign-off, exact certificate preservation, every account/key, blank-device provisioning, recovery, or power-loss behavior. Exact flashed revision, browser, duration, diagnostic enable state and before/after certificate fingerprint were not supplied.
|
||||
|
||||
| Memory (bytes) | Boot free / minimum / largest | Full mix free / minimum / largest |
|
||||
|---|---|---|
|
||||
| Internal 8-bit | 66,488 / 61,268 / 31,744 | 31,820 / 15,740 / 20,480 |
|
||||
| Internal DMA | 58,732 / 53,512 / 31,744 | 24,064 / 7,984 / 20,480 |
|
||||
| PSRAM | 8,246,148 / 8,184,056 / 8,126,464 | 8,112,076 / 8,074,196 / 7,995,392 |
|
||||
|
||||
Per-region lifetime minima are conservative/non-simultaneous; internal/DMA capability sets overlap. Loaded minima exceed the pre-idle-cleanup diagnostic capture (1,984/384 internal/DMA), but workloads differ and this is not causal attribution or reserve approval. SSH owner stack is 20,480 bytes, minimum-free 18,476 boot / 16,284 loaded; HTTPD/dispatcher margins unreported.
|
||||
|
||||
Boot: SSH/HTTPS started once with no startup failures, mDNS announced/ESP_OK, UART stopped with 230400 8N1 RTS/CTS/DTR-active configuration, USB attached/host closed, no broker clients or SSH sessions. Five accounts/two admins. HTTPS already processed two unauthenticated requests (two auth failures), but login attempts/invalid credentials remain zero; this is not a completely request-free baseline. Source of the requests is unknown, not evidence of bad passwords.
|
||||
|
||||
Loaded: USB client16 is the sole writer, SSH client9 and web clients10/11 observers, with SSH and browser admin consoles active. Two SSH sessions authenticate by public key as user/admin; two cookie logins authenticate by password as those roles. No specific key algorithm or exact verifier/key identity is established. UART at 230400 8N1 RTS/CTS/DTR active, all sampled UART/broker pending/events zero; no broker drop-counter output was supplied. User reports much traffic, but output does not establish bidirectional exercise of every route: browser serial RX and browser admin RX are zero in the supplied counters.
|
||||
|
||||
SSH: TCP/handshake success/auth attempts 2/2/2, all handshake/auth/timeouts/capacity/request rejection/IO/session-revocation failures zero; broker connect1, writer request1/denial1, no grants/revocations; admin admission1/no failures or backpressure. Stream RX/accepted/rejected 34/34/0, TX 299,932 bytes. Browser admin ticket issued/consumed1/1, connected1/disconnected0, TX136/RX0, all rejection/capacity/protocol/send/queue/backpressure counters zero. Web requests65/authenticated63/auth failures2 (unchanged from boot), root2/status56/tickets2/assets2, response errors0. Serial tickets issued/consumed2/2, no expired/rejected; serial WS connect2/disconnect0, no connection/service/broker failures. TX1,077 binary frames/495,147 bytes, control9 frames/774 bytes, RX0. Writer requests2/denials2, grants/releases/revocations0, send/queue/protocol/close counters0. Cookie sessions2/4, login attempts2, invalid/throttled/capacity/CSRF/logouts0. These counters support clean established WebSocket operation in this capture, not a measured first-attempt admission latency or absence of all pre-HTTP TLS failures.
|
||||
|
||||
No code/config changes, build, device commands or explicit sign-off were performed/inferred from this evidence update. Remaining target checks include unchanged HTTPS fingerprint, existing-account/key coverage, blank-device first admin and unavailable-only recovery as appropriate, plus longer cleanup/traffic observations if desired.
|
||||
|
||||
## 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.
|
||||
|
||||
## Evidence and handoff
|
||||
|
||||
- Parent reran successfully: `python3 tests/web_security/run.py` (**15 production groups**, real installed host mbedTLS plus public-surface checks), `tests/admin_console_boundary/accounts.py`, `tests/admin_ssh_policy/run.py`, `tests/admin_console_boundary/lifecycle.py`, `tests/web_cookie_auth/run.py --accounts`, and `tests/web_httpd_idle/run.py` (**18 + SDK guards**).
|
||||
- Integration agent additionally reports PASS for console boundary, auth parser, cookie default/admin/settings/serial-settings, session-store/default/serial, admin transport/tickets, server lifecycle, diagnostics, login UI and browser UI (68 + renderer/CSP). These are attributed agent runs, not additional parent reruns.
|
||||
- Startup now uses credential-free initialization and explicit TLS migration-source logging; SSH no longer depends on HTTPS identity readiness. Policy tests cover 64 service-gate combinations and RNG failure. Removed command/completion entries are tested; browser restrictions and UART0-only recovery remain.
|
||||
- Independent review found no actionable regression. A subsequent agent test extension verifies populated sparse key slots 0/2 across persisted v1 reload, unchanged entire records/no writes, fingerprint snapshots, retained-key authorization and rejection of the removed key; canonical accounts tests and scoped diff check passed. This final extension changes tests only.
|
||||
- Parent `pio run` **PASS, 3.93 s**, **99,260 B RAM / 1,705,781 B flash**, **−56 B RAM / −3,200 B flash** versus the recorded idle-cleanup baseline. Parent `git diff --check` passed before final test/documentation additions.
|
||||
- Host NVS doubles do not prove actual flash durability or power-loss behavior; TLS v1 fixture uses current-generated identity wrapped in the historical layout rather than an independently captured old-device blob. Device upgrade, persistence, failure-injection, frontend command removal and TLS-reset isolation checks remain manual validation procedures, not claimed passes; see [user administration tests](user_administration_tests.md).
|
||||
- Earlier phase records retain their historical evidence and procedures. Their legacy credential/bootstrap/reconciliation instructions are superseded by this record and the current command reference, not rewritten as if the old behavior never existed.
|
||||
@@ -1,152 +0,0 @@
|
||||
# Phase 8D.10 — Accounts and passwords
|
||||
|
||||
## Target acceptance — implemented 8D.8–8D.10 scope (2026-09-08)
|
||||
|
||||
**User sign-off:** after supplying settled clean-boot and full-client-mix telemetry, the user reports: “I also tested the serial parameter display and settings, as well user/account settings thoroughly. Implemented work can be signed off.” Record this as acceptance of implemented **8D.8, 8D.9 (including its automatic-completion UX), and 8D.10 (both Accounts slices)**. It supersedes target-pending/full-signoff-pending statements below and in the earlier phase records, not their evidence or implementation constraints. The current checkout/handoff includes the completed credential/self workflows and generated-password route; the exact flashed revision/build hash was not separately supplied. Do not turn the broad functional sign-off into invented individual checklist passes. M2 stands; no 8D.11 implementation is authorized by this sign-off.
|
||||
|
||||
### User-supplied memory samples
|
||||
|
||||
All values are **bytes**. Each heap cell is **free / minimum-free / largest-block**. Minimum-free is the firmware's conservative sum of matching regions' lifetime minima, not necessarily one simultaneous sample.
|
||||
|
||||
| Sample | Internal 8-bit | Internal DMA | External PSRAM |
|
||||
|---|---:|---:|---:|
|
||||
| Settled clean boot | 69,928 / 65,476 / 31,744 | 62,172 / 57,720 / 31,744 | 8,246,368 / 8,238,352 / 8,126,464 |
|
||||
| Loaded sequence 1 | 46,016 / 19,832 / 31,744 | 38,260 / 12,076 / 31,744 | 8,235,292 / 8,225,380 / 8,126,464 |
|
||||
| Loaded sequence 2 | 32,580 / 19,608 / 23,552 | 24,824 / 11,852 / 23,552 | 8,082,268 / 8,077,960 / 7,995,392 |
|
||||
| Loaded sequence 3 | 32,444 / 19,452 / 23,552 | 24,688 / 11,696 / 23,552 | 8,084,008 / 8,069,816 / 7,995,392 |
|
||||
| Loaded sequence 4 | 32,444 / 19,228 / 23,552 | 24,688 / 11,472 / 23,552 | 8,082,276 / 8,065,444 / 7,995,392 |
|
||||
| Loaded sequence 5 | 32,556 / 19,228 / 23,552 | 24,800 / 11,472 / 23,552 | 8,086,084 / 8,065,444 / 7,995,392 |
|
||||
| Loaded sequence 6 | 32,556 / 19,228 / 23,552 | 24,800 / 11,472 / 23,552 | 8,087,656 / 8,065,444 / 7,995,392 |
|
||||
|
||||
The first loaded sample precedes the settled later footprint; exact client occupancy at each memory command is not separately known. Later free/largest values are broadly stable across the supplied sequence, while lifetime internal/DMA minima fall through sample 4 and then remain unchanged. This short sequence is not a timed leak/fragmentation/soak proof or numeric reserve approval. SSH owner stack minimum-free is **18,476 B at boot / 16,284 B loaded**, of a 20,480-byte allocation. HTTPD/dispatcher stack margins were not supplied.
|
||||
|
||||
### Observed configuration and client mix
|
||||
|
||||
- Settled boot: HTTPS and SSH initialized/running, one successful start each, no start failures; web admin initialized/attached but inactive. No SSH/cookie/WebSocket/broker clients. USB attached but host-open/DTR/RTS false; UART service stopped and RS-232 owner idle. Five users/two admins. mDNS initialized and announced as `sak-1024.local`, ESP_OK.
|
||||
- Serial configuration is **230400 baud, 8 data bits, no parity, 1 stop bit, RTS/CTS, DTR active, RTS threshold 96** at both boot and load. Loaded service owns the physical port; RX-available and TX-pending are zero at the snapshot. DSR/CTS and valid voltage asserted; DCD/RI not asserted.
|
||||
- Loaded snapshot confirms **two SSH sessions**: role-user public-key broker writer **ID 8**, and public-key administrator on the separate console route. Four broker clients: SSH writer 8, USB observer 9, web observers 26 and 11. No queued client output or events in the supplied client table. This supports one writer/isolated observers; no broker drop-counter output was supplied.
|
||||
- HTTPS has **two cookie sessions, two serial WebSockets, and one active admin WebSocket**. Web serial sessions belong to administrator and ordinary-user accounts, both observing. USB host-open/DTR/RTS true, broker observer. Reported USB line coding is diagnostic only; UART1 configuration remains explicit.
|
||||
|
||||
### Counters retained without unsupported diagnosis
|
||||
|
||||
- SSH: 3 TCP connects, 2 successful handshakes, **1 handshake failure**, zero handshake timeouts/auth failures/capacity rejection/IO failures. One successful broker admission/writer grant, one admin-console admission without failures/backpressure. Stream RX 73 accepted/0 rejected, TX 9,121. **One broker revocation**, zero session-revocations; cause unspecified.
|
||||
- Web admin: 5 tickets issued/consumed, 5 connects/4 disconnects, one currently active; no capacity/auth/protocol/backpressure/send/queue failures. RX 0 / TX 408 bytes. These counters do not separately prove shell-command testing.
|
||||
- Web serial: 4 connects/2 disconnects, no service-start/broker/connect failures. TX 75 binary frames / 12,478 bytes. Four writer requests denied while SSH holds the lease, consistent with single-writer policy. **One send failure and one close** are recorded alongside connection cycling; no cause or regression attribution is established. Queue/protocol failure counters are zero.
|
||||
- General HTTPS: 86 requests/authenticated, zero reported auth/response failures in those counters; 6 serial tickets issued, 4 consumed, 2 expired. Cookie counters: 3 login attempts, **1 invalid credential**, zero throttle/capacity/CSRF-Origin rejections/logouts. No credential value is retained here.
|
||||
- The final session snapshot has no closing or RX-pending serial clients. Admin-SSH command/output activity is consistent with running the status commands, not evidence of a wedged dispatcher.
|
||||
|
||||
### Acceptance and evidence limits
|
||||
|
||||
The user's thorough Serial/account functional testing and explicit sign-off close the implemented phase scope. Exact firmware hash, browser(s), elapsed settle/load/soak durations, mutation-by-mutation results, reboot-persistence/fault-injection/secret-expiry details and post-disconnection cleanup telemetry were not supplied separately. These limits do **not** reopen accepted phases or become invented test passes. Historical checklist items below remain useful regression guidance, not remaining acceptance blockers after user sign-off. Numeric reserves/runtime timer cost/HTTPD-dispatcher stack margins remain followups. The earlier accepted intermittent full-mix admission issue is neither diagnosed nor declared fixed by successful admission in this run.
|
||||
|
||||
The CLI's “no normal UI entry” and “shell/PTY only” labels are inherited status wording, not evidence that current UI/SSH behavior differs; source confirms those strings still exist. Endpoint/status-text cleanup remains a documentation followup, not a runtime change in this validation update.
|
||||
|
||||
This sign-off update changes documentation only: no firmware/test run, upload, erase, generated assets, source/config changes or commit. The user's existing `platformio.ini` edit is preserved. **Wait for a separately requested 8D.11; no full M3 or unrestricted browser-shell parity is claimed.**
|
||||
|
||||
## Second-slice implementation evidence (2026-09-08; before target sign-off)
|
||||
|
||||
**8D.10 implementation is complete, host-tested and build-verified, including create/password/generated-value/self workflows and route integration. Target validation and full 8D.10 signoff remain pending; this is not target acceptance. No 8D.11 work.** This supersedes slice 1 scope exclusions and next-work instructions, not its historical evidence. M2 acceptance stands; continuation is not prior-phase target signoff or reserve approval.
|
||||
|
||||
### Current contracts
|
||||
|
||||
- The admin-only list and single session-bound operation/result slot now support create/password/role/delete, including own-account password/role/delete. HTTPD authorizes/parses/queues; the existing dispatcher executes mutations. No new application task, stack allocation, queue depth, socket or broker client.
|
||||
- Mutation JSON is **768 bytes / four receive calls**. Exact create schema: `{action, username, role, password}`; password replacement: `{action, username, user_id, auth_generation, password}`. Role/delete schemas are unchanged. Only password accepts JSON escapes; decoded values must be canonical **12-64 printable ASCII bytes**, without trimming. Spaces, quotes and backslashes are valid. Unknown/duplicate/extra fields, malformed encoding and invalid identities reject. Body, parsed credentials and consumed request scratch are wiped on exit.
|
||||
- Create uses canonical `user_database_create()` duplicate/capacity/NVS policy. `user_database_set_password_current()` shares CLI mutation logic and checks target identity under the mutation mutex before staging, derivation, generation increment and commit-before-live-install. Role/delete retain conditional checks and final-admin/migrated-account protections. Secret-free **96-byte** `{id, action, state}` results add `duplicate` and `full`; results remain replaceable, originating-session-bound, not durable history or retry tokens.
|
||||
- Credential admission requires one lazily created firmware-lifetime **one-second periodic ESP timer**; create/start failure rejects admission. At/after the **30-second admission deadline**, a tick cancels and wipes the current non-executing pending create/password slot, permitting replacement even while the dispatcher is blocked. Cleanup is nominally deadline plus up to one period **plus scheduling latency**, not a hard real-time guarantee. The callback inspects current ID/deadline, never cancels a replacement early or executing work, and does no database/network work. Role/delete retain dequeue-only expiry; obsolete queued IDs cannot execute replacement work.
|
||||
- Dequeue marks execution, copies inputs locally and immediately wipes shared credentials/principal/target. Dispatcher revalidates originating session/admin and deadline before database API admission. The timer does not wipe executing local data; credentials are wiped after database return or rejection, and the full local operation at exit. Blocking/derivation/NVS duration is not bounded by the queue deadline. Already-admitted work may complete after logout/expiry; navigation/request abort is not backend cancellation.
|
||||
- Separate bodyless admin/Origin/CSRF-protected **`POST /api/settings/accounts/generate-password`** calls `user_database_generate_password_value()` and rechecks session currentness before returning one **24-character base64url** value in a **96-byte**, no-store response. It performs no account mutation, slot reservation, NVS commit or revocation. Generated/response scratch is wiped on success and failure. There is **no retained secret or retrieval endpoint**. A lost generation response means nothing was applied; explicit regeneration yields another value. Generation precedes a separate commit, rather than commit-and-retrieve or guaranteed delivery.
|
||||
- UI generation is explicitly not applied. Submission requires confirmation and, for generated values, saved-password acknowledgement bound to the value and operation/target context. Generated references expire after **60 seconds**, including monotonic admission checks for delayed timers. Edits/context changes invalidate acknowledgement; submission/cancellation, navigation/session/page lifecycle cleanup clears secrets and fences late replies. No routine secret storage/logging/history/clipboard writes. JavaScript/browser-managed copies cannot be securely zeroed; reference clearing is best effort.
|
||||
- Successful non-create calls immediately best-effort target-revoke web/SSH; create does not notify. Successful role no-op still notifies. Self mutations may close all that account's web/SSH sessions, including this browser's serial/admin routes, before POST/result delivery. No deferred drain, proactive logout or guaranteed receipt. **401/disconnect proves neither success nor failure**: re-login with expected credentials/role and inspect through a surviving authorized route before retrying. Notification failure does not undo persistence; authoritative currentness remains the fail-safe. Ordinary navigation preserves socket/writer ownership; deliberate self revocation is distinct.
|
||||
- Automatic result checking remains one-second delay, at most ten GETs/15 seconds overall, with manual uncertainty recovery and no automatic mutation retry. **No browser-shell restriction changes:** typed self/generated workflows do not enable those shell commands. SSH keys/raw database or secret export/legacy credential management are outside scope; bootstrap/recovery remain permanently UART0-only.
|
||||
- The generated-password endpoint is registered independently as an optional route in `web_server.c`, bringing the budget to **23 handlers**, with six sockets/no LRU unchanged. Backend review found only the missing registration, now fixed. Route-agent lifecycle tests cover registration, optional failure isolation and restart; direct-handler tests alone are not registration proof.
|
||||
|
||||
### Continuation evidence
|
||||
|
||||
- Reported continuation `python3 tests/web_cookie_auth/run.py --accounts`: **PASS, nine Accounts groups plus shared auth/store/IDF adapter groups**. Includes credential parsing, timer creation/start failure, expiry/replacement/execution fences, cleanup, generation without mutation and self revocation. Database/queue/revocation/timer doubles are not target concurrency, flash or TLS proof.
|
||||
- UI agent reports `python3 tests/web_ui_session/run.py`: **PASS, 57 behavior groups plus renderer/CSP checks**, four added beyond its earlier 53-group slice 2 run. Modeled DOM/fetch/timers/WebSockets do not establish real-browser/backend integration or actual CSP enforcement.
|
||||
- Route agent reports `python3 tests/web_admin_transport/server_lifecycle.py`: **PASS, 15 groups**, including the corrected independent optional endpoint, failure isolation and restart. UI 57/CSP and lifecycle 15 are agent results; the parent's additional UI/lifecycle reruns have not been reported here and are not claimed as passes.
|
||||
- Parent reports PASS: `python3 tests/admin_console_boundary/accounts.py`, `python3 tests/admin_console_boundary/run.py`, `python3 tests/web_auth_parse/run.py` (**294 cases**), `python3 tests/web_cookie_auth/run.py --accounts` (**9 plus shared**), `python3 tests/web_cookie_auth/run.py --serial-settings` (**10 groups**), `python3 tests/web_admin_transport/run.py --tickets` (**25 transport / 12 ticket groups**), `python3 tests/web_session_store/run.py --serial`, and diff check.
|
||||
- Parent `pio run`: **PASS, 25.61 s; 95,908 B RAM / 1,694,237 B flash**. Delta versus slice 1: **+80 B RAM / +9,880 B flash**; versus final 8D.9 UX: **+200 B RAM / +25,400 B flash**. Slice 1 figures below remain historical. Static linker accounting is not runtime heap/stack proof: new timer and descriptor runtime costs, HTTPD/dispatcher stack margins and loaded heap reserves remain unmeasured/unapproved.
|
||||
- This documentation task performs no source/test edits or build. Results are attributed parent/agent reports, not reruns by this documentation agent. No sanitizer validation, upload, erase, device action, asset regeneration, commit or 8D.11 work is claimed.
|
||||
|
||||
### Target checklist (pending)
|
||||
|
||||
1. Test desktop/mobile disposable-account create and supplied/generated password workflows: exact bytes, confirmation/context acknowledgement, expiry/cleanup and no application from Generate. Compare UART0 state and persistence after reboot.
|
||||
2. Exercise other-account password/role/delete with active web/SSH sessions; verify target-only revocation and unrelated web/SSH/native USB continuity, plus navigation socket/writer preservation.
|
||||
3. With a surviving administrator and UART0 recovery available, test self password/demotion/deletion, final-admin rejection and role no-op. Verify uncertain 401/disconnect handling, expected relogin and inspection before retry; missing result never proves no commit.
|
||||
4. Mutate/delete/recreate selected targets through CLI and check stale rejection; exercise duplicate/full/protected failures without unintended writes or disclosure.
|
||||
5. Queue credentials behind a long console prompt; observe deadline cleanup/replacement, logout/expiry, late responses, lost generation/POST acknowledgements and manual recovery without retry. Timer scheduling and executing-secret lifetime require target observation.
|
||||
6. Verify fourth-route admission, injectable registration/start/stop failures, slow fragmented requests and full serial/admin/SSH/USB mix. Record loaded/cleanup internal/DMA/PSRAM free/min/largest and HTTPD/dispatcher stack margins. Host NVS doubles are not target failure evidence.
|
||||
7. Obtain target acceptance separately from the completed host-tested/build-verified implementation. **Stop within 8D.10; no 8D.11 authorization or target signoff is inferred.**
|
||||
|
||||
## Historical first slice (2026-09-08)
|
||||
|
||||
All sections below retain slice 1 evidence, including then-current exclusions, 22-handler count, build and next-slice checklist. They do not describe current slice 2 scope or validation.
|
||||
|
||||
**Account list and other-account role/delete implemented, host-tested and build-verified; target validation pending. 8D.10 remains incomplete.** The user requested 8D.10 after the 8D.9 UI refinement. Before editing, selected the plan's explicit split between list/role/delete and create/password/secret delivery. No 8D.8/8D.9 target signoff is inferred from continuation. M2 acceptance stands.
|
||||
|
||||
### Available behavior
|
||||
|
||||
Settings now has Serial settings and Accounts subviews. Accounts shows at most eight usernames/roles, identifies the current login, and permits confirmed role changes or deletion of another account. Changes persist immediately; there is no Apply/Save staging. Successful operations use the same best-effort targeted web/SSH revocation as CLI commands. Unrelated accounts, UART0, native USB and navigation-related serial writer ownership remain unchanged. A role no-op retains canonical CLI behavior: no database commit, but successful-command target notification still occurs.
|
||||
|
||||
The UI submits once, checks acknowledged work automatically (one-second initial/inter-check delay, at most ten GET attempts and a 15-second overall deadline including revalidation), then refreshes the list on known terminal results. It retains failed/uncertain outcome messages. Errors, deadline exhaustion or lost acknowledgement require manual Check Result/Refresh; no automatic mutation retry. Navigating between domains, leaving Settings, logout/expiry/pagehide or changed session identity aborts/fences work and clears the account list/selection labels. Navigation is not backend cancellation. Re-selecting the current domain/view is a no-op.
|
||||
|
||||
**Not included:** create, supplied-password replacement, generated-password delivery, own-account changes, SSH keys, bootstrap/recovery, raw database export or any password/verifier/private-key fields. Self role/delete is denied by the server as well as the UI. Existing browser-shell restrictions are unchanged. The second 8D.10 slice must explicitly design create/password and own-account credential/reconnect behavior; this first slice is not full Accounts/password UX or phase acceptance.
|
||||
|
||||
## Backend contracts
|
||||
|
||||
- `src/web_account_settings.{c,h}` owns one static pending/result slot. It holds only an operation ID, copied originating session/principal, target identity, action/role and deadline/state—no credential material.
|
||||
- `GET /api/settings/accounts`: current admin cookie policy, no query/body, no-store. Compact projection of username, role, account ID and authentication generation; no SSH key metadata or full database snapshot. `user_database_get_accounts()` copies under the existing mutex with **zero wait**, clearing output on failure. This narrow copy does not change the existing authentication/currentness APIs or claim all authentication paths are nonblocking.
|
||||
- `POST /api/settings/account-operation`: admin cookie/current principal, strict Origin/CSRF policy; maximum **256 bytes / four receive calls**. Exact ASCII flat schema: `{action, username, user_id, auth_generation}` for delete; plus `role` for role changes. IDs are nonzero unsigned 32-bit decimal integers. Unknown/duplicate fields, escaping/nesting, invalid usernames/enums, fractional/exponential/overflow/coerced numbers and extra fields reject. Unread rejected bodies close; request scratch is wiped.
|
||||
- `GET /api/settings/account-operation`: bodyless current-admin read of the originating login's retained `{id, action, state}` only. Results have a **96-byte buffer** and states idle/pending/ok/failed/cancelled/stale/protected. Another admitted operation may replace a completed result; this is not durable history or an idempotent retry API.
|
||||
- `admin_ssh_console_submit_account_settings()` enqueues only the ID, with **zero queue wait**, to the existing four-entry dispatcher queue. The typed union adds no queue-item size and consumes no remote console slot. One pending account operation rejects another with 503/Retry-After. The Serial and Accounts pending slots are separate but executions serialize with each other and UART0/SSH/browser-shell commands on the same dispatcher.
|
||||
- On dequeue, validate originating session/principal/admin role, reject self-target, and enforce a **30-second admission deadline**. A blocked dispatcher retains the pending slot until it dequeues the request; this is not a slot-release timer or execution timeout. Admitted database work may commit and notify after the initiating login expires. Subsequent stale work rejects. HTTPD never executes account commits or transport notification.
|
||||
- `user_database_delete_current()` / `user_database_set_role_current()` compare target username/account ID/authentication generation **inside the mutation mutex** before staging or commit. Missing or changed targets return stale without mutating a replacement account. Existing CLI APIs call the same implementation without conditional identity arguments. Final-admin/migrated-admin protections, role generation changes, NVS commit-before-live-install and candidate cleanup remain canonical.
|
||||
- Only successful database calls trigger target-name web and SSH revocation; notification failures do not roll back a committed account. Authoritative transport currentness remains the fail-safe. Result state reports database completion, not guaranteed notification delivery.
|
||||
- `web_server.c` optionally registers list GET, result GET, then mutation POST, using the existing failure-safe private adapter. Failure cannot publish mutation without both read routes; failed POST cleanup can leave at most read-only routes. Allocation failure does not disable either terminal transport. No new SDK-private access.
|
||||
|
||||
## Validation performed
|
||||
|
||||
Final sequential parent run, all passed:
|
||||
|
||||
```sh
|
||||
python3 tests/web_cookie_auth/run.py --accounts
|
||||
python3 tests/admin_console_boundary/accounts.py
|
||||
python3 tests/admin_console_boundary/run.py
|
||||
python3 tests/web_admin_transport/server_lifecycle.py
|
||||
python3 tests/web_ui_session/run.py
|
||||
python3 tests/web_admin_transport/run.py --tickets
|
||||
python3 tests/web_session_store/run.py --serial
|
||||
pio run
|
||||
git --no-pager diff --check
|
||||
```
|
||||
|
||||
- Accounts HTTP: **five groups** plus shared cookie/store/IDF getter/adapter regressions. Actual parser/auth/store/handler with deterministic DB/queue/revocation doubles. Covers security/input bounds, max-width eight-account projection, failed list, slot/queue exhaustion, session isolation, obsolete/zero IDs, failure/stale/protected outcomes, deadline/session cancellation, missed account revocation, DB-currentness failure and admitted-work completion after invalidation. A failed database currentness check invalidates that cookie session, so subsequent test operations use a newly issued login.
|
||||
- Canonical account tests compile production database mutation bodies and compact list getter, plus existing CLI handlers. Verify target generation/deletion/recreation rejection, no-write final-admin protection, unchanged live state and cleared candidate on NVS open/set/commit failure, and existing prompt/currentness/revocation behavior. RTOS/NVS/crypto are deterministic doubles, not actual flash or concurrency tests.
|
||||
- Dispatcher boundary covers nonblocking Accounts admission and separate Serial/Accounts routing on the existing queue, alongside prior console/certificate/SSH-adapter cases.
|
||||
- Server lifecycle: **14 groups**, including all three Accounts registration failure positions, failed unregister, restart recovery and unchanged transport isolation/six sockets/no LRU.
|
||||
- UI: **41 behavior groups** (six new Accounts groups), plus production C renderer/headers/failure and exact inline-loader CSP checks. Covers admin-only list/schema, confirmed typed identity-bound actions, automatic completion/refresh, polling budget/manual recovery, cancellation, failure/uncertainty and session identity/401 isolation. DOM/fetch/timers/WebSockets are modeled; this is not real-browser/backend integration.
|
||||
- Transport/tickets and session-store/serial integration reruns passed. Earlier in this slice, cookie `--settings`, `--serial-settings`, `--admin`, console `lifecycle.py` and `admin_ssh_policy/run.py` also passed.
|
||||
|
||||
Initial test failures were corrected: authored C/JS newline escaping, outdated lifecycle test route/count expectations, and new currentness tests using the existing fake's actual invalidation control. Final results above supersede those intermediate failures. Implementer source/diff review performed; no independent-agent review or sanitizer claim.
|
||||
|
||||
## Resources
|
||||
|
||||
Final `pio run`: **25.00 s; 95,828 B RAM / 1,684,357 B flash**.
|
||||
|
||||
- Versus final 8D.9 UX build (95,708 / 1,668,837): **+120 B RAM / +15,520 B flash**.
|
||||
- Versus 8D.8 (95,580 / 1,654,529): **+248 / +29,828 B**.
|
||||
- Static linker totals are not free/min/largest heap, allocation overhead, or stack high-water proof.
|
||||
- Three additional optional URI descriptors bring the configured handler budget from 19 to **22**. Six HTTPD sockets/no LRU, two serial/one admin WebSockets, dispatcher depth, tasks and stack allocations remain unchanged.
|
||||
- List response buffer **1,024 bytes**, operation request **256 bytes**, result **96 bytes**, at most eight compact records. Handler/dispatcher stack margins and additional descriptor/name/table runtime heap have not been measured on target. Four bounded receive calls still occupy HTTPD while receiving; full-mix responsiveness needs target validation.
|
||||
|
||||
No asset regeneration, upload, erase, device action or commit. Numeric reserves/stack margins remain unapproved; the accepted intermittent full-mix admission issue is unchanged and unresolved.
|
||||
|
||||
## Pending validation and handoff
|
||||
|
||||
1. Confirm Settings/Accounts desktop/mobile display and role gating on hardware; compare list/roles with UART0. Verify navigation preserves existing serial/admin sockets and writer lease.
|
||||
2. Change/delete disposable other accounts while they hold web/SSH sessions; verify immediate persistence, only affected-account revocation, and unrelated USB/web/SSH continuity. Reboot and compare persisted state. Do not delete recovery/needed accounts casually.
|
||||
3. Select an account, mutate or delete/recreate it through CLI, then confirm the stale browser request rejects. Verify self actions are unavailable, and canonical final-admin protection still works.
|
||||
4. Queue behind a long console prompt; test logout, expiry, queue deadline, busy admission, missed/lost acknowledgement and manual recovery. Check that navigation does not imply cancellation and no mutation is automatically repeated.
|
||||
5. Test optional route failure/slow fragmented HTTP requests and record loaded/cleanup internal/DMA/PSRAM free/min/largest plus HTTPD/dispatcher stack margins. Host failures are not target NVS-failure evidence.
|
||||
6. Next is the **second 8D.10 slice**, not 8D.11: create/password workflows, bounded transient secret handling, one-time generated-password delivery and safe own-account changes. Establish the secret-delivery/acknowledgement/revocation contract before editing. Bootstrap/recovery remain permanently UART0-only. First-slice target validation and full 8D.10 signoff remain pending.
|
||||
@@ -1,121 +0,0 @@
|
||||
# Phase 8D.11 — SSH authorized keys
|
||||
|
||||
## Status (2026-09-08)
|
||||
|
||||
Implemented on user request; host-tested and firmware-build verified. Target validation/sign-off remains pending. Accepted 8D.8–8D.10 and M2 remain accepted; this is not M3 completion or authorization for 8D.12.
|
||||
|
||||
## User-supplied target telemetry (2026-09-08)
|
||||
|
||||
User supplies a fresh-boot sample at 60 seconds and a later full-client-mix sample. The last browser serial/admin connection again required several attempts. This records partial target evidence, not functional key-management acceptance or phase sign-off; exact flashed revision, browser, key algorithms, traffic duration and cleanup/soak results were not supplied.
|
||||
|
||||
| Memory (bytes) | Boot free / minimum / largest | Loaded free / minimum / largest |
|
||||
|---|---|---|
|
||||
| Internal 8-bit | 69,584 / 58,076 / 31,744 | 32,596 / 5,468 / 22,528 |
|
||||
| Internal DMA | 61,828 / 50,320 / 31,744 | 24,840 / 532 / 22,528 |
|
||||
| PSRAM | 8,246,356 / 8,242,108 / 8,126,464 | 8,087,636 / 8,056,956 / 7,995,392 |
|
||||
|
||||
Minima are conservative sums of per-region lifetime minima, not a simultaneous sample; internal/DMA capabilities overlap and must not be added. Low minima are a transient-pressure warning, not proof of allocation failure. Settled loaded free internal/DMA is comparable to the prior accepted 8D.10 sample (32,556 / 24,800), but lifetime minima are substantially lower (previously 19,228 / 11,472). Different connection attempts/workloads prevent attributing that difference to 8D.11.
|
||||
|
||||
Boot: SSH/HTTPS started once without reported startup failures; all supplied network request/session counters zero; no broker clients, UART stopped, USB attached but host closed. mDNS announced with ESP_OK. Five accounts/two admins, no cookie sessions. SSH owner stack 20,480 bytes, minimum-free 18,468.
|
||||
|
||||
Loaded: two successful SSH public-key sessions (admin console and user serial writer); two browser serial observers, USB observer and active browser admin. Broker writer 16, web observers 17/19, USB observer 10, all pending/events zero at the sample. UART running at 230400 8N1 RTS/CTS, DTR active, RX/TX queues empty; USB host open. Two cookie sessions; mDNS/SSH/HTTPS report operational, no transition/error. SSH minimum-free stack 16,276 bytes; HTTPD/dispatcher stack margins unreported. Public-key authentication works for these two sessions, but algorithms and whether keys were imported through the new UI are unspecified.
|
||||
|
||||
Admission evidence: HTTPS handshake attempts logged from 197688 through 379338 ms, with three errors at 377948/378298/378658 ms: `mbedtls_ssl_handshake returned -0x0050`, TLS session creation and HTTPD accept failure. Installed mbedTLS `net_sockets.h` defines this as `MBEDTLS_ERR_NET_CONN_RESET`, not allocation failure. It does not establish why the connection reset or whether transient heap pressure caused delays. The previously accepted intermittent last-browser admission issue has recurred and remains unresolved.
|
||||
|
||||
Loaded counters: SSH TCP connections/handshake success/auth attempts 2/2/2; handshake/auth/timeouts/capacity/IO failures zero. SSH broker connect 1, writer request/grant 1/1, revocations 1; admin admission 1/no failures, stream RX/accepted/rejected 7/7/0, TX 5,977, session revocations zero. Browser admin tickets issued/consumed 2/2, connected/disconnected 2/1, TX 272; rejection/capacity/protocol/backpressure/send/queue counters zero. Web requests 89 authenticated/zero auth failures, root 2/status 76/tickets 8/assets 2, response errors zero; tickets issued/consumed/expired 8/5/3. Serial WS connect/disconnect 5/3 with zero admission/service/broker failures; RX zero; TX 218 binary frames/21,200 bytes and 16 control frames/1,322 bytes; writer requests/grants/denials 5/1/4. One send failure/close, zero queue/protocol failures. Cookie login attempts 2, invalid/throttled/capacity/CSRF/logout zero. No broker drop-counter output supplied; zero pending queues is not proof of lossless traffic. Application counters do not account for every pre-HTTP TLS failure.
|
||||
|
||||
Documentation-only evidence update: no new build, source/config edits, upload, erase, device commands or sign-off. Next diagnostic evidence should correlate connection attempts with heap/allocation failures and browser network timing/socket occupancy rather than assume heap exhaustion or increase socket capacity blindly. Key-workflow checklist and reserve approval remain open.
|
||||
|
||||
## Authorized admission diagnostic slice (2026-09-08)
|
||||
|
||||
Separately user-authorized instrumentation for the recurring third-webshell timeout, **not a timeout fix or a key-workflow sign-off**. Implemented in `src/web_diagnostics.{c,h}`, wired through `web_server.c`, `web_console.c`, `console_completion.c` and `src/CMakeLists.txt`. No generated assets, SDK patch, broad HTTPD debug logging, broker/serial change, new task/timer/queue, socket/URI capacity change, timeout change, LRU change or TLS cleanup override.
|
||||
|
||||
### Usage and interpretation
|
||||
|
||||
Use UART0 (preferred during network stalls) or authenticated admin SSH:
|
||||
|
||||
```text
|
||||
web diagnostics clear
|
||||
web diagnostics enable
|
||||
web diagnostics show
|
||||
```
|
||||
|
||||
Enable before reproducing the failed third connection. At failure, run `web diagnostics show` promptly, alongside existing `memory`/`web counters`/browser Network timing evidence; repeat the snapshot if admission remains stalled. Then `web diagnostics disable` freezes event retention (live occupancy continues updating); `web diagnostics show` prints retained history, and `web diagnostics clear` erases history/counters without changing live sockets or enable state. The setting is RAM-only and defaults off after boot. Existing browser-shell policy deliberately denies these commands, even though shared completion offers the fixed forms. There is no HTTP diagnostic endpoint.
|
||||
|
||||
- Six connection records are maintained from boot even with capture disabled, so enabling on an already loaded server does not mislabel existing sockets. Each successful TLS connection gets a monotonically increasing, non-wrapping, firmware-lifetime 64-bit `conn` sequence independent of fd, cookie, ticket, broker or user identity. Clear, disable and HTTPS restart do not reset it. Internal TLS object identity is used only during synchronous cleanup; no pointer is exported or dereferenced by the console.
|
||||
- Snapshot output gives its boot-relative `snapshot_us`, post-TLS occupancy split ordinary/serial-WS/admin-WS and each live fd/connection sequence/open time/age. `kind=0/1/2` means ordinary/serial/admin. Ordinary includes every successful TLS socket not yet observed as upgraded, including idle keep-alives, assets/login/settings and sockets not yet used for HTTP. Classification is actual public `httpd_ws_get_fd_info()` state after upgrade-handler return, **not inferred from `ESP_OK`**. No URI string is inspected.
|
||||
- The fixed 32-entry overwrite ring records successful TLS open, TLS transport-context close, and enter/result for serial/admin ticket and upgrade handlers. `t_us` is boot-relative observation time; result `dt_us` measures the underlying handler only (including its work/IO, excluding entry resource sampling); close `dt_us` is successful-TLS connection lifetime. Open/enter durations are zero. Open/result occupancy includes the connection; close occupancy excludes it. Event IDs survive clear; `overwritten` counts evicted retained records since clear. `unmatched` counts duplicate create/unmatched close; `lost` counts untrackable creates (getter failure, metadata/sequence exhaustion). Nonzero anomaly counters mean occupancy is not trustworthy as complete evidence. Counters saturate.
|
||||
- `rc` is the exact handler return, **not HTTP status or ticket issuance outcome**: sending a 401/403/503 can return `ESP_OK`. Match browser HTTP status and existing rejection/issuance counters; there is no ticket-value correlation, request ID, authentication identity, header/body/query logging, or allocation-failure attribution. Upgrade success is visible in the occupancy classification. Ring overwrite or clear/toggle during an operation can leave unpaired enter/result records; do not invent a duration for a missing result.
|
||||
- Each retained event samples free/largest bytes for internal 8-bit, internal DMA and PSRAM 8-bit, plus the current HTTPD task's minimum-free stack **in ESP-IDF bytes**. Capability scans run outside the diagnostic lock. Samples are sequential, not an atomic heap snapshot; overlapping internal/DMA pools must not be added. Stack watermark includes diagnostic call overhead and is not dispatcher margin. `show` does not query a live task handle: resources are historical event samples, not fresh heap values at show time. Use `memory` for current system heap.
|
||||
- Console snapshots copy only local diagnostic metadata under a short portMUX, never inspect HTTPD session internals or wait for its owner. Printing occurs outside the lock. At most 32 ID-qualified rows and six live records are printed, even during churn; concurrent clear/overwrite is reported as “no longer retained”. Capture epochs reject samples crossing enable/disable/clear, and sequence checks prevent a stale upgrade result from reclassifying a reused fd. No queued diagnostic work exists, hence no outstanding probe or stale queue lifetime to retire on restart. Minimal connection bookkeeping and upgrade classification remain active while disabled; heap scans and event recording do not. Instrumentation still has CPU/static RAM cost, not zero perturbation.
|
||||
|
||||
### SDK audit and explicit blind spots
|
||||
|
||||
Audited installed PlatformIO ESP-IDF **5.5.0**, `components/esp_https_server/src/https_server.c` (`httpd_ssl_open`, `httpd_ssl_close`), `components/esp_http_server/src/httpd_sess.c` (`httpd_sess_delete`) and `httpd_main.c`. HTTPS performs synchronous TLS creation before the configured `open_fn` and `user_cb` create callback. It installs a transport-context destructor; that destructor invokes the public close callback before deleting TLS/freeing the context. HTTPD's default close closes the fd, then clears contexts, then frees its session slot. The diagnostic close therefore marks a cleanup observation, not a FIN timestamp or causal close reason. It uses the stored fd, not a getter on an already-closed socket. **Neither `open_fn` nor `close_fn` is replaced**, preserving all existing HTTPS cleanup ownership and failure behavior. Callbacks execute synchronously under the existing HTTPD lifecycle; successful stop finishes cleanup before restart, while failed/partial stop retains remaining live metadata. No asynchronous fd-only events are consumed, avoiding event-delay/fd-reuse ambiguity.
|
||||
|
||||
This deliberately bounded first slice does **not** measure TCP connect/accept/listen backlog, pending clients when IDF stops accepting at capacity, handshake begin/duration/failure, TLS allocation failure or aggregate lwIP socket pressure. The public configured open hook is post-TLS too; adding it would not fix these blind spots. No owner-queued client-list probe is added. Occupancy is an owner-published **successful-TLS lower bound**, not the complete HTTPD session table while a handshake is in progress. A clean **6/6** snapshot supports established-connection saturation at that instant (three WS + three ordinary is directly distinguishable); fewer than six does not exonerate admission/TLS/global socket pressure. Correlate time with browser evidence and existing secret-free TLS errors; do not call this pre-TLS tracing or claim the root cause is proven. Existing capacities, receive/send/handshake timeouts and accepted admission issue remain unchanged.
|
||||
|
||||
### Diagnostic validation and target follow-up
|
||||
|
||||
Focused host harness compiles the entire production module plus the four actual server wrappers with deterministic public-API fakes. Twelve groups cover disabled bookkeeping, routes/return preservation/actual WS state, six-slot saturation, duplicate/stale/reused fd, clear/toggle publication races, full ring/exact overwrite, formatted-output secrecy, bounded show/clear interleaving, stale upgrade completion, partial stop/restart, invalid callbacks/commands and sequence/counter exhaustion; one additional source-guard group checks forbidden APIs and callback wiring. Real TLS/network scheduling, heap/stack values and target stalls are not simulated. Server lifecycle harness additionally checks callback configuration while preserving all 16 existing groups; canonical console lifecycle and policy verify CLI dispatch and unchanged browser restrictions.
|
||||
|
||||
Final implementer-run validation (all PASS):
|
||||
|
||||
| Command | Evidence |
|
||||
|---|---|
|
||||
| `python3 tests/web_diagnostics/run.py` | 12 runtime groups + 1 production integration/secrecy guard group |
|
||||
| `python3 tests/web_admin_transport/server_lifecycle.py` | 16 lifecycle groups; callback configured, budgets/timeouts preserved |
|
||||
| `python3 tests/admin_console_boundary/lifecycle.py` | Actual canonical diagnostic dispatch plus existing stop/certificate/reboot cases |
|
||||
| `python3 tests/admin_ssh_policy/run.py` | Actual IDF parser; four new diagnostic forms denied to WEB, accepted by SSH |
|
||||
| `python3 tests/admin_console_boundary/run.py` | Shared dispatcher, owner/currentness, completion, deferred certificate and SSH adapter regressions |
|
||||
| `python3 tests/admin_console_boundary/accounts.py` | Canonical account/key transactions, parser/curve validation, sparse keys and CLI parity |
|
||||
| `python3 tests/web_cookie_auth/run.py --admin` | Shared cookie policy and combined admin ticket-to-101 integration |
|
||||
| `python3 tests/web_cookie_auth/run.py --accounts` | Shared cookie policy and existing phase8D11 account/key route regressions |
|
||||
| `python3 tests/web_admin_transport/run.py --tickets` | 25 transport groups + 12 ticket groups |
|
||||
| `python3 tests/web_ui_session/run.py` | 68 browser behavior groups + production renderer/HTML/CSP checks |
|
||||
| `python3 tests/web_session_store/run.py --serial` | Store lifecycle/races and serial/session binding/isolation |
|
||||
| `pio run` | Final production source build **10.30 s**, **99,156 B RAM / 1,707,597 B flash** |
|
||||
| `git --no-pager diff --check` | PASS |
|
||||
|
||||
Firmware delta versus recorded pre-instrumentation phase8D11 **96,076 B RAM / 1,703,685 B flash**: **+3,080 B RAM / +3,912 B flash**. `xtensa-esp32s3-elf-nm -S .pio/build/esp32-s3-devkitc-1-n16r8/src/web_diagnostics.c.o` verifies **2,816 B ring** (32 × 88), **192 B live table** (6 × 32) and **8 B portMUX**; remaining static metadata/alignment contributes to the build delta. `xtensa-esp32s3-elf-objdump -d` on that same target object gives local entry frames: handler **144 B**, record **144 B**, TLS callback **160 B**, show **528 B**, command **32 B**. These exclude callees and are **not** end-to-end stack margin proof. No task/stack-size increase or diagnostic dynamic allocation. Static RAM is paid even while disabled; this matters against the already low observed internal/DMA minima. Capability scans can perturb enabled timing and memory pressure correlation, so compare with disabled runs rather than treating this instrumentation as free. Initial full build also emitted installed SDK FATFS Kconfig boolean-default notes; all production builds succeeded.
|
||||
|
||||
Exact changed-file inventory for this diagnostic task (key implementation untouched):
|
||||
|
||||
- Production: `src/web_diagnostics.c`, `src/web_diagnostics.h`, `src/web_server.c`, `src/web_console.c`, `src/console_completion.c`, `src/CMakeLists.txt`.
|
||||
- Tests: `tests/web_diagnostics/run.py`, `tests/web_diagnostics/fakes.h`, `tests/web_diagnostics/test.c`, `tests/web_diagnostics/README.md`, `tests/web_admin_transport/server_lifecycle.py`, `tests/admin_console_boundary/lifecycle.py`, `tests/admin_ssh_policy/run.py`.
|
||||
- Docs: `docs/phase8d11_implementation.md`, `docs/agent/current-state.md`, `docs/agent/code-map.md`, `docs/agent/architecture.md`. No change to durable design decisions was needed beyond documenting the new observation module in the architecture/code map.
|
||||
|
||||
Target pending: enable before full-mix reproduction; capture both successful and failed third admission with browser status/timing, ordinary-vs-WS occupancy and memory evidence; check live counts on disconnect/stop/restart, disabled capture and clear; retain UART0/USB recovery and one broker writer throughout. Compare enabled/disabled timing and memory under the same load. No new hardware validation, reserve approval, timeout diagnosis or phase sign-off is claimed.
|
||||
|
||||
## Scope and contracts
|
||||
|
||||
- Accounts settings lists selected-account key types, stable slot indices and SHA256 fingerprints; imports OpenSSH Ed25519/P-256 public-key text and explicitly deletes one slot or clears all keys.
|
||||
- `user_database.{c,h}` provides a zero-wait fingerprint-only snapshot and conditional key mutation wrappers. Username/account ID/auth generation are checked under the canonical database mutation lock. Existing CLI mutation, persistence, duplicate/capacity, curve validation and SSH authentication semantics are retained.
|
||||
- `web_account_settings.{c,h}` admits strict JSON and uses the existing account-operation slot and administration dispatcher. Successful mutations best-effort revoke only the target's web/SSH sessions; currentness checks remain authoritative. Self changes can invalidate the response/result login: 401/disconnect proves neither success nor cancellation. Inspect after relogin before retrying.
|
||||
- Optional **POST `/api/settings/accounts/keys`** accepts `{username,user_id,auth_generation}` and returns `{username,user_id,auth_generation,keys:[{index,type,fingerprint}]}`. This read uses POST to retain the existing bounded JSON/Origin/CSRF policy rather than introduce query parsing. Stale/absent selection returns 409; unavailable snapshot returns 503. No public-key blobs, verifier material or private keys are returned.
|
||||
- Existing POST `/api/settings/account-operation` adds `key-add` with `public_key`, `key-delete` with `key_index` (0–2), and `key-clear`. All carry target identity. Request bodies remain **768 bytes/four receives**, public-key text at most **384 decoded bytes**, decoded SSH blob at most **128 bytes**, three keys per account. Envelope/base64/schema rejection occurs at admission; canonical blob/curve validation runs on the dispatcher. Results remain bounded, secret-free, replaceable and session-bound; no mutation auto-retry.
|
||||
- UI confirms mutations, clears pasted text on submission/context changes, uses text-only rendering and fences stale navigation/session/target work. Completion uses existing bounded polling followed by account/key refresh. Stable key slots may be sparse: option values and deletion confirmation resolve by actual index, not array position.
|
||||
- `web_server.c` registers the listing route independently through the allocation-safe optional POST helper. **24 handlers**, six sockets/no LRU; no new tasks, stack-size increases, queue-depth changes or broker lease changes. Larger operation/local structures still have runtime stack costs requiring target measurement.
|
||||
- No private-key upload/export, SSH host-identity management, browser-shell restriction changes, UART0 recovery changes or generated asset regeneration.
|
||||
|
||||
## Validation
|
||||
|
||||
Parent integrated runs passed:
|
||||
|
||||
- `python3 tests/admin_console_boundary/accounts.py`: canonical key validation/authorization, Ed25519/P256, malformed/off-curve/truncated keys, zero-wait snapshots, stale ID/generation/recreation, duplicate/capacity, sparse slots, failed persistence and CLI parity. Crypto host adapters use OpenSSL; this is not target SSH validation.
|
||||
- `python3 tests/web_cookie_auth/run.py --accounts`: shared authentication policy and account/key route admission/execution tests.
|
||||
- `python3 tests/web_ui_session/run.py`: initial integrated 64 groups and subsequent sparse-slot production fix, plus renderer/HTML/CSP checks. Final test-only extension independently passed **68 browser groups** (agent-run), including sparse `[1]`/`[0,2]`, deletion/automatic refresh, and invalid/duplicate indices.
|
||||
- `python3 tests/web_admin_transport/server_lifecycle.py`: **16 groups**, including isolated optional-route failure and restart recovery (dependencies faked).
|
||||
- `pio run`: final production build **PASS, 14.85 s**, **96,076 B RAM / 1,703,685 B flash**; **+168 B RAM / +9,448 B flash** versus recorded final 8D.10. The later four UI test groups change no firmware source.
|
||||
- `git diff --check` passed for the integrated production change; the final test extension also passed its scoped check.
|
||||
|
||||
Backend agent additionally reports passing console boundary `run.py` and cookie `--serial-settings`. Independent source review identified the sparse-slot UI assumption; corrected production selection/validation and added dedicated regressions. No other concrete review findings were reported. Host tests are not real network/concurrency/stack-margin proof.
|
||||
|
||||
## Pending target checklist
|
||||
|
||||
1. Import real Ed25519 and P-256 public keys, compare fingerprints with CLI/ssh-keygen, authenticate with corresponding private keys, and verify existing password authentication remains available.
|
||||
2. Exercise maximum input, malformed/private-key text rejection, duplicates and full three-key capacity. Delete slot 0 while later slots survive; list/delete/clear sparse slots and re-add successfully.
|
||||
3. Mutate another account through browser and CLI; verify target revocation, unrelated sessions retained, persisted keys after reboot, stale selection rejection and failed-operation recovery.
|
||||
4. Exercise own-account key changes: immediate revocation and uncertain lost acknowledgement, relogin/inspect without automatic replay. Confirm last-key removal does not imply password removal.
|
||||
5. Run full supported serial/admin mix, preserving one writer and isolated observers; record heap/internal-DMA largest blocks, HTTPD/dispatcher stack margins and cleanup/repeated-operation behavior.
|
||||
6. Obtain user target sign-off separately. No upload, erase, hardware test, sanitizer run or commit was performed by this task.
|
||||
@@ -1,144 +0,0 @@
|
||||
# Phase 8D.12/8D.13 — Typed Network settings
|
||||
|
||||
## Status and scope (2026-09-08)
|
||||
|
||||
The user authorized both phases together. Backend and admin-only Settings/Network UI are implemented: **8D.12** delivers secret-free STA/AP/profile projections, non-secret edits, explicit persistence and mDNS; **8D.13** adds explicit Wi-Fi password replacement/disabled-STA clear and manager-owned connection controls. This supersedes older wait-for-8D.12 statements, not previous scoped acceptance. No 8D.14 work, M3 completion, target sign-off or numeric memory reserve approval is claimed.
|
||||
|
||||
Source authority: `src/web_network_settings.{c,h}`, `wifi_manager.{c,h}`, `wifi_config.{c,h}`, `mdns_service.{c,h}`, `mdns_config.{c,h}`, `admin_ssh_console.{c,h}`, integration in `web_server.c`/`src/CMakeLists.txt`, and authored `web_ui.c`. Contract/test details: `tests/web_network_settings/README.md`, `tests/web_ui_session/network.cjs`, cookie Network tests and server lifecycle tests. This documentation handoff changes no source, tests, generated assets or commands. Browser-shell Wi-Fi/mDNS restrictions are unchanged; typed routes do not grant general command execution.
|
||||
|
||||
## Presentation refinement — 2026-09-08
|
||||
|
||||
On user request, Accounts and Network adopt Serial's compact label/value grids, form styles, help typography and action spacing. Account/key/Network summaries are semantic definition lists populated with DOM text; all information remains available. Printable ASCII SSIDs remain quoted, other bytes use hex, and value cells preserve significant spaces while wrapping. Key textarea, generated-password field and checkboxes share form styling. IDs, request/operation behavior, warnings, secret clearing and terminal/lease ownership are unchanged.
|
||||
|
||||
Parent validation PASS: UI **100 behavior groups**, HTML structure/renderer/header/CSP, and headless Chromium geometry/whitespace regressions at **320/600/1200px**. Review's SSID space-collapse issue was corrected and regression-tested. Final `pio run` **23.69s**, **99,548 B RAM / 1,744,325 B flash**, **+0/+1,744** versus the preceding readable-ASCII summary build (1,742,581 B flash); diff check PASS. These are fixture-based browser checks, not on-device visual acceptance. No backend/generated-asset/upload/erase/commit action.
|
||||
|
||||
## Routes, authorization and isolation
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/settings/network` | Secret-free working/runtime snapshot |
|
||||
| GET | `/api/settings/network-operation` | Latest result for initiating login |
|
||||
| POST | `/api/settings/network-operation` | One typed operation |
|
||||
|
||||
All require a current admin cookie/principal. Normal users are denied, including direct API access. Existing duplicate-header, framing and Fetch-Metadata protections apply. GET is bodyless; all routes reject queries. GET permits absent Origin but rejects a supplied mismatch. POST requires matching Origin, CSRF and exactly `application/json` or `application/json; charset=utf-8`. JSON responses are no-store, nosniff and no-referrer. There is no credential-export endpoint.
|
||||
|
||||
The three added method/path handlers bring the configured budget to **27 handlers**, with **six sockets**, LRU policy unchanged. Registration is optional and staged: snapshot failure skips Network operation registration; operation GET failure skips POST; POST failure unregisters operation GET while retaining the snapshot. This preserves unrelated Settings, login/status, serial and admin routes rather than making Network a base-service startup dependency. Snapshot reads do not depend on successful secret-timer admission. Optional transport failures retain their existing independence. Host lifecycle coverage is not proof of live low-memory behavior.
|
||||
|
||||
## Complete bounded snapshot
|
||||
|
||||
The JSON object has exactly these domains/fields:
|
||||
|
||||
| Object | Fields and meanings |
|
||||
|---|---|
|
||||
| `wifi` | `generation` (nonzero uint32), `enabled_at_boot` (boolean), `ap`, `profiles` |
|
||||
| `wifi.ap` | `policy` (`off`, `fallback`, `always`), `channel` (1..11), `ssid` (byte string), `password_configured` (boolean) |
|
||||
| each `wifi.profiles` entry | `index` (stable 0..3), `enabled` (boolean), `priority` (0..255), `security` (`mixed`, `wpa3`), `ssid`, `password_configured` |
|
||||
| `runtime` | `started` (boolean), `state`, `active_profile` (-1 means none, otherwise 0..3), `ip` (dotted IPv4 string), `ap_running` (boolean), `ap_clients` (count), `last_error` (numeric `esp_err_t`) |
|
||||
| `mdns` | `generation` (nonzero uint32), `suffix`, `hostname` (without `.local`), `announced` (boolean), `last_error` (numeric `esp_err_t`) |
|
||||
|
||||
All four profiles are always present. Runtime states are `stopped`, `starting`, `connecting`, `waiting-ip`, `online`, `backoff`, `ap-only`, `error`, with `unknown` fallback. `mixed` means WPA2-or-stronger, not open or a WPA2-only guarantee. `announced` is expected STA announcement state, not client-verified DNS.
|
||||
|
||||
Wi-Fi working configuration and runtime are copied together under the Wi-Fi mutex; mDNS is a separate consistent projection, **not an atomic cross-domain snapshot**. Both acquisitions are zero-wait. Contention/unavailability returns 503 `snapshot_unavailable`, never inferred partial values. HTTPD performs no driver/NVS call or secret-bearing configuration read. Neither projection structs nor JSON contain saved PSKs or PSK lengths; only `password_configured` is exposed to support staging/enabling validation.
|
||||
|
||||
### SSIDs are reversible bytes, not JSON Unicode text
|
||||
|
||||
SSID limits are **0..32 decoded bytes**. AP and enabled STA SSIDs must be nonempty. Empty STA SSID requires disabled status and no password.
|
||||
|
||||
The wire codec accepts printable ASCII, standard single-character JSON escapes (`\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`) and case-insensitive `\u00HH`; each decoded codepoint is one byte. Raw non-ASCII, non-byte Unicode, surrogates and malformed escapes are rejected. Snapshot encoding uses `\u00hh` for nonprintable/non-ASCII bytes, quote and backslash. Thus `"A\u0000\u00ff"` represents `41 00 ff`, including embedded NUL and non-UTF-8 bytes.
|
||||
|
||||
The UI offers **UTF-8 text** and **literal hex byte pairs**. It UTF-8-encodes text before byte-preserving JSON serialization; it does not submit raw JS Unicode strings as SSIDs. Existing bytes enter text mode only after fatal UTF-8 decoding and exact re-encoding (including BOM preservation), with control bytes excluded; otherwise hex is selected. Failed conversion preserves the original input. Hex accepts byte pairs with optional single spaces; the decoded limit remains 32 bytes. Summaries display printable ASCII SSIDs as quoted text (empty SSID as `""`), with exact hex fallback when any byte is outside ASCII 0x20..0x7e. Quotes/backslashes are escaped for unambiguous display; rendering uses DOM text, not HTML. No silent replacement decoding, double encoding or truncation is intended.
|
||||
|
||||
## Complete POST contract
|
||||
|
||||
One flat JSON object, at most 13 distinct keys; unknown/duplicate fields are rejected. No nested config, arrays, nulls, signed/fractional/exponent integers or leading-zero numbers. Booleans are JSON booleans. Every optional patch field preserves the current value when omitted; patches require at least one patch field. A request selects one domain/target only.
|
||||
|
||||
| `action` | Required fields besides `action` | Optional fields |
|
||||
|---|---|---|
|
||||
| `wifi-patch` | Wi-Fi `generation` | `enabled_at_boot`, `ap_policy` (`off/fallback/always`), `channel` (1..11), `ssid`, `password`, `clear_password:true` |
|
||||
| `profile-patch` | Wi-Fi `generation`, `profile` (0..3) | `enabled`, `priority` (0..255), `security` (`mixed/wpa3`), `ssid`, `password`, `clear_password:true` |
|
||||
| `wifi-save`, `wifi-load` | Wi-Fi `generation` | none |
|
||||
| `start`, `stop`, `reconnect`, `next-profile` | none | none |
|
||||
| `mdns-set` | mDNS `generation`, `suffix` | none |
|
||||
| `mdns-save`, `mdns-load`, `mdns-defaults` | mDNS `generation` | none |
|
||||
|
||||
Generation is the selected domain's nonzero uint32 snapshot value. Replacement password is **8..63 printable ASCII bytes**; empty replacement is invalid. Omission means Keep, never clear. Replacement and clear cannot coexist; `clear_password:false` is rejected. A disabled STA password can be cleared, including a single patch that disables and clears. Enabled STA requires a valid password. AP clear is canonically invalid **even with AP policy off**; no open-AP path exists. Syntactically admitted but canonically invalid requests can return 202 followed by `invalid`.
|
||||
|
||||
### Ownership, concurrency and persistence
|
||||
|
||||
HTTPD validates/adopts a bounded request; **only its operation ID** enters the existing administration dispatcher. The dispatcher rechecks initiating session/principal/admin currentness and dequeue deadline, then invokes canonical APIs. The existing Wi-Fi manager task remains the radio/event/mDNS-transition owner; no second driver owner or generic job executor is added.
|
||||
|
||||
Wi-Fi patch checks generation, merges omitted fields against current secret bytes and validates the whole candidate **under the configuration mutex**. Required restart queue admission precedes publication; queue failure leaves RAM unchanged. Generations do not wrap/reuse. CLI applies and local Start/Stop participate, so stale browser edits cannot undo newer state. Save holds the selected generation stable under the mutex during canonical persistence. Load reads only the existing canonical stored blob and conditionally installs it; missing, invalid/incompatible or failed storage does not generate/install a new AP secret or change RAM.
|
||||
|
||||
Edits are RAM-only until explicit Save. Disabled-profile-only edits do not restart the radio; enabling/disabling and enabled-profile/AP changes follow canonical asynchronous restart policy. `enabled_at_boot` alone changes next-boot policy, not immediate radio state. Start/Stop also change RAM `enabled_at_boot`; Save persists that choice. Reconnect/Next are no-ops when stopped. **Next profile** means the next enabled profile in canonical priority order, wrapping. The UI profile selector chooses the configuration to edit, **not the profile to connect to**; it labels the connection action Next profile rather than promising explicit-index selection.
|
||||
|
||||
mDNS has its own mutex/generation and conditional Set/Save/Load/Defaults. Suffix is 1..55 lowercase ASCII letters/digits/hyphens with no leading/trailing hyphen; hostname is `sak-<suffix>`. Set/Load/Defaults change RAM and request manager-owned reannouncement; Save persists. Load may select deterministic MAC-derived defaults and reports that outcome. Offline edits are applied to an already-initialized responder on the next STA IP. mDNS is STA-only and failure is nonfatal. A RAM change followed by reannouncement queue failure is not rolled back. Existing NVS remains unencrypted; logical clear/replacement is not secure flash erasure.
|
||||
|
||||
## Admission, result states and secret lifetime
|
||||
|
||||
Successful POST returns HTTP 202; GET returns HTTP 200. Both contain exactly `id`, `action`, `state`, `error`, for example `{"id":42,"action":"profile-patch","state":"pending","error":0}`. Only the initiating login can retrieve the slot. Other logins/no retained result see `{"id":0,"action":"none","state":"idle","error":0}`. No query ID or history exists: UI compares acknowledged ID/action. Later admission replaces the previous result. IDs never wrap; exhaustion denies admission until reboot.
|
||||
|
||||
| State | Meaning |
|
||||
|---|---|
|
||||
| `idle` | No result retained for this login |
|
||||
| `pending` | Queued or executing |
|
||||
| `accepted` | RAM apply/owner queue request accepted; **not** association, DHCP, online, completed radio transition or verified DNS |
|
||||
| `ok` | Explicit Wi-Fi/mDNS Save succeeded |
|
||||
| `failed` | Canonical/owner/storage failure |
|
||||
| `cancelled` | Queued expiry or session/currentness/dequeue deadline denial before canonical admission |
|
||||
| `stale` | Selected generation mismatched |
|
||||
| `invalid` | Canonical configuration rejected patch/load |
|
||||
| `loaded_defaults` | mDNS Load selected deterministic RAM defaults and queued reannouncement |
|
||||
| `applied_not_queued` | mDNS RAM changed but reannouncement queue failed; refresh, do not assume rollback |
|
||||
|
||||
`error` is numeric `esp_err_t`, not arbitrary input/error-text echo or a state override; cancellation can have zero error. Later runtime errors appear in fresh snapshots, not by rewriting `accepted`.
|
||||
|
||||
Existing HTTP errors include 400 framing/query/body errors, 401 authentication, 403 Origin/CSRF/admin denial, and 503 auth unavailable; unsupported handler methods return 405. Backend errors are 400 `invalid_network_request`, 503 `timer_unavailable`, 503 `busy` with `Retry-After: 1`, and 503 `snapshot_unavailable`. Malformed input never queues; unread-body/receive failures close rather than drain.
|
||||
|
||||
One static session-bound pending/result slot has an executing reservation under a short portMUX. One firmware-lifetime **one-second ESP timer** cancels and wipes non-executing inputs at **30 seconds plus scheduling latency**. Shared input wipes on dequeue before auth checks; dispatcher-local inputs wipe on every return. HTTP body/parser/operation inputs wipe on rejection and before response IO. Already-admitted work may finish after logout/disconnect/deadline: no hard cancellation, transactional session-liveness or hard wall-clock erasure guarantee. Expired IDs cannot execute a replacement operation. Queue entries never carry credentials.
|
||||
|
||||
## UI behavior and connection-loss safety
|
||||
|
||||
Network is an admin-only Settings subview with strict snapshot/result shape validation, independent request ownership and stale/session/navigation fencing. Apply submits changed fields for the selected target. Refresh discards drafts; Save persists device working state, not unsubmitted browser inputs. Stale/unavailable snapshots disable mutation instead of inferring values.
|
||||
|
||||
Passwords are never fetched/prefilled: explicit Keep/Replace/Clear, with Clear restricted to disabled STA. Replacement input has a **60-second context-bound browser lifetime** and best-effort clearing on expiry, context/navigation/session change, refresh, submission and rejection. This does not promise secure erasure of immutable JS/browser copies. AP clear is unavailable in UI and denied by canonical validation.
|
||||
|
||||
Disruptive Start/Stop/Reconnect/Next/Load, AP changes and enabled-profile changes require explicit confirmation and recovery warnings; mDNS Load/Defaults confirm replacement of working state. Persistence and hostname consequences remain explicit. No automatic mutation replay. After an acknowledged POST, checks run at one-second intervals, at most ten GETs/15 seconds, with session checks; known terminal results refresh the snapshot. Manual Check Result/Refresh handles pending, replaced or uncertain results. A lost acknowledgement may leave the latest result attributable to an earlier request/another tab; an unknown ID must not be treated as proof of completion.
|
||||
|
||||
There is **no same-response delivery guarantee**: HTTPS, SSH and both browser WebSockets can disconnect before the POST acknowledgement or result arrives. `accepted`, a lost response, 401 or disconnect proves neither online nor cancellation. Reconnect through the available STA/AP address and inspect state before retrying. Changed hostname requires DNS verification and browser trust/login review at the new origin; host-only cookies do not move with the name. UART0 remains administrative recovery; native USB remains network-independent UART1 access, not a replacement admin console.
|
||||
|
||||
Settings navigation itself does not close terminals, release writer ownership or reconfigure UART1. Hidden terminal draining and selected-keyboard rules remain. Actual network disruption can close network transports and consequently release their broker client/lease; it does not intentionally stop the serial service or USB. Do not claim uninterrupted network serial delivery across a radio restart.
|
||||
|
||||
## Resources and evidence
|
||||
|
||||
- POST maximum **768 bytes**, at most **four receives**, **13 keys**, **64-byte parser value scratch**; no heap JSON tree.
|
||||
- Snapshot buffer **2,048 bytes**; backend maximum escaped fixture has **1,877 payload bytes** (fixture bound, not runtime heap measurement).
|
||||
- Result buffer **128 bytes**; **one slot and one small persistent timer**.
|
||||
- **27 handlers/six sockets**; no task count, task stack size, dispatcher item size, queue depth or persisted schema growth. Added state/timer/buffers are not zero-cost: runtime timer heap, internal/DMA/PSRAM floors, allocation overhead and HTTPD/dispatcher stack margins remain pending.
|
||||
|
||||
Reported evidence, not reruns by this documentation agent:
|
||||
|
||||
- Backend agent: Network backend and cookie Network suites PASS; its contract README records additional cookie/settings/account/admin and canonical console regressions. Backend P3 queue-drop-counter finding fixed, preserving failed queue-admission observability.
|
||||
- UI agent: **97 groups plus renderer/CSP checks PASS**, review PASS.
|
||||
- Integration/lifecycle agent: **21 groups PASS**.
|
||||
- Backend sanitizer attempt could not link because host ASan/UBSan libraries were missing; no sanitizer pass claimed.
|
||||
- **Final parent integrated validation PASS:** `python3 tests/web_network_settings/run.py` (five production-path groups), cookie `--network` (five Network groups plus shared auth), `--accounts`, `--serial-settings`, `--admin`; canonical console boundary `run.py` and `accounts.py`; server lifecycle **21**; browser UI **97 plus renderer/CSP**; idle cleanup **18 + SDK guards**; admin transport **25**/tickets **12**; session-store `--serial`; diagnostics **12 + integration/secrecy**; `git diff --check`. The backend queue-drop projection finding is fixed and covered in these reruns. Independent backend and UI reviews reported no other actionable findings.
|
||||
- Parent `pio run` **PASS, 24.99 s**, **99,548 B RAM / 1,742,437 B flash**, **+288 B RAM / +36,656 B flash** versus accepted legacy-cleanup build (99,260 / 1,705,781). Earlier integration-only build was 99,548 / 1,718,721 before final UI; it emitted a nonfatal `FATFS_PRINT_FLOAT` boolean-configuration warning. The final parent incremental build did not emit it. No unrelated configuration change was made.
|
||||
- UI agent measured authored rendered HTML **23,184 B (+5,245)** and app.js **86,535 B (+23,843)**. These are uncompressed renderer sizes, not separate target heap measurements. Generated embedded vendor assets were not regenerated.
|
||||
- No upload, erase, hardware validation, commit, heap reserve or target sign-off. Real association/DHCP/AP transitions, mDNS announcement, concurrent radio-owner behavior and HTTPD/dispatcher stack floors remain pending; host manager tests exercise extracted production paths with driver/scheduler/storage doubles rather than a full real radio loop.
|
||||
|
||||
## Pending target checklist
|
||||
|
||||
Record revision/browser/client mix and only nonsecret evidence. Prepare UART0 and native USB before deliberate network disruption; use disposable profile changes with an explicit recovery plan. This is a procedure, not completed validation.
|
||||
|
||||
1. Verify admin-only UI and direct-route normal-user denial, missing/wrong Origin/CSRF, expiry/logout, unavailable snapshot and malformed/boundary fields. Check no PSK/value/length leakage through JSON, UI summaries, logs, completion or local display.
|
||||
2. Round-trip printable, UTF-8, BOM, control/NUL, non-UTF-8 and maximum 32-byte SSIDs in text/hex; verify failed conversions preserve drafts. Test Keep/Replace/disabled-STA Clear, combined disable/clear, enabled-STA and AP-clear denial, expiry/context changes and failed submissions without stored-secret prefill.
|
||||
3. Race browser generations against CLI/local Start/Stop and another tab. Exercise queue saturation/drop accounting, stale patches and save/load. Confirm failed admission leaves RAM unchanged. Test stored-only Wi-Fi Load with missing/invalid/read/commit failures without default-secret generation; distinguish working edits, explicit Save and reboot persistence.
|
||||
4. Exercise Start/Stop/Reconnect/Next and AP policies, stopped no-ops, canonical priority/wrap, and editing versus connection selection. Cancel confirmations. Deliberately lose acknowledgements/results, revisit Settings and use Check Result/Refresh without replay. Verify STA/AP recovery and UART0/USB availability.
|
||||
5. Exercise live and offline mDNS Set/Save/Load/Defaults, stale generation, queue failure (`applied_not_queued`), responder init/live failures and next-STA-IP reconciliation. Verify actual client DNS withdrawal/reannouncement, changed-hostname trust/login and separate IP/name origins; `announced` alone is insufficient.
|
||||
6. Keep USB, two browser serial clients, SSH serial, and both admin routes active where possible. Check one writer/isolated observers, hidden output draining and no navigation-induced serial disruption. Separate expected losses from actual network changes from unrelated serial/broker regression; capture broker drops and transport errors, not merely UI responsiveness.
|
||||
7. Exercise optional route registration/allocation failure and stop/restart isolation on target where fault injection is available; retain base login/status, serial/admin and other settings. Confirm timer-unavailable admission fails safely and no queued stale ID mutates newer work. Delayed dispatcher/scheduler behavior is not a hard-cancellation test guarantee.
|
||||
8. Measure settled boot/full-mix internal/DMA/PSRAM free/minimum/largest blocks, memory floor during TLS/admission and Network reads/writes, timer/slot overhead, repeated-operation cleanup and soak. Capture **HTTPD and administration-dispatcher stack high-water margins**, not SSH alone; no stack/task/queue increase is authorized by this checklist. Completed parent build/tests and pending user target acceptance remain separate evidence.
|
||||
|
||||
## Explicit exclusions
|
||||
|
||||
No specific-index connection selection (only canonical Next), Wi-Fi reset/default generation, AP-open mode, secret export/fetch, durable operation history/idempotency, cancellation endpoint, generic jobs/command runner, scans or new diagnostics workflow. No display settings/8D.14, M3 completion, browser-shell policy widening, new commands, generated-asset changes, schema migration, task/stack/queue expansion, factory erase or new security hardening. Accepted legacy-cleanup startup correction is documentary only: `main.c` independently gates SSH on Wi-Fi plus SSH security/runtime readiness, not HTTPS identity readiness.
|
||||
@@ -1,80 +0,0 @@
|
||||
# Phase 8D.2 Implementation Record
|
||||
|
||||
Status (2026-09-05): **Implemented / host-tested / build-verified / target validated by user sign-off.** Post-flash boot and full-client-mix samples are recorded below. Numeric reserve gates remain open. 8D.0 and 8D.1 remain validated by user sign-off. No 8D.3 implementation or browser authentication cutover is claimed.
|
||||
|
||||
## Validation Sign-off
|
||||
|
||||
The user explicitly marked **8D.2 validated** on 2026-09-05 after supplying post-flash boot and full-client-mix evidence. This closes the phase checkpoint. Unrecorded detailed checks and unexplained observations below remain regression coverage and evidence limitations, not blockers to this sign-off or claims that additional tests were executed. Numeric reserve approval and target validation of future cookie-authentication paths are not implied. Do not begin 8D.3 without a separate request.
|
||||
|
||||
## Scope and Behavior
|
||||
|
||||
- Started from clean `93eef0e67641f2672c56692a7785e50f31cf236d`; changes remain uncommitted. No unrelated worktree changes were present. No branch import, upload, erase, NVS change, commit or web-asset regeneration.
|
||||
- Source changes are confined to `web_serial_transport.{c,h}`, `web_session_store.{c,h}` and the existing Basic ticket call in `web_server.c`. Existing `user_console.c` committed-mutation calls and `web_console.c` legacy synchronization calls already reach `web_serial_transport_revoke_user`; no duplicate notifications or command-policy edits were needed.
|
||||
- Four serial ticket records and two socket slots now retain a `web_session_id_t`, distinct from account ID/authentication generation and transport-slot generation. Nonzero IDs come only from the session store's non-reused 64-bit sequence. Zero explicitly identifies the still-shipped Basic path, not a missing-cookie fallback.
|
||||
- Trusted internal mint and upgrade interfaces accept an originating ID. Mint checks both session liveness and exact copied principal ownership; consume requires the same originating ID and consumes before currentness validation. A mismatched-session attempt is rejected without consuming the other session's ticket. Basic upgrade cannot consume a bound ticket. All existing serial framing, writer admission, capacities and Basic behavior remain intact.
|
||||
- Session currentness includes deadline, store readiness, copied-principal binding and authoritative database currentness. Checks run at mint/consume, serial admission boundaries, before input/writer-control dispatch and in the existing 250 ms owner reconciliation. Database calls remain outside both portMUX locks. No CSRF value is exported to the transport.
|
||||
- `web_serial_transport_revoke_web_session(id)` invalidates that store ID first, wipes only matching tickets and marks only matching reserved/active slots for HTTPD-owned close and existing broker cleanup. Repeated stale cleanup cannot match a newly issued session in a reused slot. HTTPD close-queue failure retains the close flag and retries through the existing owner path; input remains denied.
|
||||
- Account-name revocation invalidates all matching cookie records, tickets and reserved/active sockets, including after deletion. It invalidates the store even if serial initialization failed. Global revocation invalidates all records without disabling the store and now marks reserved as well as active slots. Basic cache hits continue authoritative DB checks rather than cross-task cache mutation. Username-scoped notifications intentionally cover old and newly recreated identities of that name; other names are untouched.
|
||||
- A bounded 64-bit transport epoch cancels ticket publication across revocation or detach/re-attach. It never wraps; exhaustion rejects future minting until reboot. As with store issuance, an unrelated concurrent revocation may conservatively reject an in-flight mint, but never removes another session's existing socket. Direct store invalidation/expiry and missed account notifications still fail closed through authoritative checks; no notification is an authorization lease.
|
||||
- Existing secret-free ticket reject/consume/expiry, store invalidation/rejection, close and queue-failure counters cover these paths. No session ID, cookie, CSRF, digest, password or ticket is added to routine snapshots/logs.
|
||||
|
||||
## Resource Accounting
|
||||
|
||||
Same release N16R8 environment, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0. Figures compare the final build against the recorded 8D.1 and 8D.0 builds, not a new runtime measurement.
|
||||
|
||||
| Resource | 8D.2 | Change From 8D.1 | Cumulative From 8D.0 |
|
||||
|---|---:|---:|---:|
|
||||
| Linked static RAM | 95,260 B | +56 B | +728 B |
|
||||
| Program flash | 1,601,925 B | +1,420 B | +1,952 B |
|
||||
| Serial ticket array | 384 B, 96 B x 4 | +32 B | +32 B |
|
||||
| Serial slot array | 3,328 B, 1,664 B x 2 | +16 B | +16 B |
|
||||
| Transport publication epoch | 8 B | +8 B | +8 B |
|
||||
| Cookie store and lock | 664 B; records 152 B x 4 | 0 B | +664 B |
|
||||
|
||||
Target object symbols (`xtensa-esp32s3-elf-nm -S --size-sort`) and ELF DWARF (`gdb` `sizeof`) verify array/record sizes. The six added IDs plus epoch explain the entire +56 B linked RAM delta. Store `resolve` now survives linker GC through transport currentness, along with principal-binding/account-name invalidation dependencies. Issue/lookup and session-specific logout remain without production HTTP callers; future linkage costs are not included as though all M1 code were live.
|
||||
|
||||
- **Heap/PSRAM:** No added dynamic allocation, payload, allocator fallback or per-session heap cost in normal or worst-case execution of these changes. Static internal RAM grows by the reported 56 B. Runtime free/minimum/largest-block deltas are unmeasured, not inferred from static RAM.
|
||||
- **Tasks/stacks:** No new task or stack-size change. Existing static web transport stack remains 6,144 B; dynamic HTTPD stack remains 10,240 B. Bound-session checks use the existing resolver's 152 B transient record copy; target disassembly reports a 192 B resolver frame, excluding callees. Additional ID/epoch locals use existing stacks. This is not a measured peak-stack delta or approved reserve margin; bound paths are dormant on Basic traffic.
|
||||
- **Scratch/queues/capacity:** Existing ticket response 96 B and request scratch/payload limits are unchanged. Four one-hour cookie sessions, four 30-second tickets, two serial sockets, one pending TX work item per slot, nine routes, six HTTPS clients and sixteen lwIP descriptors remain unchanged. No TLS buffers or connection limits change. Full-ticket earliest-expiry eviction is deliberately retained until 8D.3's atomic policy cutover.
|
||||
- Numeric internal-heap/largest-block/stack floors and incremental budgets remain pending. No new floor is invented and no runtime regression is accepted based solely on linked size.
|
||||
|
||||
## Executed Validation
|
||||
|
||||
- `python3 tests/web_session_store/run.py`: **PASS**, production store public APIs with OpenSSL SHA-256 and deterministic dependency doubles.
|
||||
- `python3 tests/web_session_store/run.py --serial`: **PASS**, includes the preceding suite plus production serial private ticket/admission/input/currentness/close/broker-cleanup steps with the production store linked separately. Uses `-Wall -Wextra -Werror` and finite compilation/execution timeouts.
|
||||
- Integration coverage: same-account session-specific versus account-wide cleanup, unaffected account isolation, matching-ID/single-use tickets, all principal fields, stale tickets/principals, expiry, DB failure and direct store invalidation without notification, serial-init failure, close-queue retry, old-session cleanup after slot reuse, stale periodic check after generation change, logout during mint and broker admission, and Basic admission/binary input with the store disabled. The first integration compile caught a misleading-indentation warning in its test double; corrected before passing runs.
|
||||
- `python3 tests/web_session_store/run.py --serial --sanitize`: **BLOCKED at link**, missing `/usr/lib64/libasan.so.8.0.0` and `/usr/lib64/libubsan.so.1.0.0`; static sanitizer archives are also unavailable. No sanitizer execution/pass is claimed. Optional runner mode remains available on a provisioned host.
|
||||
- `pio run`: **PASS**, first build 8.05 seconds, final source build 7.92 seconds, each with a 120-second tool timeout; identical resource totals above.
|
||||
- `git diff --check`: **PASS**. Reviewed source/API diffs for lock ordering, identity mixing, failure isolation, late publication and generation-safe cleanup. No HTTP route or generated-asset change.
|
||||
- `pio device list`: completed; native device CDC and a USB serial adapter are visible. This changed firmware was not uploaded, and no target/browser test was run. Existing flashed firmware cannot validate this diff.
|
||||
|
||||
Host tests are deterministic dependency interleavings, not real multicore scheduling, UART byte-integrity tests or HTTPD network execution. They do not establish the planned one-second expiry-detection target under load, send/close delivery, stack reserve or heap stability. Session-specific logout/expiry via browser cookies remains dormant until 8D.3; no debug route was added to exercise it early.
|
||||
|
||||
## User-Provided Target Samples
|
||||
|
||||
The user reports flashing 8D.2 and collecting a clean-boot sample, followed by the full client mix. Exact flashed revision/hash, settling/load duration, browser/version/origin and fixture were not supplied. These are sequential user observations, not atomic measurements or agent-executed tests; they supersede the implementation-time statement above that no target run had been supplied.
|
||||
|
||||
| Workload | Internal free/min/largest | DMA free/min/largest | PSRAM free/min/largest | SSH stack minimum-free |
|
||||
|---|---|---|---|---|
|
||||
| Post-flash clean boot, UART stopped, no broker clients | 69,500 / 66,448 / 31,744 B | 61,744 / 58,692 / 31,744 B | 8,223,104 / 8,190,440 / 8,126,464 B | 18,472 B |
|
||||
| SSH writer + admin SSH + USB observer + two web observers; UART running at 115200 baud | 39,200 / 18,784 / 29,696 B | 31,444 / 11,028 / 29,696 B | 8,138,284 / 8,109,712 / 8,126,464 B | 16,296 B |
|
||||
|
||||
- **Both samples:** HTTPS/SSH initialized and running, not transitioning, ports **443/22**, `last-error=ESP_OK`. HTTP Basic via the user database, four users/two admins, unchanged endpoints. SSH role-based password/public-key authentication, shell/PTY-only admission; exec/subsystem/forwarding/SCP/SFTP disabled. SSH owner core **1**, configured stack **20,480 B**. mDNS initialized/announced, `sak-1024.local`, suffix `1024`, `ESP_OK`.
|
||||
- **Boot state:** SSH/WebSocket sessions **0/2**, web serial attached, zero tickets, no broker clients. UART stopped, owner idle, configuration v1 **115200 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**, RX available/TX pending **0**. Phase 0 commands reported available, not executed. USB initialized/attached, host-open/DTR/RTS **no**, broker disconnected; host line coding **9600 baud, 8N1**.
|
||||
- **Boot command anomaly:** The transcript contains repeated `ssh sessions` input, one usage response and `0x1 (ERROR)`, followed by successful `ssh sessions` and `ssh status` output. Preserve this as an unexplained console-input/command observation; neither user input error nor a firmware regression is established.
|
||||
- **Boot counters:** SSH starts **1**, all other supplied SSH counters **0**. Web starts **1**, start-failures/stops **0**; requests total/authenticated/status **10**, root/tickets/assets/auth-failures/response-errors **0**. All ticket/WebSocket session/RX/TX/control/failure counters **0**. Boot includes authenticated status activity.
|
||||
- **Loaded sessions:** SSH **2/2**, public-key user session **5**, slot **0**, broker **8 writer**; public-key admin session **6**, slot **1**, admin-console route without a broker. Both active/authenticated, admin-command idle, zero output, no RX/TX pending or closing state. Web **2/2**, same-account password-authenticated normal-user observers: slot **0**, fd **56**, generation **1**, broker **10**; slot **1**, fd **57**, generation **1**, broker **11**. No tickets, TX pending or closing state.
|
||||
- **Loaded broker/USB/UART:** Exactly four clients: SSH **8 writer**, USB **9 observer**, web **10/11 observers**, all with zero pending bytes/events. USB initialized/attached, host-open/DTR/RTS **yes**, broker **9 observer**, host line coding **115200 baud, 8N1**. UART running, owner serial service, unchanged **115200-baud 8N1** configuration, RX available/TX pending **0**, modem **DCD=0, DSR=1, CTS=1, RI=0**, **VLD=1**. Host line coding is diagnostic only, not UART1 configuration authority.
|
||||
- **Loaded SSH counters:** Starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, failures/disconnects **0**, writer requests/grants **1**, denials **0**, **broker revocations 1**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted **19 B**, rejected **0**, TX **24,763 B**, I/O failures/session revocations **0**. The cumulative broker revocation is distinct from session revocation; current SSH writer ownership is confirmed, but the event's cause is not supplied.
|
||||
- **Loaded web counters:** Starts **1**, start-failures/stops **0**. Requests total/authenticated **72**, root **1**, status **68**, tickets **2**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **2**, rejected/expired **0**. WebSocket connects **2**, disconnects/connect failures/service-start failures/broker failures **0**. RX frames/bytes accepted/rejected **0**. TX **223** binary frames / **33,171 B**, **5** control frames / **398 B**. Writer requests **2**, grants **0**, denials **2**, releases/revocations **0**; send/queue/protocol failures and closes **0**. Writer denials match observer admission.
|
||||
- **Comparison:** Versus the 8D.1 full-client-mix sample, free internal/DMA heap is **2,220 B lower**, free PSRAM **24,540 B lower**; minimum-free internal/DMA is **600 B lower**, PSRAM **5,316 B lower**. Internal/DMA largest block is **2,048 B smaller**; PSRAM largest block is unchanged. SSH stack minimum-free is **8 B higher**. These are snapshot differences with differing HTTP/TLS activity and unspecified timing, not a controlled incremental allocation measurement or proof of a leak. Cleanup/repetition evidence is still needed for trends; the static link delta remains **+56 B**.
|
||||
- **Acceptance scope:** Startup, authenticated status and full-client admission/traffic are evidenced, with no reported SSH I/O or web failures. Byte integrity, account-mutation revocation, HTTPS/lifecycle repetition, timed soak/cleanup and full 8D.2 sign-off remain unrecorded. Dormant cookie-bound paths remain host-tested rather than exercised by these Basic-auth samples. Numeric reserves are not approved by these measurements.
|
||||
|
||||
## Target Checklist and Handoff
|
||||
|
||||
1. After a user-controlled upload, record source/build identity, browser/version/origin and UART configuration. Settle for 60 seconds; collect UART0 `memory`, `web status`, `web counters`, `broker clients`, `usb status`, `ssh status` and `ssh counters`. Keep all internal/DMA/PSRAM free/minimum/largest values; do not sum overlapping DMA/internal measurements.
|
||||
2. Log in with each role using current Basic auth. Exercise binary serial data and request/release writer, then five explicit Disconnect/Connect cycles per role. Confirm paused reconnect, broker/writer cleanup, two-browser capacity and unaffected observers. Test mDNS and direct IP separately where available.
|
||||
3. With two browser serial clients for the same disposable account and another account on SSH, mutate password, role and SSH keys, and delete/recreate the disposable account through UART0. Confirm affected existing web sockets/tickets are rejected or closed, unrelated clients survive, and fresh credentials work. Do not change the final administrator or recovery credentials merely to test revocation.
|
||||
4. Stop/start HTTPS from UART0 and reconnect through Basic; confirm native USB UART1 and user/admin SSH remain usable throughout. Capture status/counters after five bounded restart cycles. Injection of store/transport init or close-queue failure is host-covered only unless a separately controlled target fixture exists.
|
||||
5. Run a 15-minute concurrent 115200-baud workload with two web clients, USB, user SSH and admin SSH; exercise UART0 recovery and verify traffic/counter integrity. Record memory and SSH stack minimum-free at full load, then disconnect clients/USB DTR, wait 60 seconds and capture cleanup. Compare with 8D.1's comparable workload, not different-baud historical samples; investigate new drops, queue errors, leaks or declining largest-block trends.
|
||||
6. Record target results and obtain 8D.2 sign-off before stacking 8D.3 runtime changes, unless the user explicitly decides otherwise. Carry browser cookie logout isolation, cross-origin binding, expiry latency and the complete M1 acceptance gate into separately requested **8D.3 only**. No work beyond 8D.2 was performed.
|
||||
@@ -1,164 +0,0 @@
|
||||
# Phase 8D.3 — Live browser authentication cutover
|
||||
|
||||
Status (2026-09-06): **Implemented / host-tested / build-verified / Phase 8D.3 and M1 validated by explicit user sign-off.** The user closed M1 after successful both-role login, mixed-client operation and the post-soak sample below. This supersedes older blocked/pending/in-progress statements in this record. 8D.0–8D.2 sign-offs stand; numeric reserve gates remain open. Wait for a separate 8D.4 implementation request.
|
||||
|
||||
## M1 validation sign-off and post-soak evidence (2026-09-06)
|
||||
|
||||
The user explicitly requested **M1 validation be marked completed**, supplying the following post-soak data after previously confirming both-role HTTPS login. This closes the 8D.3/M1 target checkpoint. Unrecorded detailed acceptance checks remain regression coverage/evidence limitations, **not blockers to this sign-off or claims that those checks were executed**. Numeric reserve approval, later M2 work and target verification of the subsequent admin-SSH empty-line fix are not implied.
|
||||
|
||||
### Post-soak sample — clients still connected
|
||||
|
||||
| Heap | Free | Minimum-free | Largest block |
|
||||
|---|---:|---:|---:|
|
||||
| Internal 8-bit | 38,656 B | 13,756 B | 25,600 B |
|
||||
| Internal DMA | 30,900 B | 6,000 B | 25,600 B |
|
||||
| PSRAM | 8,138,320 B | 8,072,744 B | 7,995,392 B |
|
||||
|
||||
SSH stack minimum-free remains **16,288 B**, configured 20,480 B. Compared with the earlier mixed-load sample, internal/DMA current free increased by **4,788 B** each and PSRAM free by **49,260 B**; all supplied minimum-free and largest-block values are unchanged. This is not a disconnected/60-second-cleanup sample or proof of leak freedom. DMA overlaps internal heap, and lifetime minima are not synchronized snapshots. No numeric safety floor is inferred from the 6,000-byte DMA minimum.
|
||||
|
||||
- UART1 remains running at **115200 8N1/no flow**, owner serial service. RX available 128 B and TX pending zero at its snapshot; modem state DSR/CTS asserted, valid voltage. Four broker clients: user web **8** observer, user SSH **9** sole writer (65 B pending), USB **34** observer and admin web **27** observer. All event queues zero. USB is attached/open with DTR/RTS asserted and host diagnostic coding 115200; changing USB broker ID from earlier 10 to 34 is retained without attributing a specific lifecycle event.
|
||||
- SSH remains **2/2**, user/admin public-key authentication and broker/admin-console route separation intact. Admin command/output idle, no RX/TX pending or closing for either SSH session. Two successful handshakes/auth attempts; all supplied handshake/auth/I/O/session-revocation/broker failure counters zero. Cumulative stream RX **242 B**, all accepted/none rejected; TX **1,550,114 B**. One historical writer denial is unchanged while current ownership remains SSH.
|
||||
- HTTPS remains running/ready with last error ESP_OK. Cookie sessions **2/4**, challenges **0/4**, web serial **2/2**, zero outstanding tickets. Both web sockets show TX pending and not closing, consistent with active traffic rather than a cleanup endpoint. mDNS remains announced as `sak-1024.local`, error ESP_OK.
|
||||
- Web protected requests **510**, authenticated **509**, auth failures **1**; root **4**, status **493**, tickets **4**, assets **8**, response errors **0**. Tickets issued/consumed **4/4**, rejected/expired **0**; serial connects/disconnects **4/2**, no connection/service-start/broker failures.
|
||||
- Web RX **1 frame / 14 B**, accepted with no rejection. TX **14,670 binary frames / 3,146,808 B**, control **21 frames / 1,813 B**. Writer requests/grants/denials/releases **4/1/3/1**, no revocations. Send/queue/protocol/close-failure-related counters remain zero. No broker-drop counters or independent end-to-end byte-integrity result were supplied.
|
||||
- Cookie authentication remains **6 password checks**, **3 invalid-credential results**, **1 logout**, and zero throttle/capacity/CSRF-or-Origin rejections. These cumulative counts are unchanged from the earlier successful mixed-client sample; no new login fault is inferred from the historical invalid-credential/request-auth counts.
|
||||
|
||||
**Evidence boundaries:** User describes this as after the soak test; exact duration, command/traffic pattern and flashed revision/hash are not explicitly supplied with this sample. Do not silently substitute the recommended 15-minute duration or assert a disconnected cleanup happened. The transcript shows actual continued activity and no reported transport error, watchdog or memory exhaustion. The empty-Enter fix's targeted on-device retest remains unrecorded; this does not reopen M1. No agent upload, device exercise or fresh build was performed to record this sign-off.
|
||||
|
||||
**Next:** M1 is complete. Continue only on a separate request for **8D.4 — small transport-neutral console boundary**, preserving the canonical dispatcher, serial ownership and open resource gates. Do not implement later M2 chunks merely because M1 was signed off.
|
||||
|
||||
## Successful post-fix target login and mixed-client sample (2026-09-05)
|
||||
|
||||
The user explicitly reports successful HTTPS login as both `commander1024` (user) and `admin` after the Origin-mode correction. This supersedes the earlier login-blocker statements below. The following are user-provided sequential snapshots, not agent-executed tests or an atomic measurement. Exact flashed revision/hash and settled duration for this new sample were not supplied.
|
||||
|
||||
| Heap | Settled boot free / minimum / largest (B) | Mixed load free / minimum / largest (B) |
|
||||
|---|---|---|
|
||||
| Internal 8-bit | 71,204 / 66,752 / 31,744 | 33,868 / 13,756 / 25,600 |
|
||||
| Internal DMA | 63,448 / 58,996 / 31,744 | 26,112 / 6,000 / 25,600 |
|
||||
| PSRAM | 8,247,744 / 8,245,836 / 8,126,464 | 8,089,060 / 8,072,744 / 7,995,392 |
|
||||
| SSH stack minimum-free | 18,464 B (20,480 B configured) | 16,288 B |
|
||||
|
||||
**Settled boot:** UART service stopped/owner idle at 115200 8N1/no flow; no broker clients or SSH sessions. USB initialized/attached but host closed/DTR false. HTTPS ready, sessions/challenges/tickets zero, all supplied web and SSH request/traffic/failure counters zero. mDNS initialized/announced as `sak-1024.local`, last error ESP_OK. The USB host's diagnostic 9600 coding does not configure UART1.
|
||||
|
||||
**Mixed-client load:** UART running at 115200 8N1/no flow. Four broker clients: web user 8 observer, user SSH 9 **sole writer**, USB 10 observer, web admin 27 observer. Public-key user/admin SSH both active (2/2); admin SSH has no broker client. Two password-authenticated web serial sessions and two cookie sessions, no outstanding challenges/tickets. USB host open/DTR/RTS asserted; diagnostic line coding 115200. RX available/TX pending zero at the serial snapshot; all four displayed broker pending/event queues zero. mDNS remains announced without errors.
|
||||
|
||||
- Two consecutive loaded `memory` samples are identical. This is short-term observation, **not** leak/soak/cleanup or reserve-floor validation. Lifetime minima include handshake/earlier activity; DMA overlaps internal heap. The **6,000 B DMA minimum** leaves runtime reserve analysis important even though current DMA free is 26,112 B. No stack fault/watchdog or memory exhaustion is reported.
|
||||
- SSH: two successful handshakes/auth attempts, no handshake/auth/I/O/session-revocation failures. Stream RX 73 accepted, zero rejected, TX 31,941 B. Broker initial writer request was denied once; later writer snapshot shows SSH owns the lease (no inconsistency inferred from cumulative counters). Admin command-running/output-pending fields were sampled while executing status commands, not proof of a stuck dispatcher.
|
||||
- Web: 97 protected requests, 96 authenticated and one auth failure; four roots, 80 status, four tickets, eight assets; zero response errors. Four tickets issued/consumed, zero rejected/expired. Four serial connects/two disconnects; two currently active. RX one accepted frame/14 B with no rejection; TX 495 binary frames/43,738 B and 15 control frames/1,255 B. Writer requests four, grants one, denials three, releases one; no revocations. No reported send/queue/protocol/service-start/broker/connection failures.
|
||||
- Cookie auth: six password checks, three invalid-credential results, zero throttle/capacity/CSRF-or-Origin rejections, one logout, two active sessions. Successful both-role login is explicit user confirmation; the counters also show logout/reconnection activity but do not establish five cycles, account-isolation coverage or logout acknowledgement delivery. The three invalid-credential results and one protected-request auth failure are retained without attributing a cause.
|
||||
|
||||
**Reported admin-SSH empty-line issue:** pressing Enter without text prints “Command is restricted to physical UART0.” Source trace identifies `remote_command_allowed()` classifying zero parsed arguments as a policy denial. Corrected that helper to allow empty input to reach IDF's normal quiet `ESP_ERR_INVALID_ARG` handling; existing current-admin/generation checks still run, and `user bootstrap`/`user recover` remain denied. No new dispatcher, route or 8D.4 refactor. `python3 tests/admin_ssh_policy/run.py` passes 15 policy cases with the production helper and installed IDF argument parser, including quoted restricted commands. `pio run` passes in **21.04 seconds**, **95,508 B RAM / 1,625,725 B flash** (+20 B flash versus Origin fix). This SSH fix is **not yet target-tested** and was not present in the user's sample. No upload/erase/commit performed.
|
||||
|
||||
Remaining: explicit M1 sign-off, repeated lifecycle/expiry/revocation/isolation and raw-client security checks, timed full-load soak and settled cleanup, numeric reserves and non-SSH owner stack margins. Do not reopen prior phase sign-offs or invent missing execution evidence.
|
||||
|
||||
## First target sample and login blocker (historical, user-provided, 2026-09-05)
|
||||
|
||||
After clean boot and 60 seconds settled, the user reports:
|
||||
|
||||
| Heap | Free | Minimum-free | Largest block |
|
||||
|---|---:|---:|---:|
|
||||
| Internal 8-bit | 69,004 B | 66,752 B | 31,744 B |
|
||||
| Internal DMA | 61,248 B | 58,996 B | 31,744 B |
|
||||
| PSRAM | 8,223,116 B | 8,218,204 B | 8,126,464 B |
|
||||
|
||||
SSH has 0/2 sessions, all supplied error/traffic counters zero, configured stack 20,480 B and minimum-free 18,464 B. HTTPS is running/ready with no lifecycle/response errors, cookie sessions 0/4 and challenges 0/4; eight protected requests were unauthenticated, with zero password-verification attempts, CSRF/origin rejections, logouts, tickets or serial sockets at this sample point. mDNS announces `sak-1024.local`. UART1 is stopped/owner idle, configured 115200 8N1/no flow; no broker clients. USB is initialized/attached but host-open/DTR false, broker disconnected. Its reported 9600 host coding does not configure UART1. These snapshots precede the reported login attempts; they do not establish post-attempt counters. Exact flashed revision/hash was not supplied.
|
||||
|
||||
**M1 is blocked:** subsequent login attempts for a normal user and administrator both show “The sign-in challenge expired or the request was rejected. Please try again.” Browser console reports blocked inline scripts with two hashes different from the application's login script hash, denied favicon by default-src, and a denied file URL. No browser login, loaded-memory or full M1 acceptance is claimed. These errors do not establish memory exhaustion or invalid passwords.
|
||||
|
||||
The production-renderer suite was rerun and the exact shipped inline-script hash still matches its CSP (`x70ID2kbifGBVYfh/pePTt5v/AVHkT7JVAV0LjT1wCo=`). The displayed login message maps to HTTP 403 in the running script; the console's other hashes may be injected-script warnings, not a reason to broaden CSP. Request-stage/status and the bounded error code plus nonsecret Origin/Fetch Metadata are needed to isolate the rejection. No corrective firmware change has yet been made for this target report.
|
||||
|
||||
## Confirmed Origin-null diagnosis and correction (2026-09-05)
|
||||
|
||||
Follow-up user evidence: `/api/login-challenge` returns 200, `/api/login` returns 403 with request `Origin: null`, `Sec-Fetch-Site: same-origin`, and the pre-login cookie present. `web status` reports ready, zero sessions/challenges/tickets, **zero password-verification attempts** and **seven CSRF/origin rejections**. This confirms the rejection occurs before password authentication; it is not evidence of wrong credentials. No secret values were requested or retained.
|
||||
|
||||
Cause: the authored fetch requests used non-CORS `mode: 'same-origin'` under `Referrer-Policy: no-referrer`; browser Origin-header serialization for these POSTs yields `null`. Corrected login fetch options to `mode: 'cors'` and the existing app API helper to use `cors` for POST (ticket/logout), retaining same-origin mode for app GETs. Fetch CORS mode is not permission for cross-origin service access: paths remain fixed same-origin endpoints, credentials remain `same-origin`, redirects remain rejected, CSP `connect-src 'self'` remains intact, and the server's strict Origin/CSRF checks/no-CORS-response policy are unchanged. The login script hash was updated atomically to `eZO4pMDQx6SIaa5AFlMnuf0CD5JdGSWyi8lNVmCNPBQ=`; existing app loader hash is unchanged because only its external app script changed.
|
||||
|
||||
Validation: login renderer/CSP eight Node groups, app nine Node groups, and cookie-policy suite all pass. Node guards assert CORS mode for every mutation (including logout), fixed endpoint destinations and no manually supplied Origin. They do not synthesize real browser Origin headers; Firefox/target retest is still required. `pio run` passed in **14.30 seconds**, **95,508 B RAM / 1,625,705 B flash** (RAM unchanged, flash +16 B versus the preceding live build). No upload/erase. Retest both roles, serial Connect/Disconnect/reconnect and Sign out; expect login POST Origin `https://sak-1024.local` (or the actual direct-IP origin), not null. M1 remains blocked until confirmed on target; other CSP warnings were not loosened or assumed resolved.
|
||||
|
||||
## Delivered behavior
|
||||
|
||||
- `web_cookie_auth.{c,h}` replaces Basic authentication/cache completely. Both roles use `/login` and the same serial/status application. No admin shell/settings routes were added. Previously cached Basic headers do not authorize a request.
|
||||
- Four digest-only authenticated sessions retain the existing store's copied principal, canonical-origin binding, CSRF state, non-reused ID and one-hour absolute lifetime. Traffic/polling does not renew expiry. Failure to initialize authentication prevents HTTPS start; UART0/USB/SSH implementations remain unchanged.
|
||||
- Four 120-second pre-login challenges contain only token/origin digests, CSRF state and expiry. Explicit login bootstrap requires `X-Login-Bootstrap: 1`; a matching live challenge is reused without extending its lifetime or resetting its cookie. Credential submissions consume the challenge, including wrong passwords. A global fixed window permits five password verifications per 60 seconds, including successes. Further attempts return 429 with Retry-After; no HTTPD sleep or per-IP/account table.
|
||||
- Session and pre-login cookies use `__Host-sak-session` / `__Host-sak-prelogin`, `Secure; HttpOnly; SameSite=Strict; Path=/`, explicit Max-Age 3600/120 and no Domain. A consumed challenge expires its cookie; successful login additionally sets a fresh session cookie. Login with a current authenticated cookie returns 409; account switching requires logout.
|
||||
- Mutations require canonical same-origin HTTPS Origin and CSRF; upgrade requires Origin and matching cookie/session/ticket. Host case and optional default port 443 normalize; non-443 ports, malformed authorities and IPv6 literals are rejected. Direct-IP and mDNS names remain distinct cookie origins. Cross-site/same-site Fetch Metadata requests are rejected (same-origin/none accepted); absent Origin is permitted only on read/bootstrap requests after Host validation.
|
||||
- Exactly username/password string fields, maximum 512-byte login JSON, decoded 16/64-byte limits. Unknown/duplicate fields, NUL and malformed Unicode fail. Header/body/request scratch is wiped; rejected unread bodies close instead of invoking HTTPD's automatic body drain. Login reads have a three-second application deadline plus existing socket wait bounds. API authentication responses are at most 512 bytes; safe username JSON encoding is explicit.
|
||||
- Full live session/challenge/ticket tables reject with 503 and Retry-After 5; serial-ticket earliest-expiry eviction is removed. Expired/stale tickets are reclaimed without database calls under the transport lock. Existing two serial sockets, one-writer broker model and binary protocol are unchanged.
|
||||
- Logout invalidates its originating session before acknowledgement and requests only its ticket/socket cleanup. Account mutation/revocation continues to invalidate all affected account sessions, not unrelated accounts. Mint/consume/admission/input and existing periodic owner checks remain authoritative if notification fails. Zero session ID no longer falls back to Basic.
|
||||
- Browser validates `/api/session` before initial connect/reconnect/restore; stores CSRF only in memory; adds Sign out and visible absolute expiry. 401 cancels work/closes local serial/navigates once to `/login`; explicit Disconnect still pauses reconnect without ending login. 403 mutation failures require explicit retry; capacity/backoff and network errors are not bad credentials. Lost logout response is reconciled with session status rather than claiming success. Pending fetch/socket callbacks are generation-guarded. Both authored inline scripts have exact CSP hashes; generated assets were not regenerated.
|
||||
|
||||
### Route boundary
|
||||
|
||||
| Route | Policy |
|
||||
|---|---|
|
||||
| GET `/login` | Public standalone no-store login page, no protected assets |
|
||||
| GET `/api/login-challenge` | Validated Host, bootstrap header, Fetch Metadata and any supplied Origin |
|
||||
| POST `/api/login` | Strict Origin, pre-login cookie/CSRF, bounded JSON and throttle |
|
||||
| GET `/api/session` | Current cookie session; username/role/CSRF/remaining seconds only |
|
||||
| POST `/api/logout` | Current session, strict Origin/CSRF, empty body |
|
||||
| GET `/` | Current session; unauthenticated navigation gets 303 `/login` |
|
||||
| GET five `/assets/` routes; GET `/api/status` | Current session; unauthenticated gets 401, not login HTML |
|
||||
| POST `/api/ws-ticket` | Current session, strict Origin/CSRF, empty body |
|
||||
| GET `/ws/serial?ticket=...` | Cookie/Origin authorization and ticket/principal/broker admission before explicit 101 |
|
||||
|
||||
No CORS/preflight compatibility or Basic fallback. Query strings outside the exact serial-ticket route and wrong methods are rejected. Error routes have bounded no-store responses. The login document itself also rejects malformed/duplicate cookies; manually corrupted cookies may require clearing those site cookies, unlike ordinary expired well-formed cookies.
|
||||
|
||||
## Verified HTTPD boundary and maintenance risk
|
||||
|
||||
The delivered solution is **not the previously proposed SDK patch**. `web_httpd_adapter.{c,h}` alone includes installed HTTPD private headers. `src/CMakeLists.txt` supplies private include paths; the adapter fails compilation unless `ESP_IDF_VERSION == 5.5.0`. No installed SDK source was changed and no full component was vendored.
|
||||
|
||||
Verified under `/home/mscholz/.platformio/packages/framework-espidf/components/esp_http_server/`:
|
||||
|
||||
- `src/httpd_parse.c`, `httpd_req_get_hdr_value_len/str`: return the **first** matching header only. Parsed fields occupy NUL-separated scratch, not a raw CRLF block. Adapter walks that bounded storage and rejects **all duplicate field names**, case-insensitively, plus malformed fields, control characters, Transfer-Encoding and Expect. This is stricter than general HTTP acceptance, deliberately fail-closed. Public getters are called only after validation and with terminator capacity.
|
||||
- `src/httpd_txrx.c`, `httpd_resp_set_hdr`: appends pointers, does not replace an earlier same-name field. Sending emits each entry; login retains its two cookie values until send returns. Success uses exactly **six of eight additional-header slots**. Tests extract the installed getters and append function rather than inventing their behavior.
|
||||
- `src/httpd_uri.c`: routes marked `is_websocket=true` send 101 before their handler. The application's serial URI is deliberately registered as an ordinary GET. After cookie/Origin checks, transport consumes the matching ticket and completes currentness/broker admission, then adapter calls `httpd_ws_respond_server_handshake()` and installs the existing transport frame handler. Failed pre-admission never sends 101; handshake/admission failure uses existing reserved-slot/broker cleanup. Tests stub the handshake send: real on-wire integration remains a target gate.
|
||||
- `src/httpd_txrx.c`, `httpd_unrecv/httpd_recv_pending`: pending bytes are **right-aligned**. The inherited adapter incorrectly wiped the unread suffix. This continuation fixes cleanup to wipe the consumed prefix while preserving unread bytes at the end, or wipe everything when closing. Regression exercises all 0–128 pending lengths and partial reads through the installed reader function. This prevents corruption of pipelined requests/early serial frames; it is not a claim of real socket execution.
|
||||
- HTTPD DEBUG logs include headers, and URI warnings can include ticket queries. HTTPD is compiled with `LOG_LOCAL_LEVEL=ESP_LOG_ERROR`; ERROR sites were inspected for secret-bearing content. This deliberately removes HTTPD warning/debug diagnostics regardless of runtime log-level changes. Application count-only authentication telemetry remains available via `web status`/`web counters`.
|
||||
|
||||
Private layout, frame dispatch and scratch ownership must be re-audited for an SDK update, including same-version local source patches (the guard checks the version, not source hashes). Do not distribute private-structure access into other application modules. Wiping reduces request lifetime, not all TLS/allocator/browser copies; do not claim resistance to RAM extraction.
|
||||
|
||||
## Resource accounting
|
||||
|
||||
Final `pio run` passed in **17.62 seconds** after the cleanup fix:
|
||||
|
||||
| Metric | 8D.2 / preparatory baseline | Live 8D.3 | Increment |
|
||||
|---|---:|---:|---:|
|
||||
| Linked static RAM | 95,260 B | 95,508 B | +248 B |
|
||||
| Reported program flash | 1,601,925 B | 1,625,689 B | +23,764 B |
|
||||
|
||||
Cumulative versus recorded 8D.0 build (94,532 / 1,599,973 B): **+976 B RAM / +25,716 B flash**. These are linked sizes, not runtime headroom.
|
||||
|
||||
- Target object symbol accounting: challenges **576 B (144 × 4)**, counters 32 B, lock 8 B, epoch 8 B, window 8 B, attempts 4 B, ready 1 B: **637 B before placement padding**. Removed Basic cache/key/readiness offset most of this; final link delta includes alignment/other changes. Existing session store remains present.
|
||||
- No new application task, task-stack size change, module heap allocation, queue, TLS buffer, accepted socket or lwIP descriptor limit. HTTPD URI capacity rises **9 → 14**, with five additional dynamically allocated handler records; HTTPD error handlers use its existing table. Six HTTPS clients and two web serial slots remain unchanged. LRU purge remains enabled; retained-serial admission protection is still an M2 concern.
|
||||
- Auth request locals include 513 B body/response scratch, 180 B cookie header, token/CSRF/canonical buffers, copied session/principal/challenge/credentials; cookie parsing has nested 1025 B header scratch. No task-stack reserve is inferred from source locals or static link size. Existing HTTPD stack is 10,240 B; real worst-case stack/TLS/PBKDF2/fragmentation measurements remain pending.
|
||||
- Existing xterm/logo data unchanged. Login page and enlarged authored app are now actually linked; their dormant-preparation flash numbers were not their live cost. Header slots remain eight; login success six, login renderer five.
|
||||
|
||||
## Executed validation
|
||||
|
||||
All ran successfully in this continuation:
|
||||
|
||||
```sh
|
||||
python3 tests/web_cookie_auth/run.py
|
||||
python3 tests/web_auth_parse/run.py
|
||||
python3 tests/web_login_ui/run.py
|
||||
python3 tests/web_ui_session/run.py
|
||||
python3 tests/web_session_store/run.py --serial
|
||||
pio run
|
||||
```
|
||||
|
||||
- Cookie policy suite compiles production store/parser/policy/adapter with OpenSSL SHA-256 and deterministic database/HTTPD doubles. Covers fragmented reads, challenge reuse/consumption/expiry/capacity, session-specific logout, throttle, duplicate headers/cookies, methods/Origin/CSRF/Fetch Metadata, Basic denial, currentness, failures/stop race, cookie header budget and explicit upgrade state. Installed IDF getter/setter/pending-reader functions are extracted verbatim. It does **not** execute the full IDF parser, TLS, URI dispatcher, network handshake or real tasks.
|
||||
- Parser suite: **268 cases**. Login renderer: production C failure/header checks and **eight Node groups**. Serial app: production C resource/header/CSP checks and **nine Node groups**. Node VM DOM/fetch doubles are not a real browser/CSP/bfcache test.
|
||||
- Serial integration mode includes store public-API tests plus transport binding/isolation/races and no Basic/no live-ticket eviction. No sanitizer pass is claimed.
|
||||
|
||||
No upload, erase, commit, branch change or target/browser exercise was performed. The preceding agent's changes were preserved except the focused pending-buffer fix/tests; its unrecorded executions are not evidence here.
|
||||
|
||||
## M1 target acceptance handoff — stop before 8D.4
|
||||
|
||||
Use the complete [M1 contract/checklist](phase8d_baseline.md#minimal-m1-browser-contract-planned) and [user acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration). At minimum:
|
||||
|
||||
1. Keep UART0 attached. Record flashed revision/configuration and settled-boot `memory`, `web status`, `web counters`, `broker clients`, `ssh status`. Confirm native USB and both SSH roles survive HTTPS stop/start and authentication failures.
|
||||
2. Test both roles, fresh and previously Basic-authenticated profiles, direct IP and mDNS. Wrong credentials, refresh/back, expiry/reboot, sign out/account switch and lost logout response must remain usable. Verify actual secure cookie attributes and CSP; never include raw cookies/CSRF/tickets/passwords in shared evidence.
|
||||
3. Five login/serial-disconnect/reconnect/logout cycles per role; five HTTPS stop/start cycles. Check session-specific logout across two sessions of the same account, and account password/role/key changes/deletion/recreation via UART0 while unrelated sessions survive.
|
||||
4. Challenge/session/ticket capacity without eviction; bounded throttle and retry. Raw-client missing/malformed/duplicate Origin/Host/Cookie/CSRF/content-type/framing tests. Verify an unauthorized or mismatched-ticket upgrade gets **no 101**; validate actual frame routing, early/pending bytes and close cleanup after admitted upgrades. These are especially important for the private adapter.
|
||||
5. Fifteen-minute full-client mix at 115200 baud (USB, two web serial clients, user SSH and admin SSH), then 60-second cleanup. Record internal/DMA/PSRAM free/minimum/largest block plus SSH stack margins at boot/login/serial/load/cleanup. Check binary integrity, writer isolation, drops and watchdogs. Measure the planned ≤1-second expiry/revocation detection target under contention separately from socket-close delivery.
|
||||
6. Numeric reserve floors and non-SSH owner-stack instrumentation remain pending. Obtain explicit M1 sign-off before adding the browser admin shell. Do not equate host tests/build success with target acceptance.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Phase 8D.4 - Small Console Boundary
|
||||
|
||||
Status (2026-09-06): **Implemented / host-tested / build-verified / Phase 8D.4 validated by explicit user sign-off.** The user confirmed successful empty Enter and soak testing after the boot/full-client-mix evidence. M1 and previous phase sign-offs stand. Numeric reserve gates remain open. No 8D.5 implementation or browser admin route is included.
|
||||
|
||||
## Validation Sign-Off (2026-09-06)
|
||||
|
||||
The user explicitly requested: "Mark Phase 8D.4 as validated - conforming empty enter and the soak test successful". This closes 8D.4 and the previously pending admin-SSH empty-Enter target retest, superseding older pending/in-progress statements in project records.
|
||||
|
||||
Soak duration, exact flashed revision, post-soak/cleanup telemetry and individual results for other detailed checklist items were not supplied. These remain evidence limitations, not blockers to this user-approved closure or claims that unreported tests were executed. No new agent build or device operation was performed for sign-off. Numeric reserves remain open. Next is **8D.5 only on a separate implementation request**; M2 as a whole is not yet complete.
|
||||
|
||||
## Scope And Contract
|
||||
|
||||
Starting revision: `f9ee6eec9cbe06fe5120ee718507eaffd69787a2`, initially clean worktree. Changed production files are `src/admin_ssh_console.{c,h}` and `src/ssh_transport.c`; focused tests are in `tests/admin_console_boundary/`. No console-input changes were necessary because its prompt routing already uses the shared dispatcher.
|
||||
|
||||
- `admin_ssh_console_open_owned()` accepts a copied transport-qualified token/principal and an immutable firmware-lifetime owner adapter. Existing SSH admission remains available through `admin_ssh_console_open()`, now implemented beside its owner in `ssh_transport.c`.
|
||||
- The adapter supplies nonblocking application-output drain checks and deferred lifecycle handling, called by the existing control task outside console locks. SSH validates slot/session/generation and uses existing transport control APIs, never runtime wolfSSH calls from the control task.
|
||||
- Two console slots remain shared, not two per transport. Occupied and still-executing slots reject replacement. Future admission must coordinate this pool; this phase does not promise simultaneous browser and full SSH admin capacity.
|
||||
- The token includes transport, slot, session ID and generation. Owners serialize input per session, exclusively consume output, maintain transport authentication/expiry, and close on disconnect/revocation. The core retains admission/dispatch principal checks and queued-work identity checks.
|
||||
- Completion scratch has a nonblocking claim so different owners cannot race the shared buffer outside the lock. A competing TAB remains unconsumed for retry; stale completion cannot publish into a reused slot.
|
||||
- Shell exit uses an owner-relative `SELF_CLOSE` action; existing STOP/DISCONNECT/key actions still mean SSH. Unsupported actions fail before side effects. Deferred input is rejected at both admission and feed, and identity is checked again after the final delay.
|
||||
- The same dispatcher, canonical registry, queue, editor/history/prompts, UART0 policy and output ring remain. Five-second output backpressure and ten-second deferred drain plus 200 ms remain bounded best-effort heuristics, not delivery confirmation.
|
||||
|
||||
No new tasks, browser endpoints, UI changes, serial/broker changes, generated assets, persistence changes, uploads, erases or commits.
|
||||
|
||||
## Verification And Resources
|
||||
|
||||
- `python3 tests/admin_console_boundary/run.py`: PASS for production console and extracted production SSH adapter with deterministic host fakes. Covers readiness/admission, cross-transport and stale identities, completion contention and close/reopen during completion, history, queued disconnect/revocation, UART dispatch, executing cleanup, hidden/visible/cancelled/disconnected prompts, exit-to-SELF_CLOSE routing, unsupported actions, queue rejection, drain timeout/delay, SSH action routing and slow output.
|
||||
- `python3 tests/admin_ssh_policy/run.py`: PASS, including quiet empty input and physical-only command restrictions.
|
||||
- Independent code review found no defects. `git diff --check`: PASS.
|
||||
- `pio run`: PASS in **43.61 seconds**, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0, N16R8 release. Linked RAM **95,084 B**, flash **1,627,173 B**. Against the recorded latest 8D.3 build (95,508 / 1,625,725 B): **-424 B RAM / +1,448 B flash**. Against recorded 8D.0 (94,532 / 1,599,973 B): **+552 B RAM / +27,200 B flash**. These comparisons use recorded builds, not a fresh baseline rebuild.
|
||||
|
||||
Static savings come from removing the full SSH snapshot scratch in the console and reading only the matching published slot under the SSH lock. Added costs are transport tags, owner pointers in sessions/control requests, the immutable adapter, and one completion-claim flag. No payload buffer or capacity increase. Two 4 KiB output rings, four-entry command queue, two-entry control queue, four-line per-session history and task stack sizes (dispatcher 12 KiB, UART frontend 6 KiB, control 4 KiB) are unchanged. No new module heap allocation, socket, TLS connection, HTTP handler, web session or ticket capacity. Runtime heap, PSRAM and stack margins were not measured; no reserve approval is inferred from linked RAM.
|
||||
|
||||
Host fakes do not establish actual FreeRTOS scheduling, task-local stdio redirection, real queue capacities, socket behavior or hardware regression. The command runner and lifecycle operations are doubled; direct production helpers test routing and output separately. Sanitizer execution was unavailable because the host lacks the required runtime library, not a sanitizer pass.
|
||||
|
||||
## Target Handoff
|
||||
|
||||
### User-Provided Boot And Full-Client-Mix Evidence (2026-09-06)
|
||||
|
||||
The user supplied UART0 transcripts labeled clean boot and full client mix after the 8D.4 implementation. Exact flashed revision, settling interval, traffic duration and byte-integrity comparison were not supplied. These are user target observations, not agent device execution or phase sign-off.
|
||||
|
||||
| Resource (bytes) | Boot free | Boot minimum | Boot largest | Loaded free | Loaded minimum | Loaded largest |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| Internal 8-bit | 71,512 | 60,344 | 31,744 | 34,632 | 20,648 | 25,600 |
|
||||
| Internal DMA | 63,756 | 52,588 | 31,744 | 26,876 | 12,892 | 25,600 |
|
||||
| External PSRAM | 8,247,940 | 8,241,988 | 8,126,464 | 8,089,284 | 8,077,516 | 7,995,392 |
|
||||
|
||||
SSH owner stack: 20,480 B configured; minimum-free **18,472 B boot / 16,280 B loaded**. Heap minimum-free values are conservative sums of matching regions' lifetime minima; internal 8-bit and DMA are overlapping capabilities, not additive pools.
|
||||
|
||||
- Boot: HTTPS/SSH initialized and running with ESP_OK, zero active SSH/cookie/WebSocket sessions and no broker clients. Startup failure and traffic counters zero. mDNS initialized/announced. UART service stopped with 115200 8N1/no flow configured; USB attached but host closed, DTR/RTS false and no broker client. Diagnostic USB host coding 9600 did not change UART1 configuration.
|
||||
- Loaded: UART service running at **115200 8N1/no flow** with no pending RX/TX in the sample. Four broker clients: SSH **8 sole writer**, USB **9 observer**, web **10/11 observers**. Two public-key SSH sessions active, one user/broker route and one admin/console route. Admin admission successful, idle, zero output and no RX/TX pending. USB host open with DTR/RTS true.
|
||||
- Both browser roles logged in successfully: two cookie sessions and two serial WebSockets, no outstanding tickets/challenges. Two login attempts, zero invalid credentials, throttle/capacity/CSRF/Origin rejections or logouts. HTTPS/SSH/mDNS still running/announced with ESP_OK.
|
||||
- SSH: two successful handshakes, zero handshake/auth/admission/I/O failures and zero admin input backpressure. Stream RX/accepted **13/13 B**, rejected **0**, TX **8,235 B**. Cumulative broker revocations **1** is retained without diagnosis; the current snapshot still shows SSH as sole writer and no session revocations.
|
||||
- Web: two tickets issued/consumed and two successful WebSocket connects; zero response, service-start, broker, send, queue, protocol or close failures. TX **105 binary frames / 15,760 B**, **5 control frames / 398 B**; no serial RX frames. Both writer requests denied while SSH owns the lease, consistent with observer admission rather than a transport failure.
|
||||
|
||||
Compared with the earlier 8D.3 mixed-load sample (not its different post-soak sample), current free internal/DMA memory is **764 B higher** and PSRAM **224 B higher**. Internal/DMA minima are **6,892 B higher** and PSRAM minimum **4,772 B higher**; largest blocks are unchanged. SSH stack minimum-free is **8 B lower**. Different transient histories prevent attributing these runtime deltas solely to the refactor or using them as reserve/leak proof.
|
||||
|
||||
These snapshots alone support startup, both-role login and concurrent transport admission/data activity, not detailed console regressions or soak/cleanup. The subsequent explicit sign-off above confirms successful empty Enter and soak testing and closes 8D.4; unreported checklist details remain evidence limitations.
|
||||
|
||||
The original target checklist below is retained for regression coverage, not as an outstanding gate to the signed-off phase:
|
||||
|
||||
1. Boot and capture UART0 `memory`, status and `ssh status`. Verify UART0 recovery and empty Enter/normal commands through admin SSH (empty Enter is now confirmed by user sign-off).
|
||||
2. Exercise UART0/admin-SSH serialization, completion/history, visible/hidden/cancelled prompts, and disconnect/revocation while work is queued or a prompt is active. Check reconnect/slot reuse does not receive old output.
|
||||
3. Exercise slow-reader backpressure and recovery, `exit`, empty-line Ctrl+D, and the existing deferred SSH stop/disconnect/key/reboot paths as appropriate. Confirm acknowledgement remains best effort and stale sessions cannot act on reused identities.
|
||||
4. Run the always-on browser login/serial explicit disconnect/reconnect, USB UART1 and user/admin SSH smoke. Repeat five serial lifecycle cycles per role and collect comparable settled/full-client-mix/cleanup `memory` and SSH stack telemetry at the supported 115200-baud workload.
|
||||
|
||||
No target checks above were executed by the agent. Exact next chunk is **8D.5: bounded admin WebSocket backend**, only when separately requested after the applicable validation decision.
|
||||
@@ -1,146 +0,0 @@
|
||||
# Phase 8D.5 — Admin WebSocket backend
|
||||
|
||||
Status (2026-09-06): **8D.5 backend implemented / host-tested / build-verified / validated by explicit user sign-off.** The user supplied settled cold-boot telemetry and reported a successful 15-minute full-client-mix active-use soak at 230400 baud, with a few broker drops under heavy output, and explicitly closed 8D.5. Final build: **23.55 s**, **95,580 B RAM / 1,637,273 B flash**. No 8D.6 UI work or M2 acceptance. Prior 8D.4/M1 sign-offs stand; numeric resource reserves remain open.
|
||||
|
||||
## Target Sign-Off (2026-09-06)
|
||||
|
||||
User reports: "With full client mix, running and active use for 15 mins, soaked, only a few dropped broker packets at 230400 baud with extremely fast and dmesg output. Mark 8D.5 as validated."
|
||||
|
||||
This is explicit phase acceptance and supersedes older pending/incomplete validation statements below and in project memory. The reported broker drops are preserved, not treated as zero-drop or byte-integrity evidence; no cause, exact count or affected client was supplied. This 230400-baud workload is distinct from earlier 115200-baud samples. No baud-rate or capacity reduction is made.
|
||||
|
||||
Settled cold-boot UART0 measurements supplied with sign-off:
|
||||
|
||||
| Heap (bytes) | Free | Minimum-free | Largest block |
|
||||
|---|---:|---:|---:|
|
||||
| Internal 8-bit | 70,876 | 59,560 | 31,744 |
|
||||
| Internal DMA | 63,120 | 51,804 | 31,744 |
|
||||
| External PSRAM | 8,246,360 | 8,242,140 | 8,126,464 |
|
||||
|
||||
SSH owner stack: **20,480 B configured / 18,472 B minimum-free**. Internal/DMA capabilities overlap; their free bytes are not additive. Minimum-free is the firmware's conservative sum of matching heap regions' lifetime minima.
|
||||
|
||||
- HTTPS and SSH initialized/running with ESP_OK, each with one successful start and zero startup failures. mDNS initialized/announced with ESP_OK.
|
||||
- Admin backend initialized/attached with ESP_OK, no active admin socket or tickets, and all admin counters zero. Reported transport static/ticket/PSRAM payload storage **167 / 240 / 1,552 B**, matching implementation accounting.
|
||||
- No SSH, cookie or serial WebSocket sessions, challenges, tickets or broker clients. Web/SSH traffic/authentication/failure counters zero at boot; this does not describe post-soak counters.
|
||||
- UART service stopped, RS-232 owner idle, configuration **230400 8N1/no flow**, RX/TX pending zero. USB initialized/attached but host closed with DTR/RTS false and no broker client. Diagnostic 9600 host line coding does not reconfigure UART1.
|
||||
|
||||
Exact flashed revision, settling duration, loaded/post-soak/cleanup memory and counters, individual client identities and detailed checklist results were not supplied. The full client mix and 15-minute successful soak are user-reported, not reconstructed from the idle boot sample. Missing details remain evidence limitations, not blockers to user-approved phase closure or claims of unreported test execution. Runtime per-admin-socket cost and numeric reserve approval remain open. No agent build, device operation or source change was performed to record sign-off. **Next is 8D.6 only on a separate request; M2 remains incomplete.**
|
||||
|
||||
## Combined backend completion
|
||||
|
||||
The user explicitly authorized finishing the entire interrupted 8D.5 implementation, superseding the prerequisite pause below. Extensive uncommitted source/tests were preserved: ticket store, transport, protected server routes, shared-console allocator and SSH mapping, command restrictions, revocation integration, diagnostics, transport regressions, lifecycle harness and local smoke client. This continuation reviewed them, added real authenticated endpoint integration tests and fixed the final admin socket close/reuse race. No upload, erase, commit, generated asset or normal UI change.
|
||||
|
||||
### Admission and protocol
|
||||
|
||||
- `POST /api/admin/ws-ticket` runs the existing strict cookie/Origin/CSRF mutation policy, then admin-role validation. Two digest-only, non-evicting, single-use tickets expire after 30 seconds and bind the originating session ID and full current password principal. Crypto/database checks are outside critical sections; epoch/generation checks reject stale publication, consumption and prune work.
|
||||
- `GET /ws/admin?ticket=<64 hex>` is an ordinary HTTP route, not an automatic WebSocket route. Cookie, strict Origin, current admin role, exact ticket shape/consumption, one transport-slot reservation and a free shared console slot precede explicit 101. Upgrade has no CSRF header requirement: the CSRF-protected ticket plus cookie/Origin authorizes it, including browser clients that cannot add custom WebSocket headers. Rejections never execute console commands.
|
||||
- Exactly one admin socket, two admin tickets and the existing two shared console slots. SSH now retains its allocated console index separately from the physical SSH slot and resolves published owner state by full identity. Busy/executing console slots cannot be replaced. Admin does not join the serial broker or obtain a writer lease.
|
||||
- Final unfragmented binary frames carry console input (maximum 512 bytes); binary output chunks are at most 1024 bytes. Text, fragmented, oversized and overlapping pending input fail closed. Partially consumed input has a five-second deadline, checked before retry. Consumed input/output and retired payload/console state are wiped. Empty binary frames must not invoke IDF's zero-length header probe twice.
|
||||
- Saturating lifetime admin counters and allocation sizes are available through `web status`/`web counters`, without token, CSRF, verifier or private-key disclosure. `web clear-counters` does not reset admin counters; diagnostics say so.
|
||||
|
||||
### Ownership and failure isolation
|
||||
|
||||
- One permanent 20 ms ESP timer schedules at most one HTTPD poll. It does no database, console, payload or socket work. No new application task/stack/dispatcher is created. Blocking HTTPD queue-work configuration makes the optional admin initializer fail closed.
|
||||
- HTTPD exclusively owns admission, frame input, payload mutation, output sends and session-context cleanup. Revocation/control callers only flag closure and close the generation-qualified console token. Authoritative session/principal checks guard admission, dispatcher execution/prompts, input, output and idle polls. These checks cannot roll back arbitrary already-running commands.
|
||||
- Detach disables admission/tickets and console access before fencing timer submissions for up to two seconds. Timeout retains the live HTTPD handle and requires a stop retry. Failed SSL stop retains admin ownership; only successful HTTPD stop permits clearing queued-work state and reattachment. Queued polls after detach do no IO; successfully stopped HTTPD cannot execute discarded work.
|
||||
- **Final lifecycle fix:** installed IDF 5.5.0 `httpd_sess_trigger_close()` queues a raw reusable `sock_db *`. A poll could queue closure, then a frame error free that slot and acceptance reuse it before the queued close executes, potentially closing an unrelated serial client. Admin polling now calls `shutdown(fd, SHUT_RDWR)` directly on HTTPD after checking its session context. HTTPD's next read owns deletion; there is no late queued close pointer. Failed shutdown retries on later polls, with send-failure accounting. This deliberately does not promise a graceful WebSocket close frame or peer delivery. The existing serial transport's use of IDF queued close was not changed; the new admin path cannot introduce this eviction route.
|
||||
- HTTPS retains six client sockets, now with LRU purge disabled, and grows from 14 to 16 URI handlers. Full socket capacity can delay/refuse new HTTP/TLS connections rather than evict a retained serial writer. Optional admin allocation/registration failure preserves M1 routes and serial attachment; failed optional-ticket unregister leaves an authenticated but unattached/unavailable ticket handler, not a bypass.
|
||||
- Logout invalidates its cookie session before serial/admin ticket/socket cleanup; account/global revocation follows the same order through the existing `web_serial_transport_revoke_*` integration hooks. Lost notifications still fail session/principal currentness. Unrelated session notifications do not close the admin socket.
|
||||
|
||||
### Temporary command restrictions
|
||||
|
||||
The parsed canonical command policy rejects unsupported actions before `esp_console_run()`, not after a handler has mutated configuration. From web: only `web status`, `wifi status`, `mdns status`; only bare `user`, `user status`, `user list`, `user show <name>` in the user group; no `reboot`, SSH stop/disconnect/reset or SSH host-key action except `ssh host-key info`. Thus web/network identity changes, all account mutations and one-time generated credentials remain unavailable here until the later lifecycle phase. Ordinary permitted commands, empty Enter and `exit`/empty-line Ctrl+D use the existing dispatcher/editor. Only owner-relative deferred self-close is supported by WEB. UART0 bootstrap/recovery remains physical-only; SSH policy otherwise remains unchanged. See the policy suite for quoted forms.
|
||||
|
||||
## Final local validation
|
||||
|
||||
All commands below were executed in this continuation and passed. Host compiler warnings are errors; tests are deterministic dependency interleavings, not real multicore execution.
|
||||
|
||||
| Command | Actual result |
|
||||
| --- | --- |
|
||||
| `python3 tests/web_admin_transport/run.py --tickets` | 19 transport groups plus 12 ticket groups; rerun after shutdown fix |
|
||||
| `python3 tests/web_admin_transport/server_lifecycle.py` | 11 groups, including all 16 required registration failure positions, two optional positions, failed unregister, failed stop/retry and six-socket/no-LRU configuration |
|
||||
| `python3 tests/web_cookie_auth/run.py --admin` | Real cookie policy, parser, store, tickets, transport and private adapter linked together; endpoint rejection before 101, cross-session replay burn, admission, isolated logout, missed account revocation, expiry and restart; rerun after fix |
|
||||
| `python3 tests/web_cookie_auth/run.py` | Existing cookie/HTTPD policy and embedded store regressions pass |
|
||||
| `python3 tests/admin_console_boundary/run.py` | Shared two-owner allocation, production SSH publication/mapping, queued currentness, prompts, deferred actions, history/completion and wiping pass |
|
||||
| `python3 tests/admin_ssh_policy/run.py` | SSH policy and browser restrictions using installed IDF parser pass |
|
||||
| `python3 tests/web_session_store/run.py` | Store API/failure/race suite passes with OpenSSL SHA-256 |
|
||||
| `python3 tests/web_session_store/run.py --serial` | Serial/session binding, revocation, races and non-eviction regressions pass |
|
||||
| `python3 tests/web_auth_parse/run.py` | 268 cases, zero failures |
|
||||
| `python3 tests/web_login_ui/run.py` | C/header/CSP checks and eight browser-behavior groups pass |
|
||||
| `python3 tests/web_ui_session/run.py` | C/header/CSP checks and nine browser-behavior groups pass |
|
||||
| `python3 tests/web_admin_transport/client.py --help` | Local import/CLI smoke only; no network/device operation |
|
||||
| `git diff --check` | Pass |
|
||||
|
||||
The combined endpoint harness doubles console execution/IO and the logout revocation hook (matching reviewed production ordering); the console harness separately runs real shared-console code. Installed HTTPD getter/setter/pending-reader functions are extracted, but TLS, handshake writes, actual HTTPD event processing and FreeRTOS are doubled. The server lifecycle harness extracts production lifecycle/table code, not live HTTPD. No sanitizer run/pass is claimed in this continuation; inherited harness notes record missing ASan/UBSan libraries. Manual client offline evidence in its README is inherited, not rerun here beyond `--help`.
|
||||
|
||||
### Firmware and resources
|
||||
|
||||
**Final build, parent-reported:** the necessary sequential `pio run` after the shutdown fix **passed in 23.55 seconds**, reporting **95,580 B linked RAM / 1,637,273 B flash** on the existing PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0, N16R8 release configuration. This verifies the final source, including the shutdown fix. Parent also reports the final independent security integration review found **no actionable findings**. This documentation-only follow-up ran no build or tests.
|
||||
|
||||
Historical build: the continuation's one 120-second-bounded `pio run` passed in **22.67 seconds**, at **95,580 B RAM / 1,637,277 B flash**, before the shutdown fix. Both affected production-C host suites passed after the fix; the parent's subsequent final build supersedes that earlier image for final-source verification and saves 4 B flash with unchanged linked RAM.
|
||||
|
||||
Final-image deltas (baselines not rebuilt):
|
||||
|
||||
| Baseline | RAM delta | Flash delta |
|
||||
| --- | ---: | ---: |
|
||||
| 8D.5 prerequisite: 95,164 / 1,628,049 B | +416 B | +9,224 B |
|
||||
| 8D.4: 95,084 / 1,627,173 B | +496 B | +10,100 B |
|
||||
| 8D.0: 94,532 / 1,599,973 B | +1,048 B | +37,300 B |
|
||||
|
||||
Target ELF/DWARF/map inspection, with no device access: admin payload **1,552 B PSRAM-only** (512 RX + 1024 TX + 16 metadata), slot **80 B**, ticket **96 B** x two, ticket state **232 B** + lock **8 B** = **240 B**. Transport static symbols total **167 B** before placement padding (168 B occupied); retained timer handle is included there. IDF `struct esp_timer` is **32 B**, allocated with internal/8-bit capabilities, excluding heap metadata. Payload/timer persist across HTTPS restarts; PSRAM allocation has no internal fallback. SSH adds two console-index bytes in published state and retains the prerequisite's 80 B principal copies. No added console rings, queue capacities or task stacks. Dynamic TLS/socket/request allocations, heap fragmentation, HTTPD/dispatcher/timer stack margins and internal/DMA reserves remain unmeasured; these static figures are not per-socket runtime cost or reserve approval.
|
||||
|
||||
## Target Regression Procedure
|
||||
|
||||
No target/network exercise was performed by the agent. The user's target sign-off is recorded above; this original checklist is retained as regression coverage, not as outstanding gates to that closure. Use the already present bounded, stdlib-only `tests/web_admin_transport/client.py`; full usage/security caveats are in its README. It prompts for credentials without echo, keeps cookies only in memory, never logs tickets/CSRF/credential metadata, and attempts logout in `finally`. Prefer trusted certificate/hostname validation; `--insecure` is explicit test-only exposure to active interception, not a local-routing guarantee. Its console output is intentionally raw terminal output: use a trusted target and do not record secret-bearing command output.
|
||||
|
||||
```sh
|
||||
python3 tests/web_admin_transport/client.py --url https://device.local --cafile device-cert.pem --smoke --max-runtime 60
|
||||
```
|
||||
|
||||
1. Have the operator flash the final build-verified image through the usual approved procedure. Record exact revision/diff, clean-boot and 60-second settled `memory`, `web status`, `ssh status` and broker/serial counters. Build verification does not establish target acceptance.
|
||||
2. Run the smoke client separately with disposable role-user and role-admin accounts. Require user ticket 403; admin cookie/ticket/101, same-ticket replay 403, `help`, empty frame, empty Enter and `exit`, then logout and session 401. Repeat five times per role. No automatic credential retry; respect the five/60-second throttle.
|
||||
3. Keep two browser serial sockets, USB and role-user SSH at 115200 baud, with a known sole writer; concurrently admit one admin SSH plus web admin. Verify the same broker client IDs/writer before and after web admin open/exit/failure. Attempt a second admin socket and fill both console slots with SSH before web admission: reject, never replace. Fill remaining HTTPS sockets; no serial eviction. Capture live TLS/heap cost rather than infer it from six configured sockets.
|
||||
4. With a temporary authenticated development client (not a firmware endpoint), test missing/foreign/null/duplicate Origin, missing/duplicate cookie, absent/wrong CSRF on ticket POST, expired/wrong-session/replayed tickets and direct user-role upgrade. Require rejection before any 101. Check raw responses without publishing auth headers or ticket URLs. The supplied smoke client only automates the documented subset, not this full negative matrix.
|
||||
5. Exercise shared command serialization with UART0 and admin SSH, completion/history, visible/hidden/cancelled prompts, disconnect/revoke/expiry while queued or prompting, and slow input/output. Use disposable secrets and approved existing non-restricted commands; do not type secrets into a retained browser developer-console history. Unsupported web lifecycle/account mutations must report rejection before any state change. The supplied smoke client is not interactive and does not claim prompt/completion coverage.
|
||||
6. Logout one session with serial+admin; only that session's sockets/tickets close. Change its disposable account from UART0/admin SSH, test deletion/recreation and let a session reach its one-hour absolute expiry. Verify unrelated sessions, queued-command rejection, no stale prompt/output after slot reuse, and no lingering reserved console slot after an executing handler returns.
|
||||
7. From UART0, repeat five HTTPS stop/start cycles with active admin and pending output; inject detach/queue/SSL-stop failures where feasible, retry stop and ensure no handle reuse until successful stop. Stress simultaneous peer disconnect and new serial admission during admin closure, specifically validating the shutdown/reuse fix. USB and UART0 must remain usable if HTTPS is unavailable.
|
||||
8. Run at least a 15-minute full-client-mix/slow-reader soak, collect free/minimum/largest internal/DMA/PSRAM and available stack telemetry, disconnect all optional clients, wait 60 seconds and compare cleanup figures. Numeric reserve floors and real per-admin-socket cost still need approval/evidence. Stop before 8D.6; M2 also requires separately requested 8D.6/8D.7 work.
|
||||
|
||||
## Historical prerequisite record
|
||||
|
||||
The sections below record the earlier prerequisite-only checkpoint. Their no-backend statements and request to pause were superseded by the combined backend authorization/results above; their old build measurements are retained as provenance.
|
||||
|
||||
## Scope and provenance
|
||||
|
||||
Resumed at revision `e5dce12ed43154dacd086437de0f2d156014d58c` with existing uncommitted prerequisite changes in `src/admin_ssh_console.{c,h}`, `src/ssh_transport.c`, and `tests/admin_console_boundary/`. Preserved and reviewed that work, extended the harness to exercise production SSH snapshot/principal publication and wiping, ran both console suites and the firmware build, and recorded the handoff.
|
||||
|
||||
The plan's work-unit review splits the full ticket store, socket owner, HTTP policy/routes and integration tests from this runtime-changing prerequisite. No browser route, ticket store, new task, UI entry, broker client, generated asset, persistence change or device operation is included. No commit or branch change was made.
|
||||
|
||||
## Implemented contract
|
||||
|
||||
- The immutable console owner adapter now requires `is_current(token, principal)`. It runs on the dispatcher outside console locks, validates full transport identity and originating-session/principal binding, and must not call socket libraries or console handlers. Admission and transport input/output liveness remain owner responsibilities; admission need not already be published to this callback.
|
||||
- Core checks account and owner currentness for queued work, again immediately before the canonical command runner, and after dispatch. Identity/owner are rechecked after external calls so late validation cannot close a replacement slot. UART0 remains independent.
|
||||
- Visible and hidden prompts check currentness before publishing and after each wait. Waits poll at 250 ms plus validation/scheduling latency, not a hard real-time deadline; stale semaphore wakes cannot submit a still-waiting prompt. Revoked submitted replies are not returned to handlers. Close wipes prompt input immediately, including submitted input; executing session storage remains reserved until handler cleanup.
|
||||
- SSH publishes two copied principals under the same lock as its snapshots. Its dispatcher adapter validates active authenticated admin route, transport/session/generation, close intent and full principal binding, without reading owner-task slots or calling wolfSSH. Consuming an external close preserves published close intent until cleanup.
|
||||
- Reading console output wipes consumed ring segments, including wraparound, while retaining unread output.
|
||||
|
||||
These checks do not cancel or roll back arbitrary executing handlers, nor make authorization atomic with subsequent side effects. Database currentness can wait on its mutex. A future browser owner must still enforce cookie-session expiry/logout/revocation at admission, input, output and periodic cleanup; console polling is not a replacement for transport cleanup.
|
||||
|
||||
## Executed validation and resources
|
||||
|
||||
- `python3 tests/admin_console_boundary/run.py`: PASS. Production console plus extracted production SSH token/publication/adapter code; covers stale owner with current account, unrelated-session isolation, close/reuse during validation, invalidation immediately before execution, revoked/disconnected submitted prompts, unanswered prompt expiry without notification, stale wakes, UART0 recovery, output wiping, published principal cleanup, route/auth/principal/identity rejection and external-close handoff. Existing completion/history, prompts, deferred control and backpressure regressions also pass.
|
||||
- `python3 tests/admin_ssh_policy/run.py`: PASS, including empty input, ordinary commands and quoted physical-only restrictions.
|
||||
- Independent static review of the production prerequisite found no actionable defects. Final `git diff --check`: PASS.
|
||||
- `pio run`: PASS in **38.46 s**, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0, N16R8 release. **95,164 B linked RAM / 1,628,049 B flash**. Versus recorded 8D.4 (95,084 / 1,627,173): **+80 / +876 B**. Versus recorded 8D.0 (94,532 / 1,599,973): **+632 / +28,076 B**. Baselines were not rebuilt.
|
||||
- Link map attributes 80 B (`0x50`) to `s_console_principals`. The owner callback adds code/read-only adapter storage, no per-session payload. Two shared console slots, two 4 KiB output rings, four-entry request queue, two-entry control queue, four-line history and task stacks (dispatcher 12 KiB, UART0 6 KiB, control 4 KiB) are unchanged. No new module heap/PSRAM allocation or increased socket/TLS/HTTP handler/session/ticket capacity; actual web-admin socket/slot cost is not yet available.
|
||||
|
||||
Host fakes are deterministic, not concurrent: locks are counters, waits use hooks, console execution/lifecycle operations are doubled. Production publication is now exercised, but the complete SSH owner loop, real FreeRTOS scheduling, task-local stdio, socket behavior and runtime memory/stack margins are not proven. No sanitizer or target pass is claimed.
|
||||
|
||||
## Target checkpoint and exact next increment
|
||||
|
||||
Before stacking the live backend on this runtime-changing prerequisite, obtain target regression or an explicit user decision to proceed under the plan:
|
||||
|
||||
1. Boot and capture UART0 status/`memory` and SSH stack telemetry. Confirm admin SSH empty Enter, commands, history/completion, visible/hidden/cancelled prompts and `exit`/Ctrl+D.
|
||||
2. Disconnect or revoke an admin SSH account with work queued and while a prompt waits; confirm prompt cancellation, dispatcher/UART0 recovery, no reply/output crossover after reconnect, and isolation of unrelated sessions. Only use disposable test accounts and approved mutations; do not publish secrets.
|
||||
3. Run browser login/serial disconnect/reconnect, native USB UART1 and user/admin SSH smoke at the supported 115200-baud workload. Repeat five serial lifecycle cycles per role and compare settled/full-client-mix/cleanup heap and SSH stack telemetry. Check slow readers do not compromise UART0 recovery.
|
||||
|
||||
Next implementation remains **8D.5**, not 8D.6: bounded admin-only digest tickets bound to current cookie session/principal; coordinated admission to the existing two shared console slots; a separate bounded admin transport using HTTPD-owned socket work; full pre-101 Origin/cookie/ticket admission; session/account/global invalidation and authoritative liveness checks; fail-before-side-effect restrictions for unsupported self-affecting commands; counters and focused authenticated integration checks. Review scope and socket/allocation budgets before coding and split further if needed. No normal UI entry or generic HTTP command runner. Admission must never evict a serial client/writer. Test real two-WebSocket coexistence before claiming M2 capacity or acceptance.
|
||||
@@ -1,107 +0,0 @@
|
||||
# Phase 8D.6 Implementation
|
||||
|
||||
## Status and Scope
|
||||
|
||||
Implemented, host-tested, build-verified and **validated by explicit user sign-off on 2026-09-06**. Prior 8D.5 sign-off stands. Independent final re-review confirmed both session-isolation and fit-readiness findings resolved. The sign-off below supersedes historical target-pending notes in this record. Numeric reserves remain open; M2 is not yet complete.
|
||||
|
||||
The starting worktree was clean. Production changes are confined to authored `src/web_ui.c`; focused tests extend `tests/web_ui_session/`. No server/protocol/settings/8D.7 policy change, generated asset regeneration, commit, upload or erase. The change fits the work-unit guideline without a preparatory split.
|
||||
|
||||
## Target Sign-off and Evidence (2026-09-06)
|
||||
|
||||
After providing 60-second fresh-boot, full-client-mix and partial-cleanup telemetry, the user confirmed the remaining validation checks: **"Yepp, that checks all out. Let's wrap up for the next phase."** This closes 8D.6, including the Open admin fix and toolbar-order follow-up. The confirmation covers the discussed switching/lease preservation, console/connection isolation, browser/session recovery, layout and soak/recovery checks. Exact repetition counts, soak duration, browser versions/origin and flashed revision were not separately supplied; do not manufacture those details or treat missing per-case transcripts as blockers to this explicit closure. No target checks were executed by the agent.
|
||||
|
||||
| Resource (bytes) | Boot free / minimum / largest | Full mix free / minimum / largest | Partial cleanup free / minimum / largest |
|
||||
|---|---|---|---|
|
||||
| Internal 8-bit | 70,988 / 66,536 / 31,744 | 33,900 / 6,516 / 24,576 | 61,296 / 6,516 / 31,744 |
|
||||
| Internal DMA | 63,232 / 58,780 / 31,744 | 26,144 / 1,580 / 24,576 | 53,540 / 1,580 / 31,744 |
|
||||
| External PSRAM | 8,246,364 / 8,242,552 / 8,126,464 | 8,087,468 / 8,063,428 / 7,995,392 | 8,186,424 / 8,063,428 / 7,995,392 |
|
||||
|
||||
- Boot: HTTPS/SSH/mDNS and both web transports ready without reported startup errors; no sessions/broker clients, UART stopped, USB host closed. Configuration **230400 8N1/no flow**. SSH owner minimum-free stack **18,472 B**, configured 20,480 B.
|
||||
- Full mix: UART running at **230400 baud**; web broker **24 sole writer**, SSH **9**, second web **10**, USB **11** observers. Two cookie sessions, two serial sockets, browser admin and admin SSH active concurrently. SSH stack minimum-free **16,280 B**.
|
||||
- Browser admin: five connections/four disconnections, six tickets issued/five consumed, 14 RX/1,096 TX bytes; zero reported capacity, authorization, protocol, input-backpressure, send or queue failures. Serial web: four connections/two disconnections, 47 RX/869,682 binary TX bytes, no transport failures; one expired ticket. HTTPS totals include two starts/one stop, two authentication failures and no response errors. These are cumulative observations, not attribution to particular validation actions.
|
||||
- SSH: two successful handshakes and **one unexplained handshake failure**; 12 RX bytes, 11 accepted, **one rejected**, 47,392 TX bytes, no I/O failures. Observer input rejection is a possible explanation, not an established diagnosis.
|
||||
- Cleanup is described as disconnecting all clients/admins **except serial**; only heap data was supplied. It is not a verified zero-client/UART-stopped baseline or a precisely timed cleanup sample. Internal/DMA largest blocks recovered to boot size. A single cleanup sample does not establish leak freedom or exact per-socket cost.
|
||||
|
||||
The **6,516 B internal / 1,580 B DMA lifetime minima** remain a resource follow-up for 8D.7/M2. These capabilities overlap and minima are conservative sums across regions, not necessarily simultaneous free-space readings. Do not infer exhaustion, approve numeric reserves or claim an unexplained reserve-floor violation from these values alone. Preserve this evidence for comparable full-load/cleanup measurement in the next phase; it does not reopen user-approved 8D.6.
|
||||
|
||||
Toolbar follow-up moved Open/Close admin before the Serial/Admin group, keeping the selector rightmost. Production-renderer/CSP checks, all **17 UI groups**, and `git diff --check` passed; bounded `pio run` passed in **7.60 s**, unchanged **95,580 B RAM / 1,646,489 B flash**. No JavaScript behavior or generated assets changed. This sign-off/handoff update itself is documentation only; no new build, upload or commit.
|
||||
|
||||
**Next:** separately requested **8D.7 — Web-shell lifecycle parity and M2 acceptance**. Review and close the explicit unsupported self-affecting command restrictions through bounded deferred owner actions; preserve UART0 recovery, single dispatch, serial isolation and safe credential handling. Split if needed. Do not implement settings or declare M2 complete; no 8D.7 implementation is authorized by this wrap-up.
|
||||
|
||||
## Open Admin Bug Follow-up (2026-09-06)
|
||||
|
||||
User reports working serial and mode selection, but Open admin displays `Admin connection failed. Open admin to retry.` Source diagnosis: `web_ui.c:openAdmin()` incorrectly validated a 32-character URL-safe serial ticket; `web_admin_tickets.c:web_admin_tickets_issue()` emits 32 random bytes as **64 lowercase hex characters**. A successful admin-ticket POST was rejected locally before `/ws/admin` construction/admission. No Origin, role, shared-console, fit, subprotocol or private-adapter change is needed for this demonstrated failure. Repeated attempts can leave the two unconsumed tickets occupied until their 30-second expiry.
|
||||
|
||||
Changed only the admin validator to the backend's emitted format. The UI harness had incorrectly shared the serial fixture with admin; separate 64-hex admin responses now exercise the exact query URL, plus malformed/serial-format rejection and serial isolation. The corrected fixture failed before the source fix (only the serial socket existed), then all **17 UI groups** passed. Production C renderer verifies the unchanged inline loader against the exact reported `sha256-o6St1XqFiWgZZKDDKYP8Y1ROJxvOnf96z55w4i/dC20=` CSP. The other reported UUID/index.js hashes are not that loader; their source is not established, and CSP was not relaxed.
|
||||
|
||||
Final focused reruns: `python3 tests/web_ui_session/run.py`, `python3 tests/web_cookie_auth/run.py --admin` (real store/ticket/endpoint-to-101 admission), `python3 tests/web_admin_transport/run.py --tickets` (19/12 groups), and `python3 tests/web_admin_transport/server_lifecycle.py` (11 groups) all pass. Frontend request behavior and backend admission were tested in separate harnesses, not an end-to-end browser/network session. Final bounded `pio run` passed in **7.96 s**, **95,580 B RAM / 1,646,489 B flash**, unchanged from the previous build. `git diff --check` passes. No assets, uploads, commits or target execution. Parent review and user target retest remain pending; this bug fix is not a new phase or sign-off. On next user-controlled deployment, reload the app and verify explicit Open admin reaches a prompt while serial remains connected; no ticket/cookie/CSRF values are needed in any report.
|
||||
|
||||
## Browser Contract
|
||||
|
||||
- Validated `/api/session` role reveals Serial/Admin selection only for admins. Ordinary users retain serial-only navigation; existing server authorization remains authoritative.
|
||||
- Selecting Admin lazily creates one additional xterm instance. Open admin explicitly POSTs `/api/admin/ws-ticket` with the existing CSRF/cookie policy, then connects `/ws/admin`. Selection alone does not open a socket or run a command. Failures and shell termination require explicit reopen, not automatic command/session replay.
|
||||
- Mode switches only change visibility, focus, selected-terminal sizing and input gating. Both connected sockets continue receiving output. The same serial socket, broker identity and writer lease remain; broker status and Request control/Release control remain visible in both modes.
|
||||
- Serial and admin have separate 5,000-line scrollbacks and separate 65,536-byte pending xterm-write limits, released through write callbacks. Excess frames are discarded while the socket continues draining, with cumulative per-terminal browser-drop byte counts visible in both modes. The UI explicitly states oldest scrollback lines expire. These counts are not firmware broker-drop telemetry or a byte-integrity guarantee.
|
||||
- Keyboard/paste goes only to the selected terminal. Serial retains its writer check and 1,024-byte framing; admin sends binary frames of at most 512 bytes with a 4,096-byte input event/socket-buffer admission limit. Excess admin input closes only the shell rather than silently truncating a command. Earlier accepted input cannot be rolled back.
|
||||
- Close admin, shell `exit` and admin failures do not disconnect serial. Explicit serial Disconnect still closes only serial and pauses its reconnect. Admin open has a 15-second handshake deadline in addition to the existing 15-second API deadline. Capacity/security errors remain safe-text/manual retry; server admission errors during upgrade appear as generic connection failure.
|
||||
- Logout, session expiry/401 and pagehide cancel pending work and close both routes. Generation guards reject late responses/callbacks; socket handler properties are cleared before close. Resize listeners/observer are removed on exit and restored once for bfcache. Restored pages revalidate the session and preserve serial pause policy; admin requires explicit reopen. At most two terminals and one input subscription each persist for the page lifetime, not one per switch/reconnect.
|
||||
- Scrollback belongs to the document's first validated username/role/session-stable CSRF tuple, retained separately from the active request CSRF value. Before adopting any later session view, a mismatch hides both terminals, closes both routes, fences pending work and replaces the document at `/`. This also prevents logout from mutating a replacement session. Pagehide hides both terminal hosts and blocks selection until successful same-session validation; same-session restore and mode switches retain scrollback. Failed or delayed restore validation never reveals the retained buffers.
|
||||
- Terminal bounds are cached only after valid dimensions and successful resize (or an already matching size). An unavailable/invalid measurement gets at most three animation-frame retries per external fit request. Work cancellation removes the pending frame, resets the retry budget and generation-fences stale callbacks; teardown/logout cannot restart readiness retries. A later resize/selection can explicitly start a fresh bounded attempt.
|
||||
- Existing dark panel/button visual language is retained, selected buttons expose `aria-pressed`, and narrow layouts can scroll rather than clipping the terminal below the dashboard. Real desktop/mobile rendering remains to be checked. The inline loader and its exact CSP hash are unchanged and tested; authored app/document remain no-store.
|
||||
|
||||
## Executed Validation
|
||||
|
||||
All commands ran successfully in this workspace:
|
||||
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `python3 tests/web_ui_session/run.py` | Production C renderer/header/failure/CSP checks and 16 Node groups pass |
|
||||
| `python3 tests/web_login_ui/run.py` | C/HTML checks and eight Node groups pass |
|
||||
| `python3 tests/web_cookie_auth/run.py --admin` | Cookie/session policy plus combined real store/ticket/admin endpoint tests pass |
|
||||
| `python3 tests/web_auth_parse/run.py` | 268 cases, zero failures |
|
||||
| `python3 tests/web_session_store/run.py --serial` | Store and serial binding/isolation/races pass |
|
||||
| `python3 tests/web_admin_transport/run.py --tickets` | 19 transport groups and 12 ticket groups pass |
|
||||
| `python3 tests/web_admin_transport/server_lifecycle.py` | 11 lifecycle groups pass |
|
||||
| `python3 tests/admin_console_boundary/run.py` | Shared-owner/currentness/prompt/completion/SSH boundary suites pass |
|
||||
| `pio run` | Final source build after review fixes succeeds in 21.35 seconds, 120-second finite timeout |
|
||||
| `git diff --check` | Pass |
|
||||
|
||||
New UI checks cover 20 switch cycles with unchanged serial socket/client/writer IDs, hidden binary output and independent terminals, selected input framing, user-only navigation, visible overflow and resumed draining, close/reopen/remote-close isolation, 401/logout/pagehide/expiry, cancelled admin ticket late 401, stale socket callbacks, selected resize, three bfcache listener cleanup cycles and handshake timeout. The harness executes production C-rendered JavaScript with DOM/xterm/WebSocket/timer doubles. It does not execute real terminal escape parsing, hidden prompts, CSS layout, browser CSP enforcement, TLS/HTTPD scheduling, target UART traffic or browser heap profiling.
|
||||
|
||||
### Independent Review Fixes (2026-09-06)
|
||||
|
||||
Both reported P2 findings are fixed, with parent re-review pending:
|
||||
|
||||
- **Cross-session retained scrollback/live admin:** `loadSession` now compares identity before adopting CSRF, role, expiry or view. It requires a clean document on mismatch rather than reusing A's buffers or live admin socket under B's cookie. Regression coverage checks admin-to-admin, admin-to-user, same-account/new-CSRF, username-only and role-only changes, each during active restore, paused restore, live serial reconnect with admin still open, and logout. Tests assert clean `/` replacement, hidden old terminals, both sockets closed, no new ticket/logout request, unchanged old session view and fenced callbacks. Same-identity restore retains both scrollbacks and live same-session reconnect retains admin.
|
||||
- **Failed initial fit cached forever:** Valid measurements alone populate the bounds cache; three bounded readiness retries recover from an initially undefined measurement at identical host bounds. Tests also exhaust retries, verify later external retry, and invoke stale callbacks after pagehide/restore, expiry and logout. The first test run exposed a fresh fit scheduled by logout's session check; suppressing fits while logging out fixed that case before the final passing rerun/build.
|
||||
|
||||
After the fixes, reran `tests/web_ui_session/run.py` (16 groups), `tests/web_login_ui/run.py` (8 groups), `tests/web_cookie_auth/run.py --admin`, `tests/web_session_store/run.py --serial` and `tests/web_auth_parse/run.py` (268 cases), all with `python3`, all passing. The other unchanged-backend suites in the table passed during initial implementation and were not rerun for these UI-only review fixes. Final `pio run` and `git diff --check` pass. No target/browser result, phase sign-off or M2 completion is claimed.
|
||||
|
||||
## Resource Accounting
|
||||
|
||||
Final PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0 N16R8 release build:
|
||||
|
||||
| Metric | Final | Versus signed-off 8D.5 build | Versus recorded 8D.0 |
|
||||
|---|---:|---:|---:|
|
||||
| Linked RAM | 95,580 B | 0 B | +1,048 B |
|
||||
| Flash | 1,646,489 B | +9,216 B | +46,516 B |
|
||||
|
||||
The delta is authored HTML/CSS/JavaScript read-only content. Review fixes add **0 B RAM / 1,376 B flash** versus the initial 8D.6 build (22.22 seconds, 95,580 / 1,645,113 B). An earlier pre-final-CSS build passed in 25.30 seconds at 95,580 / 1,644,905 B. The table is the final post-review-fix source result. Browser-only additions are one retained session identity tuple and a retry counter using the existing single animation-frame slot, not new firmware storage or a polling task.
|
||||
|
||||
No firmware runtime allocations, tasks/stacks, queues, request scratch, route count or capacities are added. Existing six HTTPS socket slots, two serial sockets, one optional admin socket, two admin tickets and two shared console slots are unchanged. The UI now exercises the already-allocated backend with simultaneous serial/admin WebSockets plus periodic HTTPS requests; actual loaded TLS/socket/console heap headroom still requires target measurement. There is no LRU eviction/capacity increase to make the selector work.
|
||||
|
||||
Browser cost adds a lazy terminal/fit addon and 5,000 lines of scrollback, plus at most 64 KiB pending output payload per terminal (128 KiB together) and callback/object overhead. This is not a total browser heap bound: xterm cell storage depends on dimensions, browser networking queues are implementation-owned, and JavaScript cannot securely wipe engine-managed strings. Numeric internal/DMA/largest-block/stack reserve gates remain open.
|
||||
|
||||
## Target Checklist (Retained for Regression)
|
||||
|
||||
The user sign-off above closes 8D.6. This original checklist is retained for future regression coverage; individual unrecorded details are evidence limitations, not outstanding phase gates.
|
||||
|
||||
1. Record exact flashed revision, browser versions and direct-IP/mDNS origin. Verify user serial-only login and admin selector on desktop and narrow/mobile screens, selected styling/focus, resize/orientation and usable terminal height. Exercise initially unavailable font/cell measurements: sizing must recover at unchanged bounds or after a later explicit resize, without unbounded retries.
|
||||
2. With serial writer active and sustained UART output, perform 20 Serial/Admin switches. Confirm unchanged broker client/writer IDs, Request/Release from both modes, continued hidden output and no admin keystrokes on UART1. Repeat as observer. Distinguish browser-drop counts, firmware broker drops and expected scrollback rollover.
|
||||
3. Exercise admin empty Enter, normal commands, history, Tab completion, Ctrl+C and hidden prompts while switching views. Confirm no input/output/prompt crossover with UART0/admin SSH. Close/reopen five times and use `exit`/empty Ctrl+D; serial must remain intact. Explicit serial Disconnect/reconnect must not close admin.
|
||||
4. Test admin capacity rejection with existing console/socket occupancy, network failure, stale ticket and repeated manual reopen; retained serial must not be evicted. Confirm existing 8D.5 unsupported self-affecting command restrictions still reject without side effects; no 8D.7 parity is claimed.
|
||||
5. Test logout, account revocation, absolute expiry, pagehide/bfcache restore, ordinary reload and interrupted logout with both routes open and with a pending ticket. No stale admin command/session replay; expired auth returns to usable login. Recheck unrelated-session isolation. Change the same-origin cookie via another tab/account login (admin-to-admin and admin-to-user), then restore/reconnect: no old scrollback may appear in the new document, and old admin must close. Same-session restore must retain both scrollbacks; delayed/failed revalidation must leave them hidden.
|
||||
6. Run UART0 recovery, USB UART1, user/admin SSH and two-browser serial plus one browser-admin full mix with HTTPS polling. Capture `memory`, transport/broker counters and SSH stack margins at settled boot, serial-only, serial+admin, full load and after five open/close/logout cycles. Compare internal/DMA/PSRAM free/minimum/largest blocks without inventing reserve floors.
|
||||
7. Run a bounded 15-minute mixed-client soak at the user's supported workload (record actual baud; prior 8D.5 was 230400), then disconnect and collect 60-second cleanup telemetry. Check no watchdog/stack faults, monotonic leaks, declining largest-block trend or unexplained lease loss. Profile browser memory across repeated switches and output overflow where feasible.
|
||||
|
||||
8D.6 is closed by user sign-off above. Wait for a separate **8D.7** implementation request; numeric reserves and the M2 acceptance milestone remain open.
|
||||
@@ -1,154 +0,0 @@
|
||||
# Phase 8D.7 — Web-shell lifecycle parity and M2 acceptance
|
||||
|
||||
## Current status: implemented scope validated, M2 signed off
|
||||
|
||||
**8D.7 validated and M2 explicitly signed off by the user on 2026-09-07 for the implemented scope.** After "Ok, mark 8D.7 as validated", the user explicitly requested: "Jupp, sign M2 off" following discussion that 8D.8 read-only settings is next. This supersedes all earlier M2-open, target-pending, acceptance-blocking and continuation statements below. M2 is accepted without requiring revalidation; full browser command parity and individual unreported checklist passes are not asserted.
|
||||
|
||||
### User sign-off and evidence (2026-09-07)
|
||||
|
||||
- User reports thorough testing with no further defects except intermittent admission failures in the supported two serial WebSockets plus one admin WebSocket arrangement. These have recently not recurred and are accepted as nonblocking for this sign-off, not diagnosed or fixed. The earlier admission investigation and telemetry below remain historical evidence and a follow-up if the issue recurs.
|
||||
- User verified certificate rotation and web start/stop, with lifecycle testing through UART0, SSH admin and web admin. After stopping web from the browser, restart was through another administration route; this is not a claim that browser `web start` is enabled.
|
||||
- User reports full-client-mix operation with no broker drops at rates up to **230400 baud**, with the external adapter baud corrected. This is user-reported workload evidence, not a universal zero-drop guarantee or a new measurement inferred from the earlier boot sample.
|
||||
- Exact flashed revision, repetition counts, soak duration, reboot-specific results and individual account-mutation/injection checklist results were not separately supplied. Unrecorded details remain evidence limitations and regression coverage, not claims of execution or blockers reopening this user-approved validation.
|
||||
|
||||
**Deferred scope:** browser self-target/generated-password/key/legacy-credential and other owner-specific command restrictions remain in force until separately implemented; bootstrap/recovery remain permanently UART0-only. Numeric memory reserves and stack margins are not approved and remain follow-ups, not blockers reopening accepted M2. **Next is 8D.8: read-only settings entry and Serial page, only when separately requested. This sign-off alone authorizes no implementation.** This sign-off changes documentation only, with no production/test edits, build, device operation or commit.
|
||||
|
||||
## Third bounded account slice history
|
||||
|
||||
The following slice reviews, investigation and checklists preserve evidence as recorded before sign-off. Their target-pending, acceptance-blocking and next-slice instructions are superseded by the current status above; checklist items are not individually promoted to passed.
|
||||
|
||||
**Account slice reviewed / host-tested / build-verified (2026-09-07); no actionable findings remain. Target validation and 8D.7/M2 acceptance remain pending.** This supersedes the account restrictions and next-slice statements in the historical sections below, not their recorded validation limits.
|
||||
|
||||
Browser admins may now operate on other accounts with exact parsed `user add <name> user|admin`, `user password <name>`, `user delete <name> --force`, and `user role <name> user|admin --force`. Add/password use hidden password and confirmation prompts. Dispatcher policy and the canonical handler both enforce this bounded allowlist. Self-target mutations (even no-op role changes), generated-password output, key workflows, bootstrap and recovery remain blocked for browsers. Existing inspection is unchanged. Database final-admin and migrated-account protections remain authoritative.
|
||||
|
||||
### Operation-admission currentness
|
||||
|
||||
The guarantee is **operation-admission currentness**, not currentness at commit. Account, originating owner/session and console token are checked after password prompts and before admitting the database API operation; delete/role also revalidate before API admission. Queued or subsequent stale operations must reject. Disconnect, logout, expiry or revocation during password derivation does not cancel or roll back an already admitted operation. Such an operation may persist and request target-only web/SSH revocation after the initiating browser is gone. Notification failure does not undo a committed mutation, and completion output is not a receipt guarantee.
|
||||
|
||||
After a disconnect with an uncertain result, reconnect using a current administrator and inspect `user list` / `user show <name>` before retrying. Password values cannot be inspected; verify the intended login outcome or deliberately reset the target password rather than assuming cancellation. Do not expose credentials in status or logs.
|
||||
|
||||
### Review and verification
|
||||
|
||||
Independent production and regression reviews found no actionable defects. The earlier proposed precommit-currentness finding was withdrawn as inconsistent with the existing executing-handler contract. No production changes were needed during this review continuation; inherited changes were preserved.
|
||||
|
||||
Passed in this continuation: `python3 tests/admin_console_boundary/accounts.py`, `python3 tests/admin_console_boundary/run.py`, `python3 tests/admin_ssh_policy/run.py` (review agent); `python3 tests/admin_console_boundary/lifecycle.py`, `python3 tests/web_admin_transport/run.py --tickets` (25 transport / 12 ticket groups), and `python3 tests/web_session_store/run.py --serial` (parent). Account regressions cover admitted derivation invalidation, successful commits and target-only notification arguments, NVS open/write/commit failures, candidate cleanup, and rejection of the next stale operation.
|
||||
|
||||
These are deterministic handler/database and separate console/transport boundary tests, not real HTTPD disconnect/expiry, scheduler or end-to-end socket tests. Account tests double currentness, prompts, crypto, NVS and notification APIs; they do not independently prove live connection isolation. No device or sanitizer validation was performed.
|
||||
|
||||
`pio run` passed (incremental, 3.35 s): **95,580 B linked RAM / 1,648,577 B flash**, **0 / +516 B** versus the recorded certificate slice, **0 / +2,088 B** versus 8D.6, **+1,048 / +48,604 B** versus 8D.0. No new task, queue depth, route, socket capacity, assets or stack size; runtime reserves and stack margins remain unmeasured.
|
||||
|
||||
### User target evidence: admission issue (2026-09-07)
|
||||
|
||||
User supplied a 60-second fresh-boot sample, then reported intermittent full-client-mix admission failures. Exact flashed revision, browser/tab arrangement, failed-request timing and loaded duration were not supplied. This is target evidence, not full-mix acceptance or proof of an account-slice regression.
|
||||
|
||||
| Heap (bytes) | Boot free / minimum / largest | Eventually connected mix free / minimum / largest |
|
||||
|---|---|---|
|
||||
| Internal 8-bit | 70,860 / 59,548 / 31,744 | 33,428 / 5,280 / 23,552 |
|
||||
| Internal DMA | 63,104 / 51,792 / 31,744 | 25,672 / 344 / 23,552 |
|
||||
| PSRAM | 8,246,176 / 8,240,232 / 8,126,464 | 8,084,100 / 8,058,464 / 7,995,392 |
|
||||
|
||||
Boot: HTTPS/SSH running without reported startup errors, mDNS announced, four accounts/two admins, no cookie sessions or web/SSH clients, UART stopped at configured 230400 baud, broker empty, USB attached with host closed. SSH minimum-free stack 18,472 B. Admin backend initialized/attached, 167 B static / 240 B ticket storage / 1,552 B PSRAM payload. Supplied boot request/connection/failure counters were zero.
|
||||
|
||||
User could promptly connect SSH writer/admin, USB observer and browser serial; two web observers were possible, but adding browser admin repeatedly timed out. Disconnecting one observer allowed admin admission; the last observer eventually reconnected. UART logs include repeated TLS handshakes and errors `-0x0050` (also on write) and `-0x7280`. No loaded counters or failed-allocation trace supplied. DMA's 344 B lifetime minimum is concerning, but capability heaps overlap and regional minima are conservative sums, not proof of an allocation failure or simultaneous remaining reserve.
|
||||
|
||||
Installed IDF 5.5.0 / Mbed TLS 3.6.3 source identifies these errors as connection reset and connection EOF, respectively, not allocation errors. Source investigation found six shared HTTPD client slots with LRU disabled (`src/web_server.c`); IDF stops selecting the listener when full. Ordinary idle HTTP connections can retain slots: the one-second receive wait is not an idle lifetime. TLS handshakes run synchronously on HTTPD with a configured five-second handshake timeout. Browser assets, session/status/ticket requests and WebSockets compete for slots. TLS already uses external-memory allocation; increasing sockets or enabling LRU blindly would risk memory pressure or retained serial sessions.
|
||||
|
||||
**Investigation remains open:** transient HTTPD slot occupancy is the leading hypothesis, with handshake blocking, internal-memory pressure and global descriptor capacity possible contributors. Disconnecting an observer frees both a socket and resources, so it does not isolate the cause. Next evidence: identify ticket POST versus WebSocket-open timeout using redacted browser timings/status; correlate count-only HTTPD ordinary/WS occupancy and allocation failures; repeat with serial traffic paused. Do not share cookies, CSRF values, ticket URLs or unredacted HAR. No runtime fix, device operation or build performed for this investigation; full-mix reliability must be resolved before acceptance.
|
||||
|
||||
### Historical pending account target checks
|
||||
|
||||
1. Exercise each enabled other-account command, hidden confirmation/mismatch/cancel, and self/generated/key/bootstrap/recovery rejection. Verify final-admin protections and unchanged UART0/admin SSH behavior.
|
||||
2. Confirm successful target mutations revoke only that account's web/SSH sessions, retaining unrelated browser serial/admin, USB and SSH traffic. Check stale queued commands and prompts after logout/expiry/revocation and slot reuse.
|
||||
3. Where safely injectable, disconnect during admitted derivation and inspect the resulting account state after reconnect; do not expect cancellation. Check persistence failure and retry recovery without logging secrets. Unavailable timing/failure injection remains unexecuted.
|
||||
4. Run the lifecycle/full-mix/soak/resource checklist below as well. Remaining self/generated/key/legacy-credential and other owner-specific parity work stays blocked until separately implemented; this review does not close M2 or start another slice.
|
||||
|
||||
## Second bounded certificate slice history
|
||||
|
||||
**Second slice implemented / host-tested / build-verified; independent reviewer reports no actionable findings. No hardware validation or 8D.7/M2 acceptance.** The user explicitly authorized continuing to stack the next bounded slice; this is a continuation decision, not target sign-off. Next comes credential/account handling, then other owner-specific slices. Prior 8D.6 sign-off stands; numeric reserves remain open.
|
||||
|
||||
The second slice enables only exact parsed browser **`web certificate rotate --force`**. It changes the shared console boundary, WEB owner and canonical web handler (`src/admin_ssh_console.{c,h}`, `src/web_admin_transport.c`, `src/web_console.c`) plus focused tests. No new task, queue depth, route, socket/session/ticket capacity, asset, UI/settings feature or stack size. SSH and UART0 certificate/lifecycle behavior is unchanged. This documentation handoff does not change source/tests or run a build/device operation.
|
||||
|
||||
### Second-slice execution contract
|
||||
|
||||
- Policy uses the same argument parser as the canonical console: exactly four parsed arguments are required; quoted equivalents are accepted, missing `--force` or extra arguments are not. The browser handler schedules a typed action before certificate side effects, not a command string for later replay.
|
||||
- The existing request queue uses a command-line/deferred-action union, preserving capacity. The immutable owner's `dispatcher_actions` mask selects certificate rotation for the existing **12 KiB dispatcher**, not the **4 KiB control task**. Other existing actions retain control-task execution; a zero mask preserves SSH behavior.
|
||||
- Control waits up to ten seconds for application-buffer drain, cancels if it does not drain, then delays **200 ms** and attempts a nonblocking dispatcher handoff. A full queue fails before mutation. Pending input gating persists through queue residence and execution, not just handoff. This bounds the acknowledgement/drain stage, **not browser receipt, dispatcher queue latency, or certificate/NVS/stop/start execution time**; another command or prompt can delay the dispatcher.
|
||||
- Dispatcher revalidates token/principal/originating session, verifies owner and pending state, reserves the slot as executing and rechecks currentness before the owner callback. WEB revalidates again before mutation. Self-detach cannot reuse the executing slot; it is wiped/released after return. Stale/revoked work fails before side effects; these checks cannot roll back an already executing action.
|
||||
- WEB calls transactional `web_security_rotate_certificate()` → `web_server_stop()` → `web_server_start()`, outside console locks and without socket IO or handler replay. Generation/key generation/persistence failure leaves the previous committed/live material and skips stop/start. Successful commit installs the new identity before stop; later lifecycle failure does not undo it. A stop error returns immediately without start, retaining HTTPD ownership under existing server rules; auth may already be invalidated and transports detached. Start failure requires UART0/admin-SSH recovery.
|
||||
- Successful restart closes both browser routes, invalidates old sessions/tickets and removes any web writer lease. Reconnect requires verifying/trusting the new certificate and logging in again. No account password or legacy recovery credential is rotated; USB UART1, UART0 and SSH are not stopped. Completion/error output after self-detach is not guaranteed to reach the browser.
|
||||
|
||||
### Second-slice reported verification and resources
|
||||
|
||||
The implementer reports PASS for `python3 tests/admin_console_boundary/run.py` (including `certificate.c`), `python3 tests/admin_console_boundary/lifecycle.py`, `python3 tests/admin_ssh_policy/run.py`, `python3 tests/web_admin_transport/run.py --tickets` (**25 transport / 12 ticket groups**), `python3 tests/web_admin_transport/server_lifecycle.py` (**11 groups**), `python3 tests/web_cookie_auth/run.py --admin`, `python3 tests/web_session_store/run.py --serial`, and `git diff --check`. Independent review found no actionable findings. Sanitizer validation is unavailable because `libasan`/`libubsan` are missing; no sanitizer pass is claimed. Host harnesses do not prove concurrent RTOS/HTTPD/TLS behavior or target stack margins.
|
||||
|
||||
Parent reports final **`pio run` PASS in 26.32 s: 95,580 B linked RAM / 1,648,061 B flash**. Deltas: **0 / +1,036 B** versus first slice, **0 / +1,572 B** versus 8D.6, **+1,048 / +48,088 B** versus 8D.0. Baselines were not rebuilt. The owner mask and local deferred-request/principal scratch are real accounting considerations despite unchanged linked RAM and queue capacity; host `sizeof` is not target layout or stack-margin proof. Control/dispatcher path high-water marks remain unknown. No new tasks, queue depths, routes, assets or stacks; existing HTTPD/SSH stacks and transport capacities remain unchanged.
|
||||
|
||||
## First-slice history (2026-09-06)
|
||||
|
||||
The following behavior, verification and resource figures record the first slice, not the second-slice final build.
|
||||
|
||||
**First increment implemented / host-tested / build-verified; target validation pending. 8D.7 and M2 are not complete.** Prior 8D.6 user sign-off stands. The user requested starting 8D.7; scope review splits the several distinct lifecycle/identity/network/account paths as the plan permits.
|
||||
|
||||
This increment enables **browser-admin `reboot` and `web stop`** using the existing deferred-control task. Allowed production files are `src/admin_ssh_console.{c,h}`, `src/web_admin_transport.c`, `src/web_console.c` and `src/system_console.c`; related host tests and documentation are updated. Starting worktree was clean. No new task, queue, socket, route, payload capacity, generated asset, UI/settings feature, upload, erase or commit.
|
||||
|
||||
## Implemented behavior
|
||||
|
||||
- The canonical `web stop` handler recognizes browser-origin dispatch through `admin_ssh_console_dispatch_is_web()` and queues `ADMIN_CONSOLE_DEFER_WEB_STOP` before any service side effect. UART0 and admin SSH keep their existing synchronous HTTPS-stop path.
|
||||
- Browser `reboot` now passes parsed command policy and uses the existing canonical remote-reboot deferral. The acknowledgement says console output rather than SSH output. UART0 remains synchronous; SSH reboot remains deferred.
|
||||
- In the first slice, WEB owner supports SELF_CLOSE, REBOOT and WEB_STOP only. Its control-task callback revalidates originating cookie-session/principal binding and full console identity after the drain delay, outside console locks. It calls lifecycle APIs, not socket IO; HTTPD remains the socket owner. Stop API errors propagate through the existing deferred-result path. Revoked/stale work does not invoke stop/reboot.
|
||||
- Existing two-entry control queue, ten-second drain wait, 200 ms final delay and five-second output backpressure remain. Drain is a best-effort application-buffer heuristic, **not browser receipt confirmation**, and the drain bound does not establish a hard upper bound on underlying lifecycle API execution. A failed stop may already have invalidated authentication and require UART0/SSH recovery.
|
||||
- Console snapshots expose the existing deferred-pending flag. While deferral is observed, HTTPD wipes buffered/new input rather than replaying it after cancellation. The incoming frame's discard decision is latched before bounded payload reception, so cancellation during reception cannot reclassify that frame as a new command. A second frame arriving before the periodic poll does not turn deferred trailing input into a session-closing overflow. Normal invalid/oversized/fragmented frames and nondeferred buffer exhaustion still fail closed. Discard events contribute to existing input-backpressure counts.
|
||||
|
||||
**Intentional effects:** `web stop` closes both browser-admin and serial WebSockets and invalidates HTTPS sessions. A web writer therefore loses its broker lease; USB, UART0 and SSH are not stopped. Restart HTTPS through UART0/admin SSH using `web start`, then log in again. `reboot` restarts the entire device and loses RAM-only configuration. Normal terminal selection and admin-only `exit` retain their existing serial-isolation behavior.
|
||||
|
||||
## Restrictions still in force
|
||||
|
||||
These remain deliberately blocked for WEB, before canonical handler side effects:
|
||||
|
||||
- All `web` forms except exact parsed `web status`, `web stop` and `web certificate rotate --force`. This still blocks certificate info, start, help/counters, legacy credential display/rotation and full material reset.
|
||||
- Wi-Fi/mDNS commands except exact status queries.
|
||||
- User mutations; allowed inspection remains bare `user`, `user status`, `user list`, `user show <name>`.
|
||||
- SSH stop/disconnect/reset and host-key mutation (host-key info remains allowed).
|
||||
- Bootstrap/recovery remain UART0-only for all remotes. No one-time self-generated credential workflow was enabled.
|
||||
|
||||
The next increment remains **within 8D.7**: credential/account handling, then other owner-specific restrictions in bounded slices. The user explicitly authorized stacking the next bounded slice without target sign-off. Preserve explicit rejection until each path is implemented. Do not proceed to settings or close M2 based on either slice.
|
||||
|
||||
## First-slice executed verification
|
||||
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `python3 tests/admin_console_boundary/run.py` | Shared console/SSH, dispatcher-origin, pending snapshot, action bounds, queue/drain/cancellation and existing prompt/currentness regressions pass |
|
||||
| `python3 tests/admin_console_boundary/lifecycle.py` | New extracted canonical-handler checks pass: browser stop deferred, SSH/UART stop unchanged, remote reboot and scheduling-failure isolation |
|
||||
| `python3 tests/admin_ssh_policy/run.py` | Actual IDF parser permits only the intended new WEB forms, including quotes; remaining restrictions/SSH policy pass |
|
||||
| `python3 tests/web_admin_transport/run.py --tickets` | **23 transport / 12 ticket groups pass**, including stale/revoked action rejection, control-task API routing/error propagation, pending input, second frame before poll and cancellation during receive |
|
||||
| `python3 tests/web_admin_transport/server_lifecycle.py` | **11 groups pass**, including detach timeout, failed stop/retry and optional failure isolation |
|
||||
| `python3 tests/web_cookie_auth/run.py --admin` | Real cookie/store/ticket/adapter/admin endpoint integration passes; lifecycle calls doubled and not invoked by endpoint tests |
|
||||
| `python3 tests/web_session_store/run.py --serial` | Store/serial binding/isolation/race regressions pass |
|
||||
| `python3 tests/web_ui_session/run.py` | Renderer/CSP and **17 UI groups pass**; no UI changes |
|
||||
| `git diff --check` | Pass |
|
||||
| `pio run` | Final source build **12.44 s**, finite 180-second timeout; **95,580 B linked RAM / 1,647,025 B flash** |
|
||||
|
||||
Independent review identified the second-frame-before-poll cancellation bug, then cancellation-during-receive reclassification. Both were fixed with regressions; reviewer confirmed the final correction with no remaining findings in it. An initial console test still expected `web stop` denial; updated it to a still-restricted command. These are not target failures. An earlier pre-review-final build was 1,646,965 B flash; the table above supersedes it.
|
||||
|
||||
Tests use deterministic dependencies, not real concurrent RTOS/HTTPD/TLS/device execution. Actual command handlers, console state machine, transport, and server orchestration are exercised in focused harnesses, not one fully linked concurrent end-to-end firmware harness. No sanitizer pass or on-device stop/reboot is claimed.
|
||||
|
||||
## First-slice resources
|
||||
|
||||
Versus signed-off 8D.6 (95,580 / 1,646,489 B): **0 B RAM / +536 B flash**. Versus recorded 8D.0 (94,532 / 1,599,973 B): **+1,048 / +47,052 B**. Baselines were not rebuilt.
|
||||
|
||||
No new static payload/state, module heap/PSRAM allocation, task, stack-size, queue-depth, HTTP handler, TLS/socket, ticket or session capacity. The snapshot adds a boolean describing already-existing console state; callers use local snapshot scratch. WEB lifecycle validation copies one secret-free principal on the existing control stack. Existing control stack is 4 KiB, dispatcher 12 KiB; runtime stack high-water marks for these new call paths remain unmeasured. HTTPD remains 10 KiB, SSH owner 20 KiB, web payload 1,552 B PSRAM-only, six HTTPD sockets and two shared console slots.
|
||||
|
||||
Carry forward 8D.6 loaded lifetime minima **6,516 B internal / 1,580 B DMA**, noting overlapping capabilities and conservative regional sums. Numeric reserves remain open; no safe margin or new reserve violation is inferred. Do not reopen the prior sign-off solely for incomplete numeric instrumentation.
|
||||
|
||||
## Historical pending target checklist — both slices
|
||||
|
||||
1. User-controlled flash/reload; capture exact revision, 60-second settled `memory`, web/SSH status and available stack telemetry. Check browser login/serial/admin, USB UART1, user/admin SSH and UART0 at the established 230400-baud workload.
|
||||
2. With browser serial + admin and USB/user/admin SSH active, issue **`web stop` from browser admin**. Expect its best-effort scheduling acknowledgement, then both browser routes close; UART0/USB/SSH remain usable. Confirm no browser writer remains. Restart with UART0/admin SSH `web start`, reauthenticate and reconnect. Repeat five times and compare full-mix/cleanup heap and largest blocks; do not expect boot equivalence when UART/clients remain active.
|
||||
3. With any RAM-only settings loss understood, issue browser **`reboot`**. Expect best-effort acknowledgement and device restart, then usable UART0 recovery and browser login. Verify no replay of the command on reconnect.
|
||||
4. Exercise queued/slow output, trailing input and rapid additional input during deferral. No cross-session output, no deferred input replay after cancellation, no unrelated admin-only-close effect on serial. Logout/revoke/disconnect before execution should prevent stale actions. Timing/failure injection not practical on target remains explicitly unexecuted rather than assumed passed.
|
||||
5. Check remaining forbidden commands still reject without side effects, and SSH/UART0 stop/reboot/certificate behavior remains unchanged. For browser rotation, reject missing force, extra arguments and other certificate forms without identity changes; accept only exact parsed `web certificate rotate --force` (including equivalent quoting).
|
||||
6. With browser serial/admin plus USB and user/admin SSH active, rotate the certificate. Expect only a best-effort scheduling acknowledgement, both browser routes closing, old session/ticket rejection and loss of a web writer lease. Verify the changed public fingerprint through trusted UART0/admin SSH `web certificate info`, update browser trust deliberately, log in again with unchanged account credentials and reconnect. Confirm USB UART1/SSH traffic and UART0 recovery remain available; do not log private keys, passwords or session/ticket material.
|
||||
7. Repeat rotation/relogin under the established full mix and compare loaded/cleanup memory and largest blocks. Exercise revocation/disconnect/slot reuse before dispatcher execution, queued prompts/slow output, trailing input and no replay. Collect control/dispatcher/HTTPD stack high-water evidence where instrumentation permits; record unknown margins rather than substituting host sizes.
|
||||
8. Where safely injectable, verify generation/persistence failure skips stop/start and retains old material; stop/detach failure after commit retains ownership, skips start and may leave auth disabled; successful stop followed by start failure retains the new persisted identity. Recover through UART0/admin SSH by inspecting state and completing stop/start as appropriate, with no duplicate HTTPD start or blind rotation retry. Unavailable target failure injection remains unexecuted.
|
||||
|
||||
Do not run these disruptive commands automatically. The user has explicitly authorized stacking the next bounded runtime slice; no hardware checklist item is thereby passed. Final M2 acceptance remains pending and requires lifecycle parity, the full mixed-client/soak/resource checkpoint and explicit target/browser acceptance before settings.
|
||||
@@ -1,97 +0,0 @@
|
||||
# Phase 8D.8 Implementation Record
|
||||
|
||||
## Target sign-off (2026-09-08)
|
||||
|
||||
The user supplied settled boot/full-client-mix evidence and reports thorough testing of Serial parameter display/settings and user/account settings, explicitly authorizing sign-off of implemented work. **8D.8 is accepted**, together with implemented 8D.9/8D.10. This supersedes target-pending/signoff statements below, not historical build evidence. Full telemetry/counters and scope limits are recorded in `docs/phase8d10_implementation.md` under Target acceptance. Exact flashed revision and individual checklist results were not separately supplied; do not invent them or reopen explicit acceptance solely for absent detailed records. Numeric memory reserves/stack margins and the earlier accepted admission followup remain open. No runtime change/build/device action in this documentation update; no later-phase authorization inferred.
|
||||
|
||||
## Historical Status and Scope
|
||||
|
||||
2026-09-07: separately user-authorized 8D.8 is **implemented / host-tested / build-verified**. Target/browser validation and new phase signoff remain pending. No 8D.9 work, mutation/persistence controls, placeholder domains, all-subsystem settings snapshot, CLI-over-HTTP, generated-asset change, upload, erase or commit. Worktree was clean at task entry; no unrelated edits were reverted.
|
||||
|
||||
**M2 remains explicitly signed off** by the user ("Jupp, sign M2 off"). Accepted 8D.7 scope does not need revalidation. Deferred browser self-target/generated-password/key/legacy-credential and other owner-specific command restrictions remain unchanged; bootstrap/recovery remain permanently UART0-only. The intermittent supported two serial + one admin admission issue remains accepted nonblocking, unresolved and undiagnosed. Numeric memory reserves, low historical internal/DMA minima and stack margins remain unapproved followups. Nothing here establishes full browser command parity or fixes that admission issue.
|
||||
|
||||
## Implemented Contract
|
||||
|
||||
- `src/web_ui.c`: Settings joins the existing admin-only Serial/Admin selector and opens the only implemented settings page, Serial, within the same document. It displays working UART1 service state, baud, data bits, parity, stop bits, flow control, DTR behavior and RTS threshold. It explicitly does not represent saved NVS state.
|
||||
- Selection never creates/closes a serial or admin socket, joins/disconnects the broker, or requests/releases the writer lease. Both hidden terminals keep draining through their existing independent 5,000-line scrollbacks and 64 KiB pending-output bounds/drop notices. Only the selected terminal accepts keyboard input; neither does in Settings. Writer status and explicit writer controls remain available.
|
||||
- Entry/Refresh first revalidates the existing username/role/session-stable CSRF identity, then requests `GET /api/settings/serial`. Settings validation must not supersede an in-flight serial-admission session check; newer admission may supersede an older Settings check, which returns to explicit retry. Identity change still requires a clean document before adoption. Absolute expiry is never extended.
|
||||
- One active Settings refresh, no new periodic poll, no automatic retries, and the existing fixed-path/same-origin/no-store/redirect-denial fetch helper. Each fetch has a 15-second deadline; the two sequential session/snapshot requests can take up to approximately 30 seconds plus scheduling. Snapshot input is capped at 256 bytes, fatal UTF-8/JSON decoded, checked for exactly eight fields with bounded types/ranges/enums, and rendered only with `textContent`.
|
||||
- Leaving Settings aborts and clears it. Logout/401/expiry/pagehide use existing two-route teardown and also clear/hide Settings. Late replies cannot repopulate it. Same-session bfcache restore revalidates before showing the empty page; Refresh is explicit and admin is not reopened automatically. Ordinary Settings failure does not close either terminal. Shared bounded error messages never display arbitrary response/error bodies; 429/503 retry hints are capped, and retries are manual.
|
||||
- The external authored app script changed; the inline loader did **not**. Its two existing CSP hashes remain exact and unchanged, verified against the actual rendered loader. CSP was not relaxed. No vendored/generated assets were regenerated.
|
||||
|
||||
## Backend and Ownership
|
||||
|
||||
`serial_service_get_snapshot()` copies `serial_config_t` and running state under one **zero-wait** acquisition of the existing serial state mutex. NULL is invalid; a non-NULL output is cleared on failure; uninitialized returns invalid-state and contention returns timeout. No UART/GPIO/NVS/broker IO, additional allocation or new lock. This avoids the blocking config getter on the new HTTPD route and cannot stall behind a console stop/reconfiguration. It is a point-in-time working snapshot, not a promise that later console changes cannot occur.
|
||||
|
||||
`web_server.c` authorizes with the existing real cookie/session/current-principal policy and then requires admin before any serial read. The route accepts only bodyless GET without query parameters. Existing Host/origin binding and Fetch Metadata policy apply: same-origin GET can omit Origin; supplied cross/null Origin rejects. GET is nonmutating and requires no CSRF token. Duplicate headers, malformed/oversized credentials, transfer framing, unknown query fields and bodies reject through existing bounded policy. Unread rejected bodies cause connection closure rather than unbounded draining. Auth/principal temporaries and consumed header scratch are wiped.
|
||||
|
||||
Success is bounded JSON (256-byte stack buffer including terminator), containing only fixed firmware enum names, boolean and integers. No secrets, configuration version/storage blobs, counters or unrelated subsystem data are serialized. Missing/expired/revoked login gives 401; normal role gives 403; invalid request gives 400; busy/uninitialized serial gives 503 with `Retry-After: 1`. Common error routes give fixed 404/405 for unknown routes/methods. Responses are no-store/nosniff/no-referrer. Header/send failures stop without a second response; existing aggregate request/auth/response counters are reused.
|
||||
|
||||
Registration is optional and independent of admin-console attachment: failure leaves base auth/serial/admin usable, with Settings returning the existing safe 404 and manual retry message. URI capacity increases from 16 to 17; sockets remain six with LRU disabled. No admission eviction or capacity change for sessions, challenges, tickets, console slots or WebSockets.
|
||||
|
||||
### IDF Allocation Boundary
|
||||
|
||||
Review of installed IDF 5.5.0 `httpd_uri.c:147-163` found that public URI registration publishes an allocated descriptor before duplicating its name; name-allocation failure frees it without clearing `hd_calls`. Ignoring that failure for the new optional route could leave a dangling table entry. `web_httpd_register_optional_get()` in the existing private adapter stages both ordinary heap allocations before publishing the fully initialized entry. It is restricted to serialized startup, exact-match GET, no WebSocket/subprotocol and a 127-byte URI limit; duplicate/full/invalid shape rejects without publication. HTTPD retains normal ownership and frees both allocations. No SDK patch or runtime registry framework was added.
|
||||
|
||||
The new route alone uses this helper. Existing public registration callers were not refactored; broader inherited SDK allocation-failure handling remains a followup, not a diagnosed cause of the accepted admission issue. The version-pinned adapter now also requires registration/table/free-ownership re-audit on SDK updates. Host tests inject failure at both actual adapter allocations and compile the installed unregister implementation to exercise successful cleanup. They do not prove multicore registry timing or real low-heap behavior.
|
||||
|
||||
## Resources
|
||||
|
||||
Final release `pio run`, tool timeout **120 seconds**, passed in **24.31 seconds**, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0 / N16R8:
|
||||
|
||||
| Resource | Final / Delta |
|
||||
|---|---|
|
||||
| Linked RAM | **95,580 B**, **0 B** vs recorded 8D.7; **+1,048 B** vs 8D.0 |
|
||||
| Flash | **1,654,529 B**, **+5,952 B** vs recorded 8D.7 (1,648,577 B); **+54,556 B** vs 8D.0 (1,599,973 B) |
|
||||
| HTTPD handlers | 17, +1; table +4 B on target |
|
||||
| New route allocations | Target descriptor 24 B + URI/name terminator 21 B = 45 B; with table growth **49 B requested heap**, excluding allocator rounding/metadata |
|
||||
| Snapshot / response scratch | Target snapshot 36 B; response array 256 B; caller principal 40 B |
|
||||
| Compiled local frames | Settings handler 416 B, snapshot getter 32 B, registration adapter 48 B, **excluding callees** |
|
||||
| Authored HTML/app arrays | 10,432 / 32,061 B including terminators in target ELF; flash-resident, not module heap |
|
||||
| Tasks/stacks/queues | No new task, stack-size change, queue, depth, mutex or timer |
|
||||
| Sockets/capacity | Six HTTPS sockets, two serial + one admin WebSocket; all session/ticket/shared-console capacities unchanged |
|
||||
| Browser resources | Eight fixed value nodes, one active refresh; snapshot body <=256 B, session body <=512 B; existing terminal bounds unchanged |
|
||||
|
||||
Sizes/entry frames above were inspected with the target GDB against the final ELF. The URI table allocation was verified in installed `httpd_main.c` and descriptor/free ownership in `httpd_uri.c`. Ordinary `malloc`/`calloc` follow existing IDF allocator policy, with no new PSRAM-only requirement or fallback policy; conservatively budget all 49 requested bytes as possible internal heap. No module persistent heap or PSRAM payload was added beyond HTTPD's route ownership. TLS/request allocations, allocator overhead, actual internal versus PSRAM placement, browser engine storage and full call-chain stack usage remain unmeasured. Static RAM and local frames are **not** runtime reserve evidence.
|
||||
|
||||
Earlier pre-review build passed in 25.14 seconds at 95,580 B RAM / 1,654,021 B flash. The final build above supersedes it and includes the reconnect and allocation-publication fixes.
|
||||
|
||||
## Verification
|
||||
|
||||
All commands below passed in this task; compiler/test subprocesses and firmware builds had finite tool timeouts. No sanitizer run or hardware operation was performed.
|
||||
|
||||
| Command | Result / Scope |
|
||||
|---|---|
|
||||
| `python3 tests/web_cookie_auth/run.py --settings` | Existing store/auth suite + **5 Settings groups**: real auth/store/parser/adapter, exact extracted production handler/helper/snapshot/enum bodies, allocation failure and installed unregister; fake UART mutex/database/HTTP IO |
|
||||
| `python3 tests/web_ui_session/run.py` | **21 Node groups**, production-C renderer/headers/failures, exact loader CSP; includes 10 Settings cycles with retained sockets/IDs, hidden output/input, malformed/oversized/errors/timeout/manual retry, cancellation/restore/identity and concurrent serial reconnect |
|
||||
| `python3 tests/web_admin_transport/server_lifecycle.py` | **12 groups**, required/optional registration failure and stop/restart ownership; Settings allocation failure does not disable either transport |
|
||||
| `python3 tests/web_cookie_auth/run.py --admin` | Store/auth plus real admin ticket-to-upgrade integration, isolation, currentness and expiry |
|
||||
| `python3 tests/web_admin_transport/run.py --tickets` | **25 transport / 12 ticket groups** |
|
||||
| `python3 tests/web_session_store/run.py --serial` | Store and serial/session integration |
|
||||
| `python3 tests/web_login_ui/run.py` | Production renderer/CSP plus **8 Node groups** |
|
||||
| `python3 tests/web_auth_parse/run.py` | **268 cases, 0 failures** |
|
||||
| `python3 tests/admin_console_boundary/run.py` | Shared console/currentness, deferred certificate handoff and production SSH adapter regressions |
|
||||
| `python3 tests/admin_console_boundary/lifecycle.py` | Canonical lifecycle and unchanged SSH/UART0 paths |
|
||||
| `python3 tests/admin_console_boundary/accounts.py` | Existing account operation-admission/persistence/isolation regressions |
|
||||
| `python3 tests/admin_ssh_policy/run.py` | Existing parsed restrictions and role/transport policy |
|
||||
| `pio run` | Final build above, PASS |
|
||||
| `git diff --check` | PASS |
|
||||
|
||||
The initial Settings test incorrectly reused a session after testing authoritative stale-principal invalidation; the real store correctly retired it. Fixtures now mint independent sessions for stale, database-failure and success cases. This was a test error, not an authentication-policy change.
|
||||
|
||||
`todowrite` and a task/subagent tool were unavailable in this session. Progress was tracked in commentary and this record. A separate implementer review pass found/fixed the reconnect-supersession and IDF publication issues, followed by affected suite reruns and the final build. **Independent agent review was not available and is not claimed.** No remaining actionable finding was identified in the implementer's final review; that is not an independent review result.
|
||||
|
||||
### Independent Review Completion
|
||||
|
||||
Parent obtained a separate agent review after implementation. No actionable findings were identified in authorization/secrecy/bounds, nonblocking snapshot locking, navigation/session/expiry races, or the optional registration adapter's ownership against installed IDF 5.5.0. Reviewer reran Settings/auth (5 Settings groups), UI (21 groups plus renderer/CSP), and server lifecycle (12 groups), all passing. This supersedes the implementer-session independent-review limitation above. No production edits or additional build were needed for review. Real browser/HTTPD scheduling, hardware and runtime reserve limits remain unverified.
|
||||
|
||||
## Pending Target Checklist
|
||||
|
||||
- Record flashed revision/browser(s)/direct-IP or mDNS origin. On desktop and mobile, verify layout, scrolling, keyboard/touch navigation, focus/pressed states, visibility of writer controls and CSP console cleanliness.
|
||||
- Compare all eight Serial fields against UART0 `serial status` at a stable configuration, while stopped and running; make working-only changes via existing UART0/SSH admin controls, then Refresh. Confirm Settings never starts/stops/applies/saves/loads/resets serial or changes persisted values.
|
||||
- With live serial writer and open admin, switch Serial/Admin/Settings at least 10 times and Refresh. Confirm same broker client/writer IDs, continuous serial/admin output, no input while Settings is selected, retained admin prompt/history and bounded overflow notices. Confirm explicit Disconnect and Close admin still affect only their intended route.
|
||||
- Exercise normal-user direct endpoint denial; unknown query/body/method rejection; expired/logout/revoked session and account switch; delayed response/cancel, bfcache restore and explicit Refresh; unavailable/busy serial and optional route allocation failure where injectable. Confirm no old settings/terminal identity is exposed to a replacement login.
|
||||
- Run UART0 recovery, native USB UART1, user/admin SSH and both browser routes concurrently, at the supported workload used for M2 (user reported up to 230400 baud). Include Settings reads while console reconfiguration is active. Record any existing admission failure by stage without asserting it is fixed or caused by this phase.
|
||||
- Record settled boot, connected/full-mix and post-cycle/cleanup `memory` free/minimum/largest internal/DMA/PSRAM values, HTTPD and relevant task stack margins where instrumented, duration/byte/drop counts and any allocation failure. Runtime floors/budgets remain pending user approval; do not infer safety from linked RAM.
|
||||
|
||||
Stop at 8D.8 for target validation/user decision. **8D.9 is not started and requires a separate request** after that decision. Prior M2 signoff remains accepted regardless of these new-phase pending checks.
|
||||
@@ -1,93 +0,0 @@
|
||||
# Phase 8D.9 — Serial edits and persistence
|
||||
|
||||
## Target sign-off (2026-09-08)
|
||||
|
||||
The user supplied settled boot/full-client-mix telemetry and reports thorough testing of Serial parameter display/settings and user/account settings, with explicit sign-off of implemented work. **8D.9, including its automatic-completion UX refinement, is accepted**, together with implemented 8D.8/8D.10. This supersedes target-pending/signoff statements below, not historical evidence. `docs/phase8d10_implementation.md` records all samples, counters and limits. Exact flashed revision, durations and individual persistence/failure-injection checklist results were not separately supplied; broad user acceptance is not a claim that every listed case was individually observed. Numeric reserves/stack margins remain followups, not reopening sign-off. No new runtime/build/device action; wait for separately requested 8D.11 after accepted 8D.10, not inferred M3 completion.
|
||||
|
||||
## Historical state and scope (2026-09-07)
|
||||
|
||||
Implemented, reviewed, host-tested and firmware-build verified; **target/browser validation and phase signoff remain pending**. Resumed the previous agent's uncommitted implementation at the user's request. Continuation was explicitly authorized despite pending 8D.8 target validation; it does not sign off 8D.8 or 8D.9. M2 acceptance stands. No later domains, quick popover, generated assets, upload, erase, or commit actions.
|
||||
|
||||
## UX refinement (2026-09-08)
|
||||
|
||||
User reported Apply succeeds but manual Check Result hides the editor without refreshing the displayed configuration. User approved automatic completion/refresh and removal of routine disruption popups. Implemented in `src/web_ui.c`, with no backend/protocol change:
|
||||
|
||||
- Actions submit once, show applying/pending and keep settings visible with conflicting controls disabled and an explicit stale-snapshot message.
|
||||
- After acknowledged submission, result checks wait one second initially and between pending replies, capped at **10 GET attempts and a separate 15-second overall monotonic deadline** including session revalidation. The deadline aborts an in-flight check; late callbacks are fenced. Browser timer scheduling is not a hard real-time guarantee.
|
||||
- Known terminal results, including failures, automatically refresh working values while retaining outcome/uncertainty messages. Refresh failure leaves existing values visible and explicitly stale; Refresh remains available. The completion refresh has its own existing per-request bounds, outside the auto-check deadline.
|
||||
- Errors/exhaustion/lost acknowledgements stop automatic checking and retain Check Result for manual recovery. Navigation/pagehide/logout/identity changes cancel checks without auto-resumption; they do not cancel backend work. No automatic POST retry.
|
||||
- Apply/Save/Load/Defaults/Start/Stop no longer show confirmation dialogs. Reset alone confirms overwriting saved NVS configuration. Inline RAM/NVS/discard semantics remain.
|
||||
- Review found repeated selection of the current Settings tab cancelled requests/polling; selecting the current view is now a no-op, with a regression covering submission, inter-check delay, in-flight checks and completion refresh.
|
||||
|
||||
Final parent validation: `python3 tests/web_ui_session/run.py` **35 behavior groups plus renderer/header/failure/exact CSP checks**, `pio run` **PASS 10.91 s**, `git --no-pager diff --check` PASS. Build **95,708 B RAM / 1,668,837 B flash**, **0 / +2,112 B** versus the original 8D.9 build below (**+128 / +14,308 B** versus 8D.8). Static build accounting is not runtime/stack telemetry. Tests model DOM/fetch/timers/WebSockets; new flow not target-validated. No upload, erase, asset regeneration or commit. Original validation below remains historical evidence, not a claim that backend suites were rerun for this UI-only refinement.
|
||||
|
||||
## Contract
|
||||
|
||||
- Existing admin-only Settings/Serial now has typed baud, data bits, parity, stop bits, flow control, DTR and RTS-threshold drafts, plus explicit Apply, Start, Stop, Save, Load, Defaults and Reset controls. Server authorization, not UI visibility, enforces admin access.
|
||||
- `POST /api/settings/serial-operation` requires current cookie/principal, admin role, canonical Origin and CSRF. JSON is at most 256 bytes and four receive calls, with exact action-only or complete Apply schema; unknown/duplicate fields, escapes, nesting, invalid framing and out-of-range values reject. Failed unread bodies close rather than contaminate the next request.
|
||||
- HTTPD admits only an operation ID to the existing four-entry administration queue with zero wait. `web_serial_settings` owns one static session-bound pending/result slot; the existing 12 KiB dispatcher executes typed serial APIs, never CLI text or HTTPD-owned socket operations. Queue/slot exhaustion returns 503 with Retry-After; no new task or generic job framework.
|
||||
- Execution checks originating cookie/principal/admin currentness and the 30-second queue-admission deadline before side effects. This deadline is checked on dequeue: a blocked dispatcher retains the slot, and the deadline is neither an execution timeout nor a slot-release timer. Work already admitted to serial/NVS APIs may finish after logout/revocation. IDs do not wrap/reuse; stale queued IDs cannot execute replacement work.
|
||||
- Bodyless `GET /api/settings/serial-operation` exposes only the current login's retained `{id, action, state}` (96-byte response buffer). States are idle, pending, ok, failed, cancelled, loaded_defaults and rollback_failed. A subsequent admitted operation, including another session's, can replace a completed result. This is neither durable history nor an idempotent retry API.
|
||||
- Result checking is single-flight and automatically bounded after acknowledgement, with manual recovery as detailed above; mutations are never automatically retried. Lost acknowledgements and replaced results retain uncertainty warnings across further reads, Refresh and navigation. Cancellation/navigation is not a promise that admitted work stopped. Inspect current working settings and CLI storage before deciding to retry.
|
||||
- Settings navigation preserves both terminal sockets, hidden output, broker client and writer lease. The operation itself may stop/reconfigure UART1 and discard serial-service RX/TX/task-pending bytes; already-fanned broker output and writer ownership remain unchanged. Open USB can restart a stopped service.
|
||||
|
||||
### Working versus persisted state
|
||||
|
||||
| Action | Canonical behavior |
|
||||
|---|---|
|
||||
| Apply | Validate and replace all working fields; running service stop/restart and best-effort rollback use existing service API. Overwrites intervening CLI edits. No NVS write. |
|
||||
| Start / Stop | Existing service lifecycle APIs; no persistence or broker lease change. |
|
||||
| Save | Persist device working configuration at execution, **not the browser draft**. |
|
||||
| Load | Load and apply stored config; absent/incompatible storage applies defaults and reports `loaded_defaults`, without writing NVS. |
|
||||
| Defaults | Apply defaults to RAM only. |
|
||||
| Reset | Capture previous working config, apply defaults, persist defaults through canonical reset API; on persistence failure attempt runtime rollback, reporting rollback failure distinctly. |
|
||||
|
||||
Refresh replaces the browser draft. Snapshots do not expose a saved-config copy or claim a dirty/persisted comparison. There is no compare-and-swap protection against intervening CLI edits.
|
||||
|
||||
## Integration and continuation fixes
|
||||
|
||||
- `src/web_serial_settings.{c,h}`: parser, bounded operation slot, typed execution, result protocol.
|
||||
- `src/admin_ssh_console.{c,h}`: typed ID in existing queue union and dispatcher; no console slot consumed.
|
||||
- `src/web_cookie_auth.{c,h}`: bounded-JSON mutation authorization while preserving bodyless policies for existing endpoints.
|
||||
- `src/web_httpd_adapter.{c,h}`: existing failure-safe optional registration generalized to exact GET/POST. `src/web_server.c` registers result GET first and mutation POST second; partial failure cannot leave a mutation-only endpoint. Optional failure preserves both transports. SDK-private boundary remains pinned to IDF 5.5.0; inherited non-Settings registration audit remains open.
|
||||
- `src/web_ui.c`: explicit typed controls, Reset-only confirmation, bounded automatic completion/refresh and manual result recovery and session/navigation fencing. Review fixed uncertainty warnings disappearing after later result checks/Refresh/navigation.
|
||||
- `/api/status` now uses the zero-wait serial snapshot rather than blocking config acquisition. Review fixed a separate live running read mixing moments with the snapshot. When unavailable, `running` is JSON `null` and `config_available` is false; consumers must not interpret that as an authoritative stopped state.
|
||||
- Backend review found no further confirmed runtime defects; added parser/security/queue/currentness/race regressions and documented deadline/result limitations. Existing parent/user edits in `docs/phase8d8_implementation.md` were preserved.
|
||||
|
||||
## Validation performed
|
||||
|
||||
Final parent sequential run, all passed:
|
||||
|
||||
```sh
|
||||
python3 tests/web_cookie_auth/run.py --serial-settings
|
||||
python3 tests/web_cookie_auth/run.py --settings
|
||||
python3 tests/web_ui_session/run.py
|
||||
python3 tests/admin_console_boundary/run.py
|
||||
python3 tests/web_admin_transport/server_lifecycle.py
|
||||
pio run
|
||||
git --no-pager diff --check
|
||||
```
|
||||
|
||||
- Serial: 10 groups plus shared auth/store regressions, including 288 valid framing/range combinations, 256-byte/four-read boundaries, strict rejection, no HTTPD serial/NVS execution, session isolation, stale IDs, simulated concurrent reads/submissions, canonical CLI/API ordering, failures/reset rollback, account revocation and cookie expiry before the queue deadline.
|
||||
- Settings/status: 6 groups plus shared regressions. Status serial projection compiles production acquisition/format/arguments, verifies consistent snapshot across changed live state and null on unavailability; it is not the full multi-subsystem handler.
|
||||
- UI: 27 behavior groups plus production C renderer/header/failure and exact loader CSP hash checks. DOM/fetch/WebSockets are modeled, not on-wire integration.
|
||||
- Console boundary: FIFO/zero-wait typed admission, full queue preserves all four UART requests; existing console/certificate/SSH-adapter regressions.
|
||||
- Server: 13 lifecycle groups, including optional GET/POST failure and recovery, six sockets/no LRU.
|
||||
|
||||
Additional passing runs during continuation: parent transport `run.py --tickets` (25 transport/12 ticket groups), session store `run.py --serial`; backend review agent cookie `--admin`, console `accounts.py` and `lifecycle.py`. These use deterministic RTOS/UART/NVS/transport doubles, not real hardware scheduling, flash failures or rollback. No sanitizer run or target pass claimed.
|
||||
|
||||
### Resource accounting
|
||||
|
||||
Final `pio run`: **23.73 s; 95,708 B RAM / 1,666,725 B flash**. Delta against recorded 8D.8: **+128 B RAM / +12,196 B flash**; against 8D.0: **+1,176 / +66,752 B**. Build totals are static/linker accounting, not runtime free heap or stack margins.
|
||||
|
||||
Two additional optional handlers (GET and POST at one path) raise the configured URI budget from 17 to **19**. Six HTTPS sockets/no LRU, two serial sockets/one admin socket, existing queue depth, tasks and stack allocations are unchanged. Additional route descriptors/names/table pointers consume runtime heap beyond static RAM; exact allocator overhead and handler/dispatcher high-water marks have not been measured. The bounded request/result buffers are 256/96 bytes. No generated asset changes; loader hash remains unchanged and verified. Numeric memory reserves/stack margins remain unapproved.
|
||||
|
||||
## Pending target checklist / next step
|
||||
|
||||
1. Flash only by explicit user action/authorization; compare browser Refresh with UART0 serial config/status and confirm ordinary users cannot access settings/mutations.
|
||||
2. Compare every browser action with CLI semantics, valid framing boundaries and invalid requests. Verify Apply/Defaults versus Save/Load/Reset across reboot; confirm explicit Refresh draft replacement and concurrent CLI overwrite warning.
|
||||
3. Exercise start/stop/reconfigure under USB + SSH + both browser serial observers + browser/SSH admin. Confirm expected serial-service discards, unchanged broker writer/client identity, isolated output and USB restart behavior; do not mistake expected discards for a regression.
|
||||
4. Hold dispatcher in a prompt, submit once, observe bounded busy behavior and dequeue deadline cancellation. Test logout/account revocation while queued, expiry, navigation, lost acknowledgement, replacement by another tab/login, and manual uncertain-result recovery without blind resubmission.
|
||||
5. Where safely injectable, verify NVS/load/apply/rollback failures and physical port fault behavior. Host doubles are not evidence of hardware rollback success.
|
||||
6. Capture settled boot/full-mix/post-cleanup internal/DMA/PSRAM free/min/largest and dispatcher/HTTPD stack high-water data; test slow/fragmented body requests alongside serial traffic. Existing intermittent supported-mix admission issue remains accepted nonblocking and unresolved.
|
||||
7. Obtain explicit 8D.8/8D.9 target acceptance as appropriate. Stop before separately requested 8D.10. M2 is not reopened, deferred browser owner/credential restrictions remain, and no memory reserve approval is inferred.
|
||||
@@ -1,296 +0,0 @@
|
||||
# Phase 8D.0 — Baseline and M1 browser contract
|
||||
|
||||
Status: **8D.0 and 8D.1 validated by user sign-off on 2026-09-05.** Documentation/source audit, builds and target runtime samples are recorded. Numeric reserve floors and future incremental budgets remain open engineering gates, not blockers to these user-approved closures. The M1 browser contract below was established during baseline planning; the subsequent [8D.3 live cutover record](phase8d3_implementation.md) now documents implemented/host-tested/build-verified authentication and explicit 8D.3/M1 user sign-off after post-soak evidence on 2026-09-06. Numeric reserve gates remain open. Baseline measurements and source-behavior descriptions here remain historical, not measurements of the live cutover. See [execution plan](phase8d_plan.md) and [acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration).
|
||||
|
||||
## Validation sign-off
|
||||
|
||||
The subsequent [8D.2 implementation record](phase8d2_implementation.md) contains its separate build/resource accounting, target samples and user sign-off. The baseline and 8D.1 measurements below remain historical evidence, not 8D.2 target validation.
|
||||
|
||||
- The user explicitly marked **8D.0 validated** and identified the tested firmware with the latest checked-in project state, resolved at sign-off to Git revision **`d8999cd4a96e477fabd392ced02d810c3cd22d0f`**. This is user-confirmed source provenance, not an independently read-back device binary hash. The earlier reproducible build revision and SHA-256 table remain historical build evidence, not newly generated hashes for this revision.
|
||||
- The user attributes the SSH I/O errors to testing at **out-of-spec 460400 baud**. Preserve that exact reported rate separately from the transcripts' **460800-baud UART configuration**; the differing rate may describe the test setup, and no firmware baud-support change or independently reproduced diagnosis is implied. The session-revocation counter remains recorded without a separately supplied causal explanation.
|
||||
- This sign-off closes 8D.0 using the supplied evidence. Per-sample missing-provenance and unverified-check notes below describe the evidence available when collected; source-revision uncertainty is superseded by this sign-off. Unrecorded detailed checks remain regression coverage, not claims that the agent executed them or outstanding blockers to 8D.0 closure. Numeric memory/stack reserve approval and post-change 8D.1 validation are not implied.
|
||||
|
||||
## Reproducible build baseline
|
||||
|
||||
- Recorded 2026-09-05 on `devel`, revision `af89dd1bd96cdd97d8d57eee7a29f68e3874506b` (`Define staged Phase 8D delivery plan`). Working tree was clean before measurement. No experimental-branch inspection/import, firmware edits, asset regeneration, upload, erase, or device reconfiguration.
|
||||
- `pio run` passed in 36.08 seconds (120-second timeout). Release environment `esp32-s3-devkitc-1-n16r8`, `platformio/espressif32@6.12.0`, ESP-IDF 5.5.0, Xtensa toolchain 14.2.0+20241119. Existing framework Kconfig invalid-bool-default notes appeared; they did not fail the build.
|
||||
- N16R8: 16 MB QIO flash, 8 MB octal PSRAM at 80 MHz, custom partitions. Application flash budget is 4,194,304 bytes, not the full chip. Wi-Fi/lwIP allocations prefer external RAM; the configured mbedTLS allocator is PSRAM-only, without internal fallback. Software crypto configuration is retained.
|
||||
- `pio device list` completed with no ports listed. No target/browser connection was available to this task. Historical target reports are not fresh evidence for this revision.
|
||||
|
||||
| Measurement | This build | Historical mDNS reference | Difference |
|
||||
|---|---:|---:|---:|
|
||||
| Linked static RAM | 94,532 B | 94,532 B | 0 B |
|
||||
| Program flash reported by PlatformIO | 1,599,973 B | 1,599,765 B | +208 B |
|
||||
|
||||
The flash difference predates this documentation-only change; its cause was not diagnosed. Linked RAM is not runtime heap headroom. No runtime delta can be inferred from these figures.
|
||||
|
||||
SHA-256 identification (configuration and generated binary, not secrets):
|
||||
|
||||
| File | SHA-256 |
|
||||
|---|---|
|
||||
| `platformio.ini` | `65ca5103c7aaf36b685a05371a856294ba6f7f5f53209c68d61a828d3beb78e5` |
|
||||
| `sdkconfig.defaults` | `af8fb8a9866888a12219a9d4ff5148d9f45f17c40067dce90fbc55c0b75986e5` |
|
||||
| `sdkconfig.esp32-s3-devkitc-1-n16r8` | `c9c8c08b18027e959ba8131686f0e42573e507574079be3cf8fb72a9e1955905` |
|
||||
| `partitions.csv` | `9107a2aab52f02633aea72683ffede979d9db2050e95ba4788cd0f8580c7f0ff` |
|
||||
| `.pio/build/esp32-s3-devkitc-1-n16r8/firmware.bin` | `c5f10b2137d2cbffad59cda025274dd82aad1a42cdea2144d31424fd918b4e6d` |
|
||||
|
||||
## Source-verified current behavior and capacities
|
||||
|
||||
Authoritative paths: `src/web_server.c`, `src/web_serial_transport.{h,c}`, `src/web_ui.c`, `src/user_database.h`, `src/system_console.c`, `src/session_broker.{h,c}`, `src/ssh_transport.{h,c}`, `src/admin_ssh_console.c`. Configuration-dependent defaults below were also checked against installed IDF 5.5 HTTPD/HTTPS headers and implementation and targeted generated sdkconfig entries.
|
||||
|
||||
- Both roles currently use Basic authentication for the same serial/status UI; there is no login page, cookie session, logout, or browser admin route. All five app assets require authentication. Basic header buffers are wiped after verification. The four-entry keyed-digest cache has a 300-second sliding TTL and rechecks principal currentness; cache expiry is not browser logout.
|
||||
- Ticket issuance accepts only an empty POST body. Serial upgrade consumes a one-use, principal-bound ticket; supplied Origin must match `https://<Host>`, but missing Origin is currently accepted. Four tickets, 30-second lifetime, 24 random bytes encoded to 32 characters, digest-only storage. Full ticket storage currently evicts the earliest-expiring entry.
|
||||
- Serial admission opportunistically requests writer ownership. Explicit browser Disconnect closes the socket and pauses reconnect; Connect resumes it. Binary data and existing `request-writer`/`release-writer` messages remain unchanged in M1. Status polling runs every five seconds. Current fetch failures do not distinguish expired login from transport failure; cutover must change this.
|
||||
- Principals copy account ID, authentication generation, role, method and username. Database limits are eight accounts, username capacity 16 bytes, password capacity 64 bytes. Mutation/recreation invalidates principal currentness; transport notification supplements, not replaces, that authority.
|
||||
|
||||
### Allocation and execution inventory
|
||||
|
||||
These are **source array capacities/configured stacks**, not measured allocator costs or complete `sizeof` totals. Do not add stack-local scratch to the stack allocation again.
|
||||
|
||||
| Resource | Existing bounded allocation or limit |
|
||||
|---|---|
|
||||
| HTTPS owner | One dynamic internal 10,240 B stack; serialized request handlers; dynamically allocated server/socket/route/TLS state |
|
||||
| HTTP request/response scratch | Status JSON 3,072 B on stack; Basic header 115 B, decoded credentials 81 B, digest 32 B on stack; startup certificate/key scratch 1,024 + 256 B |
|
||||
| HTTP parser/response headers | Request headers 1,024 B, URI 512 B, eight additional response-header slots; implementation must count cookie + security headers before cutover |
|
||||
| TLS records | Configured input/output content limits 16,384/4,096 B per TLS connection; external allocation, dynamic resizing disabled; contexts, certificates, TCP and allocator overhead additional; HTTPS TLS resumption tickets disabled |
|
||||
| Web serial | Two static slots, each 1,024 B RX + 512 B TX (3,072 B payload total), principals and metadata; four ticket records; one static 6,144 B stack + TCB; at most one pending HTTPD TX work item per slot |
|
||||
| Broker | Eight preallocated clients, 4,096 B usable output/client (32,776 B backing storage including sentinel bytes); 16 events/client, additional `8 × 16 × sizeof(session_broker_event_t)` storage; payloads PSRAM-preferred with internal fallback, controls internal; one dynamic 4,096 B stack |
|
||||
| SSH | Two total slots shared by serial/admin/handshakes, 512 B RX + 512 B TX each (2,048 B payload total), plus snapshots/metadata; one dynamic 20,480 B owner stack pinned to core 1; wolfSSH allocations PSRAM-preferred with internal fallback |
|
||||
| Shared administration | Two static remote states with 5,899 B explicit byte arrays each (including 4,096 B output and four history entries); additional 1,024 B SSH and 1,024 B UART completion formatters; command queue four, deferred-control queue two; 256-character commands, ten parsed arguments |
|
||||
| Admin tasks | Dynamic dispatcher 12,288 B, deferred control 4,096 B, UART frontend 6,144 B; no task per remote session |
|
||||
| Serial service | 16 KiB RX + 8 KiB TX usable stream payloads PSRAM-preferred with internal fallback; UART driver/control/task storage additional |
|
||||
|
||||
The seven listed HTTPD/web/broker/SSH/admin task stacks total **63,488 B**; this is not a whole-firmware task inventory. Other existing tasks, including Wi-Fi, USB, serial, UI, mDNS and system tasks, contribute to the measured baseline. No new task is allowed for M1. Internal fallback is a worst-case reserve cost, not free PSRAM capacity. Exact padded structure sizes, per-TLS/SSH handshake peaks and fragmentation remain unmeasured.
|
||||
|
||||
### Supported concurrency and socket accounting
|
||||
|
||||
Configured capacity is not evidence that every combination has passed this revision's target tests.
|
||||
|
||||
| Resource | Baseline ceiling / intended workload |
|
||||
|---|---|
|
||||
| Broker clients | Eight, exactly one writer; normal physical-transport maximum is USB + two web serial + two user SSH = five |
|
||||
| Web serial | Two simultaneously connected serial WebSockets, independently of four outstanding tickets |
|
||||
| SSH | Two connections total; baseline full mixed workload uses one user and one admin |
|
||||
| HTTPD routes | Nine registered / nine slots: `/`, `/api/status`, `/api/ws-ticket`, `/ws/serial`, five `/assets/` routes; no spare route slot |
|
||||
| HTTPS connections | Six accepted clients total, including WebSockets; two serial sockets leave four HTTP/TLS slots, not four parallel handlers |
|
||||
| HTTPD infrastructure | Three more descriptors: listen, UDP control receive, UDP control send; full HTTPS therefore uses nine |
|
||||
| SSH descriptors | Listener + two sessions = three; combined HTTPS/SSH subtotal twelve of sixteen lwIP sockets |
|
||||
| Remaining socket allowance | Four before other services (including mDNS), network diagnostics and transient accept/reject costs; not four guaranteed spare connections |
|
||||
|
||||
HTTPS has LRU purge **enabled**, one-second send/receive waits and five-second handshake timeout; SSH has a two-connection listen backlog and 15-second handshake deadline. Before M2, admission must protect retained serial sockets from LRU eviction; do not claim current settings already guarantee that protection. M2 must budget two browser sockets (serial + admin) per dual-mode browser: two such browsers would occupy four of the six HTTPS slots, leaving only two for ordinary HTTPS. This is arithmetic, **not approval of a future admin capacity**. Do not raise lwIP/HTTPD limits or reduce existing serial/SSH capacities to conceal pressure.
|
||||
|
||||
## Minimal M1 browser contract (planned)
|
||||
|
||||
Policy constants below are bounded functional choices, **not approved memory reserve floors**. No settings API, admin shell, generic command endpoint, new permanent task, Basic compatibility path, stored browser passwords/localStorage credentials/remember-me tokens, or generated-asset changes are included.
|
||||
|
||||
### Routes and small wire schema
|
||||
|
||||
All API JSON is UTF-8, length-bounded and safely encoded. Login accepts only `application/json`; no form/query credentials, duplicate/unknown fields, embedded NULs, oversized bodies, or unbounded parser allocations. Maximum login body: **512 bytes**, decoded username/password limits **16/64 bytes**, using existing database validation. Reject excessive bodies before buffering. New authentication JSON responses have a **512-byte serialized ceiling**; the existing status schema/buffer is unchanged. `X-CSRF-Token` is the only CSRF request header.
|
||||
|
||||
| Route | Access and contract |
|
||||
|---|---|
|
||||
| `GET /login` | Public minimal standalone login document; inline bounded CSS/script with matching CSP hash, no dependency on protected app assets. No automatic login or credential verification. |
|
||||
| `GET /api/login-challenge` | Public same-origin pre-login bootstrap, empty body; returns `{ "csrf": string, "expires_in": integer }` (remaining whole seconds, at most 120) and pre-login cookie described below. |
|
||||
| `POST /api/login` | Strict Origin + pre-login cookie + CSRF header; body `{ "username": string, "password": string }`; success `200 { "authenticated": true }` and new session cookie. Browser navigates to `/`. |
|
||||
| `GET /api/session` | Session required; returns `{ "username": string, "role": "user" or "admin", "csrf": string, "expires_in": integer }`, remaining whole seconds. No raw session token or account/verifier internals. |
|
||||
| `POST /api/logout` | Session + strict Origin + CSRF; empty body. Invalidate current session before `204`, expire cookie, browser navigates to `/login`. Never a state-changing GET. |
|
||||
| `GET /`, five existing `/assets/` routes | Session protected. Unauthenticated document navigation to `/` receives `303 /login`; assets/API receive `401`, never login HTML masquerading as JS/JSON. |
|
||||
| `GET /api/status` | Session required; existing status for either role. |
|
||||
| `POST /api/ws-ticket` | Session + strict Origin + CSRF; empty body; preserve existing ticket response fields/protocol, bind ticket to originating session as well as principal. |
|
||||
| `GET /ws/serial` upgrade | Session cookie + strict Origin + one-use matching-session ticket; no Basic fallback. Currentness and expiry checked before attach. |
|
||||
|
||||
Five new handlers mean **14 route slots total** at cutover if using these exact routes. This is a separately accounted bounded route-table increase, not a socket increase. Preflight/CORS access is unsupported. Other methods must not bypass policy. API failures use a bounded `{ "error": code }` without echoing submitted input; existing non-auth errors may remain plain text and the browser must tolerate both.
|
||||
|
||||
### Sessions, CSRF bootstrap and admission
|
||||
|
||||
- **Four authenticated sessions globally**, independent of two serial sockets and four outstanding serial tickets. New login issues 32 random bytes as 64 lower-case hex characters; store only a SHA-256 token digest with copied principal, origin binding, CSRF state, monotonic deadline and generation-safe session identity. Use `secure_random`; fail closed on RNG/init failure. Keep synchronization explicit for HTTPD lookups versus console invalidation; no cross-subsystem callbacks while holding session storage locks.
|
||||
- Cookie: `__Host-sak-session=<token>; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`, no Domain. **Absolute lifetime one hour; idle expiry disabled in M1.** Polling, traffic and refresh never extend it. No sliding renewal or refresh token; reauthentication after expiry issues a new identity. This deliberately simple policy will interrupt a long-running serial session at expiry and must be visible to the user. Reboot/web-server stop clears sessions and tickets. Browser cookie lifetime is not server authority.
|
||||
- CSRF state: independent 32 random bytes encoded as 64 hex characters, retained only in bounded session state and browser memory. Return via `/api/session` on page load; never localStorage, URL or logs. It does not substitute for the HttpOnly session cookie. Refresh does not rotate it or invalidate another tab.
|
||||
- **Four independent pre-login challenges**, 120-second absolute lifetime. Bootstrap issues `__Host-sak-prelogin` with the same cookie attributes and `Max-Age=120`, backed by token digest, origin binding, separate random CSRF value and deadline. `/login` itself allocates nothing. Bootstrap reuses an unexpired matching challenge for that browser without reissuing the cookie or extending its lifetime, returning remaining seconds; otherwise reclaim expired slots and reject at capacity. Require a same-origin custom header `X-Login-Bootstrap: 1` on the bootstrap fetch; reject cross-site Fetch Metadata when present, and validate any supplied Origin. No CORS headers: another origin cannot read a challenge or make that custom-header request. This handles same-origin GETs where browsers omit Origin without weakening POST checks.
|
||||
- Login consumes a valid challenge **on every credential attempt**, including wrong passwords; clear its cookie, wipe request/password scratch on all exits, and fetch a new challenge for a user-initiated retry. Concurrent tabs share the cookie: a stale form gets a recoverable challenge-expired error, not a login loop. Successful login always generates a fresh authenticated token (no fixation). Already authenticated browsers receive `409 already_authenticated` instead of silently replacing an account/session; account switching requires logout.
|
||||
- Reclaim expired/stale records first; full live session/challenge/ticket tables return `503 capacity` with `Retry-After: 5`, with **no live-session/ticket eviction**. Thus ticket overflow intentionally changes from baseline earliest-expiry eviction to explicit rejection at cutover. A session never reserves a serial socket; full serial capacity can reject Connect without discarding login or disturbing an existing writer.
|
||||
- Login throttle: one global bounded monotonic bucket allowing **five credential-verification attempts per 60-second window**, successes included; further attempts get `429 throttled` with seconds until window reset in `Retry-After`. Invalid CSRF/Origin never reaches password verification. No per-username/IP table, sleep in HTTPD, secret retention or persistent lockout. Tradeoff: a hostile client can exhaust shared login capacity; UART0/USB and established sessions remain usable. Do not claim comprehensive DoS resistance; measure PBKDF2 blocking/TLS pressure on target.
|
||||
|
||||
### Origin, expiry and revocation rules
|
||||
|
||||
- Every mutation (login, logout, ticket issuance) and WS upgrade requires a single non-null HTTPS Origin equal to the request's validated Host authority after lower-casing host and normalizing default port 443. Reject absent/malformed/oversized Origin, userinfo, paths, query/fragment, comma lists, invalid Host or non-443 port. Use bounded host/origin buffers; do not trust forwarded headers. A Referer is not a substitute. Bind challenge/session/ticket to that canonical origin. No arbitrary return URLs.
|
||||
- Direct IP and `sak-<suffix>.local` are separate host-only cookie origins, even on one device. Login/logout on one does not affect the other's unchanged session; no alias cookie sharing. Both require trusted/accepted device TLS in the browser. Hostname changes do not migrate cookies.
|
||||
- Lookup/admission, ticket mint/consume and sensitive WS input recheck session liveness plus principal currentness. Extend existing owner-task periodic checks to enforce expiry/revocation for idle sockets, with a planned **at most one-second detection-latency acceptance target**, no new task. This bound is not established by the current loop; implementation and target validation must include lock contention and concurrent load. Mark invalid immediately and request generation-safe close through HTTPD ownership; network delivery/close completion can take longer. Failed notification must not allow more serial input or revive an expired session.
|
||||
- Logout invalidates only the originating session and its tickets/serial sockets (and later admin sockets); other sessions for the same unchanged account survive. Logout in one tab affects all tabs sharing that cookie. Account password/role/key changes, deletion/recreation and explicit account revocation invalidate all that account's sessions/tickets/sockets across origins, not unrelated accounts. Preserve distinct account, web-session and transport generations. Wipe retired records; late cleanup cannot close reused slots.
|
||||
- All login/session/auth errors, protected documents, API and ticket responses use `Cache-Control: no-store`; preserve nosniff, no-referrer, frame denial and restrictive CSP. Existing immutable vendored asset policy can remain; no auth-dependent data in those assets. Count response headers against HTTPD's eight-slot default, including Set-Cookie (two on login); adjust only if the exact count requires it. Never log cookies, CSRF, ticket query strings, passwords, verifier or private-key material.
|
||||
|
||||
### Browser recovery behavior
|
||||
|
||||
- `401`: stop polling/reconnect work, close local socket, clear in-memory CSRF, navigate once to `/login`. On login submission, `401 invalid_credentials` stays on the form with a generic usable error; it must not reveal account existence. No `WWW-Authenticate`, cached Basic header authorization or browser credential-reset instructions after cutover.
|
||||
- `403`: report CSRF/origin failure without retrying a mutation automatically; allow challenge/session reload and explicit retry. `400`/`413`/`415`: show input/request error. `409`: offer return to app/logout. `429`/`503`: display bounded retry information and do not retry credentials automatically.
|
||||
- Transport/network error: show connection failure without claiming logout succeeded. A lost logout response can follow successful invalidation: check session status when reachable; `401` confirms login is needed. Do not rely solely on client cookie deletion (HttpOnly).
|
||||
- Refresh/back navigation validates `/api/session` before ticket/reconnect; expired cookies, reboot and previously Basic-authenticated profiles must all land on usable login. Explicit Disconnect still pauses reconnect while leaving login valid. Capacity and network failure must not look like bad credentials. Cancel pending work on logout/page exit and ignore late responses via generation checks.
|
||||
|
||||
## Runtime measurements, reserve gates and exact target checklist
|
||||
|
||||
**The user-provided settled-boot and concurrent-session samples below form the 8D.0 runtime memory baseline. Other workload measurements remain pending, not zero.** `memory` reports free/minimum-free/largest-block for internal 8-bit, internal DMA and PSRAM. Record all nine values at every point. DMA overlaps internal heap: do not sum them. Minimum-free is the conservative sum of per-region lifetime minima, not a synchronized low-water sample. Only SSH currently exposes stack minimum-free via `ssh status`; HTTPD/web/broker/admin/UART task margins lack instrumentation and remain pending (no telemetry changes in 8D.0).
|
||||
|
||||
| Workload point | Internal free/min/largest | DMA free/min/largest | PSRAM free/min/largest | SSH stack minimum-free |
|
||||
|---|---|---|---|---|
|
||||
| **Settled clean boot, ~60 seconds, Wi-Fi STA connected, mDNS running (8D.0, user-provided)** | **68,036 / 67,192 / 31,744 B** | **60,280 / 59,436 / 31,744 B** | **8,198,508 / 8,188,800 / 8,126,464 B** | **18,464 B** |
|
||||
| **Basic-authenticated browser/status, browser serial explicitly disconnected, UART service running; fresh settled boot (user-provided)** | **65,340 / 60,088 / 31,744 B** | **57,584 / 52,332 / 31,744 B** | **8,212,812 / 8,158,868 / 8,126,464 B** | **18,464 B** |
|
||||
| **Basic-authenticated browser/status, browser serial explicitly disconnected, UART service stopped (user-provided)** | **69,988 / 60,280 / 31,744 B** | **62,232 / 52,524 / 31,744 B** | **8,223,088 / 8,158,832 / 8,126,464 B** | **18,464 B** |
|
||||
| **Browser/status sample: one active browser serial writer, no SSH sessions (user-provided; disconnected label unconfirmed)** | **60,980 / 55,880 / 31,744 B** | **53,224 / 48,124 / 31,744 B** | **8,163,616 / 8,109,644 / 7,995,392 B** | **18,464 B** |
|
||||
| **Updated baseline: one browser serial + user SSH + admin SSH (user-provided)** | **44,552 / 17,812 / 31,744 B** | **36,796 / 10,056 / 31,744 B** | **8,187,416 / 8,176,688 / 8,126,464 B** | **16,288 B** |
|
||||
| **Baseline follow-up: two browser serial + USB + user SSH + admin SSH (user-provided)** | **42,056 / 17,812 / 29,696 B** | **34,300 / 10,056 / 29,696 B** | **8,162,828 / 8,126,736 / 7,995,392 B** | **16,288 B** |
|
||||
| **After five browser serial disconnect/reconnect cycles, ending connected as user writer (user-provided)** | **60,776 / 59,792 / 31,744 B** | **53,020 / 52,036 / 31,744 B** | **8,163,656 / 8,153,968 / 8,126,464 B** | **18,464 B** |
|
||||
| **After 15-minute concurrent soak, clients disconnected, then 60-second settled cleanup; UART running (user-provided)** | **65,040 / 17,812 / 31,744 B** | **57,284 / 10,056 / 31,744 B** | **8,212,596 / 8,126,736 / 8,126,464 B** | **16,288 B** |
|
||||
| Cookie login/logout and browser admin connected | Not implemented | Not implemented | Not implemented | Not implemented |
|
||||
|
||||
### Settled clean-boot sample (8D.0 firmware)
|
||||
|
||||
The user supplied this transcript on 2026-09-05 after 8D.1 work had begun, explicitly identifying the measured firmware as based on **8D.0**, not 8D.1. Conditions reported: clean boot, settled after approximately 60 seconds, Wi-Fi STA connected and mDNS running. Commands were `memory`, `ssh sessions`, `broker clients`, `ssh counters`, `web counters`, `web status`, then `mdns status`; these are sequential user-provided snapshots, not an atomic sample or an agent-executed test.
|
||||
|
||||
- SSH sessions **0/2**; no broker clients connected. HTTPS initialized/running, not transitioning, port 443, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins. Web serial attached, sessions **0/2**, zero tickets. Endpoints reported: `GET /`, `GET /api/status`, `POST /api/ws-ticket`, `WSS /ws/serial`.
|
||||
- mDNS initialized and announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. This records announcement status, not an independently verified hostname-resolution test.
|
||||
- SSH counters: lifecycle starts **1**, start-failures/stops/TCP connections/capacity rejects **0**. All handshake, authentication, request-rejection, broker, admin-console and stream counters **0**, including failures, backpressure and revocations.
|
||||
- Web counters: lifecycle starts **1**, start-failures/stops **0**. Requests total **29**, authenticated **29**, status **29**; auth-failures/root/tickets/assets/response-errors **0**. All ticket, WebSocket session, RX, TX, writer-control and failure counters **0**, including service-start/broker failures and closes.
|
||||
- The 29 authenticated status requests mean this is a settled boot with HTTPS status activity, not a no-HTTP-traffic idle sample. Browser/version, request origin and polling source were not supplied; do not infer an additional controlled browser-only workload measurement from these counters.
|
||||
- Follow-up user-provided `ssh status` for settled boot: initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**. Authentication reports role-based password and SSH public key via the user database; admission reports shell/PTY only, with exec, subsystem, forwarding, SCP and SFTP disabled. Owner task on core **1**, configured stack **20,480 B**, stack minimum-free **18,464 B**. This is a settled-boot stack low-water measurement, not a loaded-workload margin or approved reserve floor.
|
||||
- Exact flashed revision/configuration or binary hash was not supplied. The user's 8D.0 firmware attribution is retained without independently tying this device to the build hash above. This sample does not validate 8D.1, establish reserve floors or measure post-load cleanup; do not treat comparisons with the earlier concurrent samples as controlled per-client allocation deltas.
|
||||
|
||||
### Browser/status sample with active serial WebSocket
|
||||
|
||||
The user supplied this additional baseline transcript on 2026-09-05 labelled "Basic-authenticated browser/status, serial explicitly disconnected." However, the supplied `web status` reports **one active serial writer**, and counters report one connection with zero disconnects/closes. Preserve the measurements separately; the later disconnected sample below also stops the UART service. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, then `ssh status`, so the snapshots are not atomic.
|
||||
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoint list. Web serial attached, sessions **1/2**, zero tickets; slot **0**, fd **56**, generation **1**, role-`user` password authentication, broker **8 writer**, no TX pending or closing state. No `broker clients` snapshot was supplied, so other broker-client presence is not established.
|
||||
- Web lifecycle: starts **1**, start-failures/stops **0**. Requests total/authenticated **28**, root **1**, status **26**, tickets **1**, assets **0**, auth-failures/response-errors **0**. Tickets issued/consumed **1**, rejected/expired **0**. WebSocket connects **1**, disconnects **0**, connect/service-start/broker failures **0**.
|
||||
- WebSocket RX: **14** accepted frames / **14 B**, no rejected frames/bytes. TX: **18** binary frames / **753 B**, **2** control frames / **148 B**. Writer requests/grants **1**, denials/releases/revocations **0**; send/queue/protocol failures and closes **0**.
|
||||
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**; role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **18,464 B**. SSH lifecycle starts **1**; all other supplied SSH counters **0**.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`.
|
||||
- This is user-provided baseline evidence, not an 8D.1 target-validation claim. Exact flashed revision/hash, browser/version/origin, settling duration and serial fixture were not supplied. Web request totals are lower than in the settled-boot transcript; same-boot continuity is not established, and no controlled heap delta is inferred. Lifetime minima include prior activity, including the reported serial traffic.
|
||||
|
||||
### Browser disconnected, UART service stopped
|
||||
|
||||
The user supplied this follow-up 8D.0 baseline transcript on 2026-09-05, reporting that the browser automatically connected serial after login, then was explicitly disconnected and the serial service stopped. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, then `serial status`; these are sequential observations, not an atomic sample or an agent-executed test.
|
||||
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **0/2**, zero tickets. This confirms browser serial disconnection; no `broker clients` snapshot was supplied to establish other broker-client presence.
|
||||
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **26**, root **1**, status **24**, tickets **1**, assets **0**, auth-failures/response-errors **0**. Tickets issued/consumed **1**, rejected/expired **0**. WebSocket connects/disconnects **1** each; connect/service-start/broker failures **0**.
|
||||
- WebSocket RX accepted/rejected frames and bytes **0**; TX binary frames/bytes **0**, control frames **2** / **148 B**. Writer requests/grants **1**, denials/releases/revocations **0**. Send/queue/protocol failures and closes **0**; the zero closes counter is preserved separately from the reported disconnect count.
|
||||
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**; role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **18,464 B**. SSH lifecycle starts **1**, all other supplied SSH counters **0**.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`.
|
||||
- UART service **stopped**, RS-232 owner **idle**. Configuration v1: **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Phase 0 hardware commands reported available while stopped; none are claimed executed.
|
||||
- Stopping the UART service changes the allocation workload, so this sample is not a substitute for browser-disconnected memory with the service running. That separate workload is recorded in the fresh-boot sample below. Prior automatic connection/startup can contribute to lifetime minima. Exact build hash, browser/version/origin, settling duration and same-boot continuity with earlier samples remain unspecified; do not infer controlled allocation savings, reserve floors or 8D.1 validation.
|
||||
|
||||
### Browser disconnected, UART service running
|
||||
|
||||
The user supplied this follow-up 8D.0 baseline transcript on 2026-09-05 after a **fresh, settled boot**, with the UART service running and browser serial explicitly disconnected after login. It fills the intended Basic-authenticated browser/status workload row. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, then `serial status`; these are sequential user observations, not an atomic sample or an agent-executed test. Exact settling duration was not supplied.
|
||||
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **0/2**, zero tickets. No `broker clients` snapshot was supplied to establish other broker-client presence.
|
||||
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **31**, root **1**, status **28**, tickets **1**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **1**, rejected/expired **0**. WebSocket connects/disconnects **1** each; connect/service-start/broker failures **0**.
|
||||
- WebSocket RX accepted/rejected frames and bytes **0**; TX binary frames/bytes **0**, control frames **2** / **148 B**. Writer requests/grants **1**, denials/releases/revocations **0**. Send/queue/protocol failures and closes **0**; closes and disconnects are distinct reported counters.
|
||||
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**; role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **18,464 B**. SSH lifecycle starts **1**, all other supplied SSH counters **0**.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`.
|
||||
- UART service **running**, RS-232 owner **serial service**. Configuration v1: **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted: **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
|
||||
- Lifetime minima include startup and the browser's initial automatic serial connection, not just the disconnected state. This fresh boot is separate from previous samples; no controlled per-connection or service-stop allocation delta is inferred. Exact flashed revision/hash and browser/version/origin remain unspecified. This fills the workload measurement, not reserve-floor approval, serial data-integrity testing or 8D.1 target validation.
|
||||
|
||||
### After five browser serial disconnect/reconnect cycles
|
||||
|
||||
The user supplied this 8D.0 baseline transcript on 2026-09-05, reporting **five browser serial disconnect/reconnect cycles**. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, then `serial status`; these are sequential user observations, not an atomic sample or an agent-executed test. The endpoint is **connected**, not settled disconnected cleanup.
|
||||
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **1/2**, zero tickets. Slot **0**, fd **54**, generation **8**, role-`user` password authentication, broker **64 writer**, no TX pending or closing state. No `broker clients` snapshot was supplied to establish other clients or independently check old-client cleanup.
|
||||
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **152**, root **3**, status **140**, tickets **8**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **8**, rejected/expired **0**. WebSocket connects **8**, disconnects **7**, connect/service-start/broker failures **0**. These cumulative counts include more activity than the reported five test cycles; no per-cycle snapshots or counter-reset boundary were supplied.
|
||||
- WebSocket RX **11** accepted frames / **33 B**, rejected frames/bytes **0**. TX **43** binary frames / **8,413 B**, **16** control frames / **1,218 B**. Writer requests/grants **8**, denials/releases/revocations **0**. Send/queue/protocol failures and closes **0**; the zero closes counter is retained separately from seven disconnects.
|
||||
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**; role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **18,464 B**. SSH lifecycle starts **1**, all other supplied SSH counters **0**.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. UART service **running**, RS-232 owner **serial service**; configuration v1 **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
|
||||
- This records the reported five-cycle endpoint, successful cumulative admissions/writer grants and traffic without reported web failures. It does not prove byte integrity, paused reconnect behavior, observer isolation, per-cycle cleanup or a leak/fragmentation trend. Only the final normal-user role is shown; five cycles per role, including admin-role browser coverage, remain unverified. Exact flashed hash, browser/version/origin, timing and same-boot continuity with prior samples were not supplied. No reserve-floor approval or 8D.1 target validation is inferred.
|
||||
|
||||
### Updated one-browser plus user/admin SSH baseline
|
||||
|
||||
The user supplied this updated 8D.0 baseline transcript on 2026-09-05 for **one browser serial + user SSH + admin SSH**. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, then `serial status`; these are sequential user observations, not an atomic sample or an agent-executed test. The superseded original measurement was removed at the user's request.
|
||||
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **1/2**, zero tickets. Slot **0**, fd **53**, generation **1**, role-`user` password authentication, broker **8 writer**, no TX pending or closing state.
|
||||
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **2/2**, both public-key authenticated. Admin session **5**, slot **0**, routes to the admin console with no broker client; user session **6**, slot **1**, routes to broker **9 observer**. Both report active/authenticated, admin-command idle, zero admin output, no RX/TX pending or closing state. Authentication/admission remains role-based password/public-key, shell/PTY only, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **16,288 B**.
|
||||
- SSH lifecycle starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, handshake failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, writer requests **1**, grants **0**, denials **1**, failures/disconnects/revocations **0**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted/rejected **0**, TX **657 B**, I/O failures/session revocations **0**.
|
||||
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **49**, status **48**, tickets **1**, root/assets/auth-failures/response-errors **0**. Tickets issued/consumed **1**, rejected/expired **0**. WebSocket connects **1**, disconnects/connect failures/service-start failures/broker failures **0**.
|
||||
- WebSocket RX **19** accepted frames / **19 B**, rejected frames/bytes **0**. TX **39** binary frames / **7,681 B**, **3** control frames / **237 B**. Writer requests/grants **1**, denials/releases/revocations **0**; send/queue/protocol failures and closes **0**. The SSH writer denial is consistent with the browser retaining writer ownership.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. UART service **running**, RS-232 owner **serial service**; configuration v1 **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
|
||||
- No `broker clients` snapshot was supplied, so the transport snapshots establish the browser writer/user SSH observer but not the absence of additional broker clients such as USB. Exact flashed revision/hash, browser/version/origin, uptime, settling/soak duration and same-boot continuity with earlier samples remain unspecified. This records concurrent admission, traffic counters and an SSH stack low-water measurement, not byte integrity, a leak trend, reserve-floor approval or 8D.1 validation.
|
||||
|
||||
### Follow-up full-client-mix sample
|
||||
|
||||
The user supplied this replacement 8D.0 baseline transcript on 2026-09-05 for **two browser serial + USB + user SSH + admin SSH**, including broker/USB snapshots and requesting that it overwrite the previous measurement. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, `serial status`, `broker clients`, then `usb status`; these are sequential observations, not an atomic sample or an agent-executed test.
|
||||
|
||||
- SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **2/2**, both public-key authenticated: admin session **5**, slot **0**, admin-console route with no broker; user session **6**, slot **1**, broker **9 observer**. Both active/authenticated, admin-command idle, zero admin output, no RX/TX pending or closing state. Role-based password/public-key authentication and shell/PTY-only admission remain reported, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **16,288 B**.
|
||||
- SSH lifecycle starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, handshake failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, writer requests **1**, grants **0**, denials **1**, failures/disconnects/revocations **0**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted/rejected **0**, TX **17,312 B**, I/O failures/session revocations **0**.
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **2/2**, zero tickets, both password-authenticated normal-user sessions for the same account. Slot **0**, fd **56**, generation **2**, broker **16 writer**; slot **1**, fd **57**, generation **1**, broker **10 observer**. Neither slot TX-pending or closing.
|
||||
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **181**, root **2**, status **175**, tickets **3**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **3**, rejected/expired **0**. WebSocket connects **3**, disconnects **1**, connect/service-start/broker failures **0**.
|
||||
- WebSocket RX **25** accepted frames / **25 B**, rejected frames/bytes **0**. TX **274** binary frames / **41,456 B**, **10** control frames / **823 B**. Writer requests **3**, grants **2**, denials **1**, releases/revocations **0**; send/queue/protocol failures and closes **0**. Writer denials are consistent with observer admission, not reported transport failures.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. UART service **running**, RS-232 owner **serial service**; configuration v1 **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
|
||||
- Broker lists exactly four clients: web **16 writer** (`web-0-2`), SSH **9 observer** (`ssh-1-1`), web **10 observer** (`web-1-1`), USB **11 observer** (`usb-cdc`), all with **zero pending bytes/events**. Admin SSH is not a broker client.
|
||||
- USB CDC initialized/attached, host-open **yes**, host DTR/RTS **yes**; broker client **11 observer**. Last host line coding **38400 baud, 8N1** is reported only and does not change UART1's **460800 baud, 8N1** configuration. This confirms USB attachment/admission, not USB byte-integrity testing. Both browsers are normal-user sessions, so admin-role browser login remains unverified.
|
||||
- Exact flashed revision/hash, browser/version/origin, fixture, durations and same-boot continuity remain unspecified. This records concurrent network admission and traffic, not serial byte integrity, a timed soak, controlled per-client allocation costs or 8D.1 validation. Internal/DMA minima **17,812 / 10,056 B** are observed lows, not approved reserve floors; DMA overlaps internal heap.
|
||||
|
||||
### Settled post-soak cleanup
|
||||
|
||||
The user supplied this 8D.0 baseline transcript on 2026-09-05 and subsequently confirmed the timing: **15-minute soak**, then client disconnection, then **60 seconds of settled cleanup** before running the commands. This fills the timed soak/cleanup memory row. Commands were `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, `serial status`, `broker clients`, then `usb status`; these are sequential user observations, not an atomic sample or an agent-executed test.
|
||||
|
||||
- HTTPS initialized/running, not transitioning, port **443**, `last-error=ESP_OK`; HTTP Basic via the user database, four users and two admins, unchanged endpoints. Web serial attached, sessions **0/2**, zero tickets. SSH initialized/running, not transitioning, port **22**, `last-error=ESP_OK`, sessions **0/2**. SSH role-based password/public-key authentication and shell/PTY-only admission remain reported, with exec/subsystem/forwarding/SCP/SFTP disabled. Owner task core **1**, stack **20,480 B**, minimum-free **16,288 B**.
|
||||
- Broker reports **no clients connected**. USB CDC initialized/attached but host-open **no**, DTR/RTS **no**, broker disconnected. Last host line coding **9600 baud, 8N1** is reported only; UART1 remains independently configured at **460800 baud**. Physical USB attachment therefore remains, without an active broker client.
|
||||
- SSH lifecycle starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, handshake failures/timeouts/auth failures/request rejects **0**. Broker connects/disconnects **1** each, writer requests **1**, grants **0**, denials **1**, failures/revocations **0**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted **5 B**, rejected **0**, TX **84,546 B**, **I/O failures 1**, **session revocations 1**. These two nonzero counters are retained without attributing them to normal disconnect or a defect; the transcript does not establish their cause.
|
||||
- Web lifecycle starts **1**, start-failures/stops **0**. Requests total/authenticated **527**, root **2**, status **521**, tickets **3**, assets **1**, auth-failures/response-errors **0**. Tickets issued/consumed **3**, rejected/expired **0**. WebSocket connects/disconnects **3** each, connect/service-start/broker failures **0**.
|
||||
- WebSocket RX **25** accepted frames / **25 B**, rejected frames/bytes **0**. TX **1,490** binary frames / **177,163 B**, **11** control frames / **918 B**. Writer requests **3**, grants **2**, denials **1**, releases/revocations **0**; send/queue/protocol failures and closes **0**.
|
||||
- mDNS initialized/announced, hostname `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`. UART service **running**, RS-232 owner **serial service**; configuration v1 **460800 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**; RX available/TX pending **0**. Modem asserted **DCD=0, DSR=1, CTS=1, RI=0**, valid-voltage **VLD=1**.
|
||||
- Relative to the recorded full-client-mix snapshot, free internal/DMA heap is **22,984 B higher** and free PSRAM **49,768 B higher**; largest blocks return to **31,744 B internal/DMA** and **8,126,464 B PSRAM**. Lifetime minima are unchanged. These endpoint observations show headroom recovery, not a controlled per-client allocation measurement or proof of no leak. Relative to the fresh-boot browser-disconnected/UART-running sample, free internal/DMA heap is **300 B lower** and PSRAM **216 B lower**, with equal largest blocks; differing HTTP activity and uncontrolled allocation state prevent treating those differences as a diagnosed leak.
|
||||
- All serial transport clients are disconnected, but cumulative authenticated status requests are present; this is not evidence of no HTTPS polling or retained TLS connections. Exact flashed hash, browser/version/origin, fixture and traffic pattern/verified byte counts remain unspecified. The 15-minute soak and 60-second cleanup durations are user-confirmed; the reported UART configuration is **460800 baud**, not the checklist's separate **115200-baud** soak workload. Full soak/stress acceptance, SSH counter diagnosis, reserve floors and 8D.1 validation remain pending.
|
||||
|
||||
Resource gates:
|
||||
|
||||
| Gate | Budget / status |
|
||||
|---|---|
|
||||
| 8D.0 firmware allocation/route/task/socket change | **0**; documentation only |
|
||||
| Baseline internal free/minimum/largest-block reserve floors | **Pending reserve analysis and remaining workload measurements**; the recorded sample is not a safety floor |
|
||||
| PSRAM reserve and largest-block floor | **Pending**, including TLS/SSH connection and handshake peaks |
|
||||
| Stack reserve floors | **Pending** reserve analysis; SSH measurements recorded, other owner-task telemetry missing |
|
||||
| 8D.1 incremental budget | **Actual static delta +672 B**, including 664 B store/lock symbols; no module heap allocation, task, route or socket increase. Basic cache retained. Runtime reserve acceptance still pending; see implementation record below. |
|
||||
| 8D.2 incremental budget | **Pending** exact generation/session bindings added to four tickets/two slots and cleanup integration |
|
||||
| 8D.3 incremental and cumulative M1 budget | **Pending** four challenge records, throttle, five handlers, parser/CSRF scratch, minus removed Basic cache/key; no new task/socket allowance |
|
||||
| M2 admin allocation/socket budget | **Pending** retained-serial admission design and target M1 measurements; not approved here |
|
||||
|
||||
To close these gates, record each chunk's actual linked/static and allocated structure sizes, internal-versus-PSRAM placement and worst-case fallback, then measure peak incremental handshakes/traffic and post-cleanup fragmentation under the same workloads. Set numeric reserve floors sufficient for measured recovery/USB/SSH needs and the largest required allocation, with an explicitly justified margin; subtract these floors before approving incremental/cumulative budgets. A percentage of total RAM or a source payload subtotal is not a reserve. Stop on monotonic leak/declining largest block, watchdog/stack fault, lost lease or inaccessible login.
|
||||
|
||||
Target operator checklist (user-provided samples cover status/memory collection and concurrent admission of two web serial clients, USB and user/admin SSH, with traffic counters and broker/USB snapshots confirming four broker clients and USB host DTR. Remaining checks are unverified, and none were executed by the agent):
|
||||
|
||||
1. Identify flashed revision/configuration against this record; record browser/version, IP/mDNS origin, Wi-Fi mode, serial framing/flow/baud and attached peer/fixture. Use existing provisioned accounts; do not expose credentials in evidence. Firmware upload is a separate user-controlled action; no erase is required for this documentation task.
|
||||
2. On UART0 run `memory`, `serial status`, `broker status`, `broker clients`, `usb status`, `web status`, `ssh status` at settled boot and each table point. Record output without secrets. Use `pio device monitor -b 115200` once a port is available; this is interactive, not a completed automated test.
|
||||
3. In separate browser profiles authenticate an existing admin and user through **current Basic auth**; verify protected assets/status and bidirectional serial bytes, single writer/request/release, and observer isolation. Test direct-IP and mDNS separately where STA mDNS is available. If baseline login fails, stop and diagnose in a separate task, not during auth replacement.
|
||||
4. Verify native USB UART1 with host DTR, user SSH serial and admin SSH console, while UART0 remains usable. Open two browser serial sockets plus USB, one user SSH and one admin SSH; collect the full-workload row. This is four broker clients and two SSH sessions; admin SSH is not a broker client. Also check the alternative two-user-SSH mix (five broker clients) without increasing any limit.
|
||||
5. Run **five** explicit browser serial Disconnect/Connect cycles per role, observing paused reconnect, old broker cleanup, writer/observer correctness and memory after each cycle. Baseline has no logout: do not report Basic-cache reset as a logout test. M1 will repeat five login/serial/logout cycles per role instead.
|
||||
6. Run a **15-minute** concurrent soak at **115200 baud, 8N1, no flow control**, using a safe known peer/fixture and a recorded binary pattern/byte count. Transfer writer ownership explicitly among transports, collect memory/status every minute, and record bytes/drops, latency and any watchdog/reset. Repeat a separately recorded **five-minute 460800-baud** stress run with the same mix; historical drops at that load are not permission for new exhaustion or lease loss. These workloads do not reduce supported baud rates. Record any fixture limitation rather than inventing results.
|
||||
7. Disconnect network clients and USB DTR, wait 60 seconds and capture cleanup memory/status. Compare free/largest-block recovery and lifetime minima with boot/full-load rows. Record SSH stack margin; explicitly retain other stack floors as pending until instrumentation is separately scoped.
|
||||
8. Fill measured reserve/incremental budget cells with justified numbers and review remaining failures. The user explicitly requested 8D.1 with this gate pending; that is not approval to skip later regression checks or M1/M2 milestone pauses.
|
||||
|
||||
## 8D.1 implementation and validation record
|
||||
|
||||
- **Checkpoint:** Work started from clean `devel` at `02fdeee3453654680c11096c9a6224c26233eced` (documentation-only successor to the build baseline). The user requested 8D.1 after the initial runtime sample and provided the full-client-mix sample while work began. Remaining reserve floors are explicitly pending; this is not an automatic budget approval for subsequent chunks.
|
||||
- **Implemented:** `src/web_session_store.{h,c}` provides four fixed internal records; 32-byte token digests, 32-byte origin digests, separate 32-byte CSRF state, copied principals, one-hour absolute deadlines and non-reused 64-bit IDs. Issue/lookup/currentness/prune/session and user invalidation APIs exist, plus secret-free counts/storage sizes. No raw bearer token is retained. Failed output and retired record cleanup is explicit. Stop wipes records, cancels in-flight issuance/init and never resets IDs/epochs. No task, module heap allocation or internal-fallback allocation is introduced.
|
||||
- **Concurrency:** A short portMUX protects only bounded state operations; RNG/SHA/database work is outside it. Resolution rechecks ID/liveness/deadline after principal validation. Issuance rechecks an invalidation epoch before publication; any explicit invalidation conservatively cancels concurrent issuance, including issuance for another account, without removing unrelated live sessions. Epoch/ID exhaustion is fail-closed. Snapshot counts are cumulative for the boot; views containing CSRF are sensitive request-local results, never status snapshots. HTTP Origin validation remains the future caller's responsibility; the primitive only binds a bounded canonical origin digest.
|
||||
- **Lifecycle integration:** Only an admitted HTTPS start initializes the store; failed starts and accepted stops disable it before cleanup, including failed teardown. Store-init failure is warning-only for the unchanged Basic-auth service. No production caller issues/looks up sessions yet. No login page, cookie route, ticket binding, account-mutation notification hook, new URI, protocol change or generated asset change. Those remain 8D.2/8D.3 scope.
|
||||
- **Build:** Final `pio run` passed in 8.90 seconds after review fixes, with the same PlatformIO/IDF configuration: **95,204 B linked RAM (+672 B)**, **1,600,505 B flash (+532 B)** relative to 8D.0. Target object symbol inspection reports **656 B state + 8 B portMUX = 664 B**; DWARF confirms **152 B per record × 4 = 608 B**, principal 40 B. The aggregate link delta includes eight additional bytes beyond those store symbols. All storage coexists with the existing Basic cache. No post-change runtime heap claim is made.
|
||||
- **Dormant-code accounting:** All functions compile and are exercised by the host harness, but the firmware link currently retains only init/stop and their dependencies; unused issue/lookup/etc. code is garbage-collected. The static state is retained in full. Later production callers must account for the newly linked code and stack/crypto execution costs rather than treating this flash delta as the full M1 cost.
|
||||
- **Focused executable checks:** `python3 tests/web_session_store/run.py` passed using OpenSSL SHA-256 and deterministic RNG/database/time/FreeRTOS doubles. Coverage includes failed init/retry/idempotence, capacity/no eviction, token/origin mismatch, exact expiry/no renewal, per-session/account isolation, stale principals and DB failure, stop/reinit/stale IDs, invalidation during pruning and candidate issuance, replacement during lookup, expiry during revalidation, stop during init, and post-token SHA failure output wiping. RNG/SHA/database doubles assert that no store lock is held. This narrow harness is not a general firmware test suite.
|
||||
- **Review-only limits:** ID/epoch exhaustion, collision rejection and private record wiping were inspected, not forced by public-API tests. Callback races are deterministic interleavings, not real multicore scheduling. Host tests do not test device DRBG, mbedTLS integration, HTTPD lifecycle races, heap/stack margins or future dormant browser paths. Independent source review found and verified the fix for reinitialization during a rejected concurrent start.
|
||||
- **Post-change target gate: validated by user sign-off on 2026-09-05.** In addition to the boot and full-client-mix samples below, the user reports a long-lasting command producing output at full **115200-baud line speed with no dropped broker packets**, and explicitly requests 8D.1 validation closure. Exact command, duration and byte/drop-counter totals were not supplied; this is user-reported hardware evidence, not an agent-executed test. Unrecorded detailed lifecycle/HTTPS restart/cleanup checks remain regression coverage, not blockers to this sign-off or claims of execution. Numeric reserve approval and target testing of future cookie-authentication paths are not implied. Stop before 8D.2 until requested.
|
||||
|
||||
### 8D.1 target samples: boot and full client mix
|
||||
|
||||
Supplied by the user on 2026-09-05 as **Phase 8D.1 validation**. Each transcript runs `memory`, `ssh sessions`, `ssh counters`, `web counters`, `web status`, `mdns status`, `ssh status`, `serial status`, `broker clients`, then `usb status`. These are sequential user observations, not atomic samples or agent-executed tests. Exact flashed revision/hash, boot settling interval, loaded duration, browser/version/origin and serial fixture were not supplied for this phase; the 8D.0 source sign-off does not identify this changed firmware.
|
||||
|
||||
| 8D.1 workload | Internal free/min/largest | DMA free/min/largest | PSRAM free/min/largest | SSH stack minimum-free |
|
||||
|---|---|---|---|---|
|
||||
| Fresh boot, UART stopped, no broker clients | 69,564 / 67,312 / 31,744 B | 61,808 / 59,556 / 31,744 B | 8,223,100 / 8,216,900 / 8,126,464 B | 18,464 B |
|
||||
| SSH writer + admin SSH + USB observer + two web observers; UART running at 115200 baud | 41,420 / 19,384 / 31,744 B | 33,664 / 11,628 / 31,744 B | 8,162,824 / 8,115,028 / 8,126,464 B | 16,288 B |
|
||||
|
||||
- **Both samples:** HTTPS and SSH initialized/running, not transitioning, ports **443/22**, `last-error=ESP_OK`. HTTPS retains HTTP Basic via the user database, four users/two admins and unchanged endpoints. SSH retains role-based password/public-key authentication and shell/PTY-only admission, with exec/subsystem/forwarding/SCP/SFTP disabled. SSH owner core **1**, configured stack **20,480 B**. mDNS initialized/announced as `sak-1024.local`, suffix `1024`, `last-error=ESP_OK`.
|
||||
- **Boot state:** SSH **0/2**, WebSocket **0/2**, web serial attached, zero tickets, no broker clients. UART stopped, RS-232 owner idle; configuration v1 **115200 baud, 8N1, no flow control**, DTR inactive, RTS threshold **96**, RX available/TX pending **0**. Phase 0 commands reported available, not executed. USB initialized/attached, host-open/DTR/RTS **no**, broker disconnected; reported host line coding **9600 baud, 8N1**.
|
||||
- **Boot counters:** SSH starts **1**, every other supplied SSH counter **0**. Web starts **1**, start-failures/stops **0**; requests total/authenticated/status **9**, root/tickets/assets/auth-failures/response-errors **0**. All ticket, WebSocket session/RX/TX/control/failure counters **0**. Thus the boot sample includes authenticated status activity, not a no-HTTP-traffic idle state.
|
||||
- **Loaded clients:** SSH **2/2**, public-key user session **5**, slot **0**, broker **8 writer**; public-key admin session **6**, slot **1**, admin-console route, no broker. Both active/authenticated, admin-command idle, zero admin output, no RX/TX pending or closing state. Web **2/2**, both password-authenticated normal-user observers for the same account: slot **0**, fd **55**, generation **1**, broker **10**; slot **1**, fd **56**, generation **1**, broker **11**. Zero tickets, neither web slot TX-pending or closing.
|
||||
- **Loaded broker/USB/UART:** Exactly four broker clients: SSH **8 writer**, USB **9 observer**, web **10/11 observers**, all with zero pending bytes/events. USB initialized/attached, host-open/DTR/RTS **yes**, broker **9 observer**, reported host line coding **38400 baud, 8N1**. UART running, owner serial service, unchanged **115200-baud 8N1** configuration; RX available/TX pending **0**, modem **DCD=0, DSR=1, CTS=1, RI=0**, **VLD=1**. USB line coding does not reconfigure UART1.
|
||||
- **Loaded SSH counters:** Starts **1**, TCP connections **2**, start-failures/stops/capacity rejects **0**. Handshake successes/auth attempts **2**, failures/timeouts/auth failures/request rejects **0**. Broker connects **1**, failures/disconnects **0**, writer requests/grants **1**, denials **0**, **broker revocations 1**. Admin admissions **1**, admission failures/input backpressure **0**. Stream RX/accepted **152 B**, rejected **0**, TX **20,012 B**, I/O failures/session revocations **0**. The broker revocation is preserved as an unexplained cumulative event, distinct from session revocation; the current snapshot confirms SSH writer ownership.
|
||||
- **Loaded web counters:** Starts **1**, start-failures/stops **0**. Requests total/authenticated **119**, status **117**, tickets **2**, root/assets/auth-failures/response-errors **0**. Tickets issued/consumed **2**, rejected/expired **0**. WebSocket connects **2**, disconnects/connect failures/service-start failures/broker failures **0**. RX accepted/rejected frames/bytes **0**; TX **334** binary frames / **37,242 B**, **5** control frames / **398 B**. Writer requests **2**, grants **0**, denials **2**, releases/revocations **0**; send/queue/protocol failures and closes **0**. Denials are consistent with both browsers being observers.
|
||||
- **Interpretation:** These samples establish post-change service startup, authenticated status requests and concurrent admission/traffic with the full client mix, without reported SSH I/O or web failures. They do not establish byte integrity, lifecycle/soak completion or target exercise of dormant cookie-session APIs. Boot UART is stopped; loaded UART is **115200 baud** and SSH is writer, unlike the **460800-baud browser-writer** 8D.0 full mix. No controlled incremental heap cost or memory improvement is inferred. DMA overlaps internal heap; lifetime minima and SSH stack low-water marks are not approved reserve floors.
|
||||
@@ -1,214 +0,0 @@
|
||||
# Phase 8D — Incremental web administration plan
|
||||
|
||||
**Current implementation (2026-09-08):** User authorized **8D.12 and 8D.13 together**, backend and Network UI delivered. 8D.12 covers nonsecret STA/AP/profile/mDNS edits and persistence; 8D.13 adds explicit secret replacement/disabled-STA clear and connection controls. Profile selection means selecting a configuration to edit; connection control is canonical **Next profile**, not explicit-index selection. 27 handlers/six sockets, one bounded slot/timer, no task/stack/queue/schema growth. Backend/cookie Network PASS, UI agent97+renderer/CSP/review PASS, lifecycle agent21 PASS; backend P3 queue-drop-counter finding fixed. **Parent integrated suites and build PASS:** 24.99 s, 99,548 B RAM / 1,742,437 B flash (+288/+36,656 vs legacy-cleanup baseline). Parent UI97/CSP, lifecycle21, Network/HTTP policy, canonical console/accounts, transport/tickets, idle/store/diagnostics checks passed; exact attribution below. **Target behavior, timer heap/memory floors and HTTPD/dispatcher stack margins remain pending.** [8D.12/8D.13 implementation](phase8d12_13_implementation.md) is the exact API/SSID/secret/uncertainty contract and checklist. No Wi-Fi reset/default-secret/export, browser-shell policy widening, 8D.14 work, M3 completion or target sign-off. Supersedes historical next-request restrictions below; previous scoped acceptance stands.
|
||||
|
||||
**8D.11 implementation history (2026-09-08):** User-requested **8D.11 implemented, host-tested/build-verified; target sign-off pending**. Accounts fingerprint listing and Ed25519/P256 import/delete/clear use canonical target-checked APIs and the existing dispatcher. Sparse-slot selection regression fixed; 24 handlers/six sockets, no new task/stack-size/queue expansion. Final build 96,076 B RAM / 1,703,685 B flash. [8D.11 record](phase8d11_implementation.md) contains API/bounds, test attribution and pending hardware checklist. Supersedes historical wait-for-8D.11 instructions below; 8D.8–8D.10/M2 remain accepted. No M3 completion or 8D.12 work.
|
||||
|
||||
**Latest target sign-off (2026-09-08):** User reports thorough Serial parameter display/settings and user/account testing, supplies settled boot/full-client-mix evidence, and explicitly says implemented work can be signed off. **Implemented 8D.8–8D.10 are accepted**, including 8D.9 UX and both 8D.10 slices. Supersedes target/signoff-pending statements below, not build/test evidence or restrictions. [8D.10 target acceptance record](phase8d10_implementation.md) contains all six loaded samples, client mix, counters and evidence limits. Full mix at 230400 baud confirms SSH sole writer + USB/two web observers with both admin routes. Final internal/DMA free 32,556/24,800 B, lifetime minima 19,228/11,472 B, largest 23,552 B; isolated failures retained without diagnosis. Exact revision/browser/durations/individual cases are unspecified; no leak-free-soak or reserve approval inferred. M2 stands; full M3/browser-shell parity is not claimed. **Next is 8D.11 only when separately requested; this sign-off authorizes no implementation.**
|
||||
|
||||
**Current slice 2 completion (2026-09-08):** **8D.10 implementation is complete, host-tested/build-verified, not target accepted; target validation/full signoff remain pending.** Create/password/self workflows use bounded 768-byte admission and periodic credential cleanup (30-second deadline plus one-second timer/scheduling latency); admitted executing work is not cancelled. Protected generation is separate before commit, with no retained retrieval. Self revocation may prevent results: 401/disconnect is uncertain, never grounds for automatic retry. Browser-shell restrictions remain unchanged. Review's only finding, missing generated-route registration, is fixed as an independent optional endpoint with failure isolation/restart coverage, **23 handlers/six sockets**. Parent PASS canonical accounts/boundary, parser **294**, cookie accounts **9 plus shared**, serial-settings **10**, transport **25**/tickets **12**, store/serial and diff check. UI agent **57 plus CSP** and route agent lifecycle **15** pass; these are not claims of the parent's additional UI/lifecycle reruns. Parent `pio run` **PASS 25.61 s, 95,908 B RAM / 1,694,237 B flash**, **+80/+9,880** vs slice 1 and **+200/+25,400** vs final 8D.9 UX. Timer runtime costs and heap/stack margins remain unmeasured. [Current 8D.10 record](phase8d10_implementation.md) contains contracts/evidence/target checklist. No sanitizer validation, assets/device/commit/8D.11 work, M2 reopening, prior-phase signoff or reserve approval inferred.
|
||||
|
||||
The implementation/continuation entries below are historical evidence. In particular, slice 1's exclusions, next-slice instruction, 22-handler count and build figures do not describe current slice 2.
|
||||
|
||||
**Latest implementation (2026-09-08):** User-requested **8D.10 first slice is host-tested/build-verified; target pending and phase incomplete**. Pre-edit split follows the row below: Accounts list and other-account role/delete now implemented, with conditional target identity checks, existing dispatcher/target notifications and bounded automatic UI completion. Three optional routes, **22 handlers/six sockets**, **95,828 B RAM / 1,684,357 B flash**. [8D.10 record](phase8d10_implementation.md) covers tests/resources/limits. Next is the second 8D.10 slice (create/password/generated-secret/self workflows), not 8D.11. Supersedes historical stop-before-8D.10 instructions; no prior target signoff, M2 reopening or reserve approval inferred. Final 8D.9 UX baseline is recorded in [8D.9](phase8d9_implementation.md).
|
||||
|
||||
**Latest continuation (2026-09-07):** Separately authorized **8D.9 is implemented / reviewed / host-tested / build-verified**, with 8D.8/8D.9 target acceptance still pending. [8D.9 record](phase8d9_implementation.md): typed Serial framing/lifecycle/persistence, one session-bound operation slot on the existing dispatcher, manual bounded result recovery, 19 handlers/six sockets; final **95,708 B RAM / 1,666,725 B flash**. No new task/queue depth/stack or broker writer semantics. This supersedes older stop-before-8D.9 instructions, not M2 signoff or deferred restrictions, admission followups or unapproved reserves. Stop before separately requested 8D.10; no target signoff inferred.
|
||||
|
||||
**Previous implementation (2026-09-07):** Separately user-authorized **8D.8 is implemented / host-tested / build-verified**, with target/browser validation and new phase signoff pending. [8D.8 record](phase8d8_implementation.md): read-only admin Settings/Serial, nonblocking typed snapshot, 17 handlers/six sockets, final 95,580 B RAM / 1,654,529 B flash; exact tests/resources/limits and target checklist recorded. This supersedes older next-8D.8/wait-for-request instructions below, not M2 signoff or evidence. Deferred restrictions, accepted unresolved admission issue and unapproved memory/stack followups remain. Stop before separately requested 8D.9; no new signoff is inferred.
|
||||
|
||||
Status: **8D.0–8D.6 and M1 validated by explicit user sign-off. 8D.7 implemented scope validated and M2 explicitly signed off by the user on 2026-09-07 ("Jupp, sign M2 off"), superseding historical M2-open, target-pending and continuation instructions below without requiring revalidation. Full browser parity is not claimed: self/generated/key/legacy-credential and other owner-specific command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Intermittent supported two serial + one admin web admission failures are accepted nonblocking, not fixed. Numeric memory reserves/stack margins remain unapproved. Next is separately requested 8D.8 read-only settings entry and Serial page; sign-off alone authorizes no implementation.** See the [8D.7/M2 sign-off and evidence](phase8d7_implementation.md), [8D.6 implementation record](phase8d6_implementation.md), [8D.5 implementation record](phase8d5_implementation.md), [8D.4 implementation record](phase8d4_implementation.md), [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md).
|
||||
|
||||
This is the execution plan for [roadmap Phase 8D](roadmap.md#phase-8--role-based-users-and-administrative-access). The roadmap retains the feature/security requirements; this document defines small work units, dependencies, and release gates. The [administration test matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration) remains the final acceptance checklist.
|
||||
|
||||
## Why this phase is split
|
||||
|
||||
The previous all-in-one attempt was rolled back from `devel`. The user reports a roughly +10k/-1k-line change, repeated agent/time/context interruptions, incomplete validation, broken browser login, and severe memory pressure. These are reported symptoms, not a diagnosed root cause. The separate experimental branch is not the implementation baseline: do not merge/cherry-pick it wholesale or copy its abstractions without a separately scoped review.
|
||||
|
||||
Deliver three independently useful milestones before attempting feature completeness:
|
||||
|
||||
1. **M1: reliable browser login/logout and the existing serial UI** — 8D.0–8D.3.
|
||||
2. **M2: browser admin shell alongside uninterrupted serial access** — 8D.4–8D.7.
|
||||
3. **M3: guided administration, one settings domain at a time** — 8D.8–8D.21.
|
||||
|
||||
8D.22 is final integration acceptance, not the first time anyone builds or tries the firmware. Stopping after M1 or M2 is valid incremental delivery, but does not mean all of Phase 8D is complete.
|
||||
|
||||
## Work-unit rules
|
||||
|
||||
- **One numbered chunk per implementation request.** Do not interpret “continue Phase 8D” as permission to implement all remaining chunks. Select the first unblocked chunk and state its scope before editing.
|
||||
- Each chunk should fit one normal agent session, including review, a bounded build, focused validation, and handoff. Plan for roughly 60–90 minutes of implementation and reserve at least the final third for validation/documentation. These are scope limits, not runtime guarantees; split further before coding if the estimate does not fit.
|
||||
- Aim for a few hundred changed authored lines in a small source set. An expected change above roughly 600–800 authored lines, more than one new task, or several independent subsystem changes triggers a scope review and another split. This is not an incentive to compress code, omit tests, or hide generated changes.
|
||||
- Declare allowed files, behavior changes, explicit exclusions, resource deltas, and acceptance checks first. Source sets below are starting points, not permission to refactor every listed module. New files must have a narrow responsibility justified by that chunk.
|
||||
- Build the smallest complete increment. Internal preparatory chunks may leave unused interfaces, but must not expose half-protected routes. Do not ship an intermediate cookie-authentication route without CSRF/origin protection, currentness checks, expiry, and logout cleanup.
|
||||
- Reuse subsystem APIs, the existing HTTPD ownership model, the canonical command dispatcher, and authored UI assets. Do not introduce a generic web framework, CLI-over-HTTP endpoint, second command registry, second dispatcher, task-per-request model, or speculative settings infrastructure.
|
||||
- Do not rename/extract the entire `admin_ssh_console` module just to give it a generic name. Adapt the smallest necessary boundary and retain existing SSH callers. Do not regenerate `src/web_assets_data.*` or update vendored xterm dependencies as incidental work.
|
||||
- Add focused counters and checks with the feature that needs them, not as a final observability project. New tests must be local/bounded and document their exact command; this repository currently has no automated host test command. Avoid scaffolding a general test platform as part of implementation.
|
||||
- Run `pio run` with a finite tool timeout. A timeout is **incomplete validation**, not a pass; record it and stop rather than starting overlapping/repeated build jobs. Fix only failures caused by the chunk.
|
||||
- End with a reviewed diff and handoff: implemented behavior, build result, memory delta, checks actually run, hardware checks pending, and exact next chunk. Keep `docs/agent/current-state.md` current. Never include credentials, cookies, CSRF values, tickets, or verifier material in evidence.
|
||||
- A chunk is **implemented / build-verified / target-verified** as separate states. If device access is unavailable, provide the small manual checklist and mark it blocked for target validation. Do not proceed past an M1/M2 gate, or stack further runtime-changing chunks on an unverified predecessor, without an explicit user decision. Never silently promote documented tests to passed tests.
|
||||
- No automatic commits, branch changes, uploads, erase, NVS migration, or imports from the abandoned branch. The user chooses commit/revert checkpoints; preserve independently reviewable diffs.
|
||||
|
||||
## Baseline and resource gates
|
||||
|
||||
8D.0 must record the actual baseline revision and configuration before resource budgets become acceptance criteria. The current memory notes report 94,532 bytes linked RAM and 1,599,765 bytes flash for an mDNS-enabled build; these are historical reference values, **not a fresh measurement or sufficient runtime headroom**.
|
||||
|
||||
For every chunk that changes allocation, concurrency, or routes:
|
||||
|
||||
- Inventory static internal RAM, normal and worst-case internal heap, PSRAM, task count/stack sizes, request/response scratch buffers, queue depth, HTTPD handlers, sockets/TLS connections, and session/ticket capacity. Record limits before increasing any of them. Cookie sessions, serial sockets, admin sockets, and outstanding tickets are different resources.
|
||||
- Use UART0 `memory` to record internal/PSRAM free, minimum-free, and largest-block values at the same defined workload points: settled boot, login, serial connected, admin connected when available, full supported concurrent workload, and after repeated close/logout/reconnect. Compare both each chunk and cumulative growth against the baseline. Collect relevant task stack high-water marks where available; explicitly record missing instrumentation.
|
||||
- Set numeric internal-heap/largest-block/stack reserve floors and per-chunk incremental budgets from baseline measurements and actual allocation sizes in 8D.0. No invented “safe free heap” constant and no percentage-of-total-RAM substitute. If the floor cannot be measured, mark the gate pending rather than guessing.
|
||||
- Account for **two simultaneous browser WebSockets** when serial and admin coexist, plus HTTPS requests and existing SSH connections. Capacity rejection must be explicit and must not silently evict an active serial client/writer to admit an admin socket. Do not merely raise HTTPD/lwIP limits until a page happens to work.
|
||||
- Prefer bounded PSRAM payload storage only where cache-disable/lifetime constraints allow it; retain required internal control structures/stacks. Record fallback behavior: opportunistic internal fallback must not consume the recovery reserve unnoticed. Optional web-admin allocation failure must leave current serial, UART0, USB, and SSH paths usable.
|
||||
- Do not accept a monotonic heap leak, declining largest-block trend, watchdog/stack fault, unexplained reserve-floor violation, or unrecoverable login failure. Stop and fix/split the current chunk instead of borrowing from future budgets. Supported baud rates and client capacities must not be silently reduced to pass.
|
||||
|
||||
### Always-on regression smoke check
|
||||
|
||||
After each runtime-changing chunk: build; boot and read UART0 status/`memory`; log in through the currently supported browser authentication path; open serial, explicitly disconnect and reconnect; exercise native USB UART1 access and existing user/admin SSH routes. Add the chunk-specific checks below. Use a fixed small repetition count selected in 8D.0 for routine lifecycle checks and a longer bounded soak at milestone gates; record actual counts and durations, not just “stable.”
|
||||
|
||||
## M1 — Authentication without changing the rest of the UI
|
||||
|
||||
### 8D.0 — Baseline, browser contract, and resource budget
|
||||
|
||||
**Scope:** Documentation and measurement only. Read the relevant `web_server`, `web_serial_transport`, `web_ui`, user-principal, and memory-reporting paths. Confirm current login/serial operation on rolled-back `devel`; no investigation of the experimental branch is required.
|
||||
|
||||
**Deliver:** A short baseline record, measured resource table, supported concurrency/socket budget, and exact M1 browser contract: public login assets; protected routes; session/ticket capacities and lifetimes; absolute/idle expiry rules; capacity rejection; cookie renewal; pre-login CSRF bootstrap; strict origin policy; no-store responses; logout-versus-account-wide revocation; and browser error handling. Preserve mDNS and direct-IP access as separate host-only cookie origins. Choose the simplest bounded policy, with no Basic compatibility path by default. Explicitly list the few authentication request/response fields rather than designing all future settings APIs.
|
||||
|
||||
**Gate:** Existing admin and user browser login, serial data, USB, and SSH work; baseline `pio run` and target memory evidence are recorded. If the rolled-back baseline already fails login, diagnose that in a separate task before changing authentication. If target evidence is unavailable, the budget and runtime gate remain pending.
|
||||
|
||||
**Record:** [8D.0 baseline, browser contract, resource inventory and target checklist](phase8d_baseline.md). **Validated by user sign-off on 2026-09-05**, with tested source identified as `d8999cd4a96e477fabd392ced02d810c3cd22d0f`. Historical baseline build: 94,532 B linked RAM / 1,599,973 B flash. User-provided boot, browser, lifecycle, mixed-client and 15-minute soak/60-second cleanup samples include heap and SSH stack measurements. The user attributes SSH I/O errors to out-of-spec 460400-baud testing; see sign-off for distinction from reported UART configuration. Numeric reserve floors and incremental budgets remain open, without blocking user-approved 8D.0 closure. Do not treat the documented contract as implemented behavior.
|
||||
|
||||
### 8D.1 — Bounded server-side session primitives, not yet browser-facing
|
||||
|
||||
**Start in:** `src/web_server.{c,h}`, `src/user_database.h`, `src/secure_random.h`; add a narrowly owned session module only if needed.
|
||||
|
||||
**Scope:** Fixed-capacity session issue/lookup/expiry/invalidation with digest-only token storage, copied principal and authentication-generation binding, CSRF state, and secret-free capacity/expiry/invalidation counters. Decide ownership/locking explicitly because future console revocation is not necessarily on the HTTPD task. Wipe transient secrets and use existing secure randomness. No login page, HTTP auth cutover, admin route, new permanent task, or settings work.
|
||||
|
||||
**Gate:** Focused local/component checks for lifecycle, capacity, slot reuse, stale principals, and failed initialization; bounded storage accounting and build. Existing Basic-auth behavior remains unchanged. If no executable harness is practical, distinguish code review from target execution and carry the missing checks into 8D.3; do not claim unused code was exercised by a boot test.
|
||||
|
||||
**Record:** Implemented `web_session_store` plus admitted-start/stop lifecycle hooks; no route uses it yet. `python3 tests/web_session_store/run.py` and `pio run` pass. Linked RAM **95,204 B (+672 B)**, flash **1,600,505 B (+532 B)** versus 8D.0; static store/lock symbols total 664 B, no module heap allocation/new task/routes/sockets. Only init/stop are currently retained in the firmware link; host tests exercise the full production module. See the [8D.1 implementation record](phase8d_baseline.md#8d1-implementation-and-validation-record) for exact accounting, review limits and target evidence. **Target validated by user sign-off on 2026-09-05**, following boot/full-client-mix samples and a reported long-lasting command at full 115200-baud line speed with no dropped broker packets. Numeric reserve gates remain open. Stop before 8D.2 until requested.
|
||||
|
||||
### 8D.2 — Bind existing serial tickets and sockets to a web-session identity
|
||||
|
||||
**Start in:** `src/web_serial_transport.{c,h}`, `src/web_server.{c,h}`, the session module from 8D.1, and the existing revocation call sites in `src/user_console.c`.
|
||||
|
||||
**Scope:** Add generation-safe originating web-session identity to serial tickets/slots, specific-session cleanup, and account-wide invalidation hooks. Keep web-session identity distinct from account authentication generation and transport slot generation. Preserve authoritative currentness checks when best-effort notification fails. Keep the current Basic path working until the atomic cutover; no public cookie-auth route yet.
|
||||
|
||||
**Gate:** Build and existing serial regression. Exercise session-specific versus account-wide cleanup through focused checks where available: one session's logout must not disconnect another session for the same unchanged account; account mutation must invalidate all affected account sessions/tickets. Stale cleanup cannot close a reused slot. Record any dormant paths that require the next chunk's browser validation.
|
||||
|
||||
**Record:** [8D.2 implementation, accounting and target checklist](phase8d2_implementation.md). Distinct session IDs now bind tickets/slots; internal cleanup and account-notification hooks are present, while Basic remains the only public authentication path. Both focused host modes and `pio run` pass: **95,260 B linked RAM (+56 B)**, **1,601,925 B flash (+1,420 B)** versus 8D.1. No capacity/task/route/asset changes. **Target validated by user sign-off on 2026-09-05**, following post-flash boot and full-client-mix samples. Sanitizer execution is blocked by missing host runtime libraries; numeric reserve gates remain pending. Stop before 8D.3 until separately requested.
|
||||
|
||||
### 8D.3 — Atomic login/logout cutover with the unchanged serial application
|
||||
|
||||
**Start in:** `src/web_server.{c,h}`, `src/web_ui.{c,h}`, session primitives, and the session-bound serial integration.
|
||||
|
||||
**Scope:** Minimal same-origin login page, session status, explicit logout, bounded login throttling, and cookie-based authorization of the existing app/status/ticket routes. Use a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain`, and explicit lifetime. Enforce the agreed pre-login CSRF protection plus strict origin checks on login, and session CSRF plus strict origin checks on every authenticated mutation including logout and ticket issuance. Keep passwords/tokens transient, preserve security headers/CSP, update the loader hash atomically if it changes, and handle JSON/text safely. Remove browser Basic challenges/cache authorization so stale credentials cannot bypass logout. No admin shell, settings, visual redesign, or new serial protocol.
|
||||
|
||||
**Gate — M1 (mandatory target/browser pause):** Both roles log in; incorrect credentials give a usable error; logout and account switching need no browser credential-cache reset. Refresh/reboot/expiry return to a usable login; no redirect loop or missing login asset. Test fresh and previously Basic-authenticated browser profiles, direct IP and mDNS where available, bounded capacity/backoff, CSRF/origin rejection, current-session logout cleanup, password/role/key changes and deletion/recreation via UART0, and unrelated-session/account isolation. Existing serial data, writer ownership, and explicit reconnect still work. Record memory before/during/after repeated login/serial/logout and simultaneous SSH/USB operation. Stop here for user confirmation before M2.
|
||||
|
||||
If 8D.3 exceeds the work-unit limit, first split out inert login-page rendering or private request-parsing helpers. Do not split the live security cutover into an insecure intermediate deployment.
|
||||
|
||||
**M1 sign-off (2026-09-06):** User explicitly completed M1 after successful both-role login, full-client-mix operation and post-soak telemetry. See [sign-off/evidence](phase8d3_implementation.md#m1-validation-sign-off-and-post-soak-evidence-2026-09-06). Minima/largest blocks are unchanged from the earlier loaded sample, with no reported transport failures. Clients remain connected; exact soak duration/revision and disconnected cleanup are not claimed. Numeric reserves stay open; unrecorded detailed checks are evidence limitations, not blockers to the user-approved milestone closure. **Wait for a separate 8D.4 request.** Older checkpoint notes below are historical.
|
||||
|
||||
**Live cutover checkpoint (2026-09-05):** [Implementation, HTTPD boundary, resource accounting and M1 handoff](phase8d3_implementation.md). Cookie login/logout replaces Basic for app/status/ticket routes; explicit pre-101 admission and strict header/Origin/CSRF policy use an isolated version-checked private IDF adapter, **not an SDK patch**. Resumed another agent's implementation and fixed pending-buffer wiping to preserve right-aligned unread data. All five focused suites and `pio run` pass. Final **95,508 B RAM / 1,625,689 B flash**, +248/+23,764 B versus 8D.2. No hardware/browser execution or reserve-floor approval. **Stop for mandatory M1 acceptance before 8D.4.** The following preparatory records are historical, superseded for current implementation status.
|
||||
|
||||
**Preparatory split (2026-09-05):** Scope review selected private request parsing first; the complete challenge/throttle/route/browser/test change exceeds the authored-line work-unit target. Added allocation-free `src/web_auth_parse.{c,h}` with no live HTTP callers: bounded canonical same-origin comparison, unique cookie extraction and strict UTF-8 login JSON decoding. `python3 tests/web_auth_parse/run.py` passes **268 cases** against production C; both existing session-store host modes pass. Final `pio run` passes in **7.50 seconds** and reports **95,260 B RAM / 1,601,925 B flash**, unchanged from 8D.2 because helpers are not live linked paths. No route/task/socket/stack-size/asset changes. No target/browser validation, runtime reserve approval or M1 completion is implied. Continue within **8D.3**, with the full atomic cutover still pending; see `docs/agent/current-state.md` for exact integration obligations.
|
||||
|
||||
**Login-renderer preparatory split (2026-09-05):** Added `src/web_login_ui.{c,h}` with no live caller/route, leaving Basic and existing serial UI unchanged. Standalone 7,387-byte HTML plus terminator has no protected asset dependencies, five security headers including no-store and exact script-hash CSP. Explicit-only challenge/login flow, bounded request/response handling, safe errors/manual backoff, disabled pending inputs, best-effort password/reference cleanup and generation-safe page-exit cancellation. Review fixes abort every attempt on exit (including unread error bodies) and clear re-entered passwords. `python3 tests/web_login_ui/run.py` passes production C rendering/failure checks, exact CSP hash and eight Node VM groups; parser and both session test modes also pass. Final `pio run` passes in **8.25 seconds**, unchanged **95,260 B RAM / 1,601,925 B flash**: unused renderer costs are not live-linked/runtime costs yet. No new task/socket/route/stack-size/module heap or generated-asset change. No real-browser/HTTPD/hardware validation or M1 completion. **Next is the atomic live 8D.3 cutover using both prepared pieces**, followed by the mandatory M1 target gate; no additional login-rendering split is needed.
|
||||
|
||||
## M2 — Reuse the admin shell, then expose it
|
||||
|
||||
### 8D.4 — Small transport-neutral console boundary
|
||||
|
||||
**Implementation checkpoint (2026-09-06):** [8D.4 implementation, resource accounting and target sign-off](phase8d4_implementation.md). Implemented / host-tested / build-verified / validated by explicit user sign-off after boot/full-client-mix evidence and successful empty Enter and soak testing. Owner callbacks and transport-qualified identity retain the existing two shared slots and SSH API, with no browser routes. Build **95,084 B RAM / 1,627,173 B flash**, **-424 / +1,448 B** versus recorded latest 8D.3. Numeric reserves remain open; unrecorded detailed checks do not reopen this user-approved closure. Wait for a separate 8D.5 request.
|
||||
|
||||
**Start in:** `src/admin_ssh_console.{c,h}`, `src/console_input.{c,h}`, and only the necessary `src/ssh_transport.c` callers.
|
||||
|
||||
**Scope:** Introduce the minimal transport identity/output/lifecycle boundary needed by a future web frontend. Retain the single dispatcher, fixed queue, line editing/history/completion/prompts, currentness checks, generation tokens, and existing SSH API compatibility where practical. Do not duplicate per-SSH buffers for hypothetical web capacity or rename the whole module. No web endpoint or second dispatcher/task.
|
||||
|
||||
**Gate:** UART0/admin-SSH serialization, hidden prompts, completion/history, deferred actions, disconnect/revocation with queued work, and slow-output behavior regressions pass. Memory delta is explained before adding browser slots. This refactor must stand alone and leave behavior unchanged.
|
||||
|
||||
### 8D.5 — Bounded admin WebSocket backend, no normal UI entry yet
|
||||
|
||||
**Target sign-off (2026-09-06):** User explicitly validates 8D.5 after settled cold-boot telemetry and successful 15-minute full-client-mix active-use soak at **230400 baud**, reporting a few broker drops under extremely fast/dmesg output. [Evidence and limitations](phase8d5_implementation.md#target-sign-off-2026-09-06). This supersedes the older pending acceptance/checkpoint notes below. No zero-drop claim or loaded/cleanup telemetry is inferred. Numeric reserves remain open; missing detailed results do not reopen signed-off 8D.5. Wait for a separate 8D.6 request; M2 is not yet complete.
|
||||
|
||||
**Combined backend checkpoint (2026-09-06):** User authorized finishing all of 8D.5, superseding the prerequisite-only pause below. Backend, shared-console allocation, protected admission, revocation, fail-before-side-effect restrictions and local/manual test tooling are **implemented / host-tested / build-verified**. All relevant host suites pass, including real cookie/store/ticket/transport endpoint integration. Parent reports the sequential final `pio run` after the HTTPD-owned shutdown/reuse fix passed at **95,580 B RAM / 1,637,273 B flash**, **23.55 s**. Deltas: **+416/+9,224 B** versus prerequisite, **+496/+10,100 B** versus 8D.4, **+1,048/+37,300 B** versus 8D.0. Final independent security integration review reported no actionable findings. **Target runtime/socket measurements, numeric reserves and acceptance remain pending.** No device operation or 8D.6/UI/M2 completion. Details and historical build evidence: [implementation record](phase8d5_implementation.md). 8D.6 onward remain separately requested work.
|
||||
|
||||
**Preparatory checkpoint (2026-09-06):** [8D.5 prerequisite, validation and handoff](phase8d5_implementation.md). Resumed existing uncommitted console-owner currentness/prompt cleanup work; reviewed and extended production-publication tests. Both console suites and `pio run` pass: **95,164 B RAM / 1,628,049 B flash**, **+80 / +876 B** versus recorded 8D.4. No routes/tasks/sockets/UI added. Work-unit review keeps the live backend in the next increment within 8D.5; backend/M2 remain incomplete. Target regression or explicit user decision is needed before stacking runtime changes; numeric reserves remain open.
|
||||
|
||||
**Start in:** The console boundary from 8D.4, `src/web_server.{c,h}`, and a small web-admin transport adapter as justified. Reuse existing HTTPD scheduling patterns without mixing admin data into the serial transport.
|
||||
|
||||
**Scope:** Admin-only, short-lived single-use tickets bound to both current web session and principal; bounded console admission/input/output; session expiry/logout/revocation cleanup. HTTPD owns socket work and the dispatcher owns command execution. No broker client for this route. An absent UI is not authorization: every ticket, upgrade, and sensitive operation is checked on the server. For self-affecting web actions not safely supported yet, explicitly reject before side effects and list the temporary restrictions for 8D.7.
|
||||
|
||||
**Gate:** Focused authenticated test-client or temporary local development-page checks (not a shipped debug endpoint): user-role rejection, admin command/output, prompt/backpressure, stale ticket/slot rejection, cleanup, and concurrent UART0/admin SSH. Admission failure does not remove the serial writer. Build and measure actual socket/console-slot cost. No generic HTTP command runner.
|
||||
|
||||
### 8D.6 — Browser terminal selector and serial-lease preservation
|
||||
|
||||
**Target sign-off (2026-09-06):** User confirms the remaining validation checks after 60-second boot/full-client-mix/partial-cleanup telemetry, and closes 8D.6. [Evidence and handoff](phase8d6_implementation.md#target-sign-off-and-evidence-2026-09-06). Full mix at **230400 baud** includes browser/admin SSH and four serial broker clients with one web writer. Internal/DMA lifetime minima **6,516 / 1,580 B** remain a numeric-reserve follow-up, not a claim of exhaustion or grounds to reopen sign-off. Exact flashed revision/browser/repetition counts/soak duration were not separately supplied. Latest cosmetic toolbar build passes in **7.60 s**, unchanged **95,580 B RAM / 1,646,489 B flash**, with 17 UI groups/CSP checks passing. Supersedes older pending notes below. Wait for separately requested **8D.7**; M2 remains open.
|
||||
|
||||
**Implementation checkpoint (2026-09-06, updated after review fixes):** [8D.6 implementation, accounting and target checklist](phase8d6_implementation.md). Implemented / host-tested / build-verified; target validation and parent re-review of fixes pending, no phase sign-off or M2 completion. Both P2 findings fixed: session identity changes require a clean document before adopting a new view, and fit caches only successful measurements with three bounded readiness retries. Sixteen production-rendered UI groups and focused UI/auth/store reruns pass; prior admin/console regression results remain recorded. Final finite `pio run`: **21.35 s, 95,580 B RAM / 1,646,489 B flash**, **0 / +9,216 B** versus 8D.5; cumulative **+1,048 / +46,516 B** versus 8D.0; review-only delta **0 / +1,376 B**. No backend/capacity/8D.7 restriction/asset change. Numeric reserves remain open; prior sign-offs stand. Stop for validation/user decision before separately requested 8D.7. This supersedes older planned-8D.6 status text in this document.
|
||||
|
||||
**Start in:** `src/web_ui.{c,h}`, using the completed serial/admin protocols.
|
||||
|
||||
**Scope:** Add admin-only Serial/Admin selection and separate bounded terminal state; no new settings. Keep serial connected and drained/observed while hidden. Keep writer/observer identity and Request control/Release control visible in both modes. Mode changes route displayed output and keyboard input only. Closing the admin route or shell `exit` leaves serial intact; explicit serial Disconnect retains its documented cleanup/reconnect behavior. Bound scrollback and avoid leaking browser listeners/sockets across switches.
|
||||
|
||||
**Gate:** Repeated switches preserve the same broker client ID and writer ID; background serial remains observed, with any bounded overflow visible rather than silently stopping observation. Ordinary users retain serial-only navigation and server-side denial. Exercise hidden prompts, line editing/history/completion, resize, admin-route reconnect, full logout, and expired-session UI. Measure simultaneous two-WebSocket plus HTTPS request headroom; no eviction to make the selector work.
|
||||
|
||||
### 8D.7 — Web-shell lifecycle parity and M2 acceptance
|
||||
|
||||
**M2 sign-off (2026-09-07):** User explicitly says "Jupp, sign M2 off" after the implemented-scope validation below and discussion of 8D.8 next. M2 is accepted with the deferred restrictions, nonblocking unresolved admission issue and unapproved numeric reserves/stack margins recorded in the current status. No full parity, new checklist passes or revalidation requirement is implied. Next is 8D.8 read-only settings entry and Serial page only upon a separate request; no implementation is authorized by this sign-off alone. The earlier checkpoints and original scope/gate below are historical planning and evidence, not outstanding conditions for M2 closure.
|
||||
|
||||
**Historical 8D.7 target sign-off (2026-09-07), before M2 sign-off:** User explicitly requests marking 8D.7 validated after thorough testing. Certificate rotation and web start/stop were verified, with lifecycle via UART0/SSH admin/web admin and restart after browser stop via another route. Full mix without broker drops up to 230400 baud after external adapter baud correction is user-reported. Intermittent supported two serial + one admin admission failures have recently not recurred and are accepted nonblocking, not fixed. [Evidence and limits](phase8d7_implementation.md). No detailed reboot/individual mutation checklist passes are inferred. This supersedes pending status and next-slice instructions in the historical checkpoints below for all three implemented slices, including other-account operations. Remaining self/generated/key/legacy-credential and other owner parity stays deferred/restricted; numeric reserves and M2 acceptance remained open at this earlier checkpoint.
|
||||
|
||||
**Second bounded certificate slice:** [Implementation, separate slice histories and pending target checklist](phase8d7_implementation.md). Exact parsed browser `web certificate rotate --force` uses a typed request-queue union and immutable owner `dispatcher_actions` mask: bounded drain/200 ms then nonblocking handoff to the existing 12 KiB dispatcher, not 4 KiB control. Pending input gating, token/principal/session revalidation and executing-slot reservation persist through execution. Transactional certificate commit → stop → start short-circuits errors and retains ownership on failed stop; SSH/UART0 unchanged. No new tasks/depth/routes/assets/stacks; target owner-mask/local-scratch accounting and stack margins unknown, host sizeof is not proof. Parent final `pio run` PASS **26.32 s, 95,580 B RAM / 1,648,061 B flash**: **0 / +1,036 B** vs first slice, **0 / +1,572 B** vs 8D.6, **+1,048 / +48,088 B** vs 8D.0. Implementer focused suites pass (transport **25**/tickets **12**, server **11**, boundary including certificate, lifecycle/policy/cookie-admin/store-serial/diff); independent reviewer reports no actionable findings. Sanitizers unavailable (missing libasan/libubsan); no hardware validation. User explicitly authorized stacking the next bounded slice: credential/account, then other owner slices. Other mutations remain blocked; target/M2 acceptance and numeric reserves pending. This supersedes the first-slice next-step/continuation-pending statement below.
|
||||
|
||||
**First bounded increment history (2026-09-06):** [Implementation, restrictions and target checklist](phase8d7_implementation.md). Browser `reboot` and `web stop` now use existing deferred control with final WEB session/currentness checks and discard of pending input. Other identity/network/account/SSH restrictions remain explicit. Host suites/review/build pass: **95,580 B RAM / 1,647,025 B flash**, **0 / +536 B** versus 8D.6, final build **12.44 s**. No new task/route/capacity. Target regression or explicit continuation decision pending; this is not completed 8D.7/M2. Next slice remains HTTPS identity/certificate handling, not settings. Prior sign-offs stand; numeric reserves remain open.
|
||||
|
||||
**Start in:** The console deferred-control boundary, web lifecycle owner, and only the affected command handlers.
|
||||
|
||||
**Scope:** Close the explicit 8D.5 restrictions for self-terminating web-shell operations (including HTTPS stop/identity changes and reboot where supported by the canonical registry). Reuse bounded deferred acknowledgement/close semantics; never claim application-buffer draining proves browser receipt. Prevent further input during pending actions. Preserve UART0-only bootstrap/recovery and safe policy for one-time self-generated credentials. No typed danger-zone API yet. If this requires several distinct owner changes, split them before implementation and keep unimplemented actions explicitly rejected.
|
||||
|
||||
**Gate — M2 (mandatory target/browser pause):** Browser/UART0/admin-SSH commands serialize with no output or hidden-prompt crossover; queued work is discarded after logout/revocation/slot reuse. Deferred acknowledgement/drain handling is bounded and reconnect behaves as documented; this is not confirmed peer receipt or a deadline for dispatcher queue residence or underlying certificate/NVS/lifecycle execution. Run simultaneous USB, serial WebSocket, admin WebSocket, user SSH, admin SSH, UART0 and UART1 traffic; verify lease retention, recovery availability, and measured memory/stack floors. Web-admin initialization/admission failure must leave M1 serial login and existing non-web paths usable. Stop for user confirmation before settings.
|
||||
|
||||
## M3 — Typed settings, one domain per chunk
|
||||
|
||||
Every row is a **separate implementation request**, not a batch. Add only the endpoints and UI needed for that row. All typed routes require current admin authorization, bounded bodies/responses, secret-safe encoding, and the established CSRF/origin/no-store policy. Do not send constructed command strings to `esp_console_run()`.
|
||||
|
||||
Typed operations must preserve subsystem owner/lock/persistence contracts and coexist safely with console operations. The current `admin_command_gate` is a narrow user-command wrapper, not an existing global typed-operation serializer; do not assume it solves concurrency. Specify per-domain serialization and committed-mutation notification before adding writes. Reuse a small common mechanism only when a concrete second caller needs it.
|
||||
|
||||
| Chunk | Bounded deliverable and starting source set | Focused acceptance gate / exclusions |
|
||||
|---|---|---|
|
||||
| **8D.8 — Read-only settings entry and Serial page** | Minimal admin Settings navigation, common bounded error handling, and a typed serial snapshot; start in `web_server`, `web_ui`, `serial_service.h`, `serial_config.h`. | Normal users are denied by server, unknown/oversized input fails safely, serial values match UART0. No mutations, schema generator, empty placeholder pages, or all-subsystem snapshot. |
|
||||
| **8D.9 — Serial edits and persistence** | Typed framing/lifecycle and explicit apply/save/load/default/reset controls through existing serial APIs. | Invalid framing is rejected; working versus persisted state and stop/reconfigure data-discard effects are explicit. Writer ownership semantics stay unchanged; compare CLI/browser edits and reboot persistence. No quick popover yet. |
|
||||
| **8D.10 — Accounts and passwords** | User list/create/delete/role/password workflows, final-admin protection, one-time generated-password handling; start in `user_database`, existing `user_console` mutation/revocation behavior, and web handlers/UI. | No raw database export, verifier fields, or UART0 recovery endpoints. Commit/invalidation behavior matches CLI, unrelated users remain connected, own-account changes have safe reconnect/credential-delivery semantics, secret fields are cleared after use. If CRUD and password UX exceed budget, split at read-only/role/delete versus create/password before editing. |
|
||||
| **8D.11 — SSH authorized keys** | List fingerprints and add/delete/clear supported public keys through bounded user APIs. | Ed25519/P-256 import, maximum supported length, malformed input, duplicates, targeted revocation, and unchanged SSH authentication behavior. No private-key upload/export or host-identity management. |
|
||||
| **8D.12 — Network settings without secret mutation** | Secret-free STA/AP/profile and mDNS settings, non-secret edits, and explicit persistence through `wifi_manager`, `wifi_config`, `mdns_service`/`mdns_config`. | Responses never serialize saved PSKs; validate working/persisted semantics, live hostname changes, and behavior after connection loss. No new manager/task or Wi-Fi blob migration. Split mDNS into a follow-up if needed. |
|
||||
| **8D.13 — Wi-Fi secrets and connection controls** | Explicit password replacement/clear semantics, bounded transient input, profile selection/reconnect and AP policy actions using manager-owned operations. | Preserve existing secrets when fields are omitted; never prefill saved secrets; document apply/save and likely connection loss; reconnect via STA/AP and verify UART0/USB recovery. No background secret fetch or general credential export. |
|
||||
| **8D.14 — Display settings** | Typed local display configuration and explicit persistence via `local_ui_config`/public UI APIs. | Validate limits, save/reboot, absent-display behavior, and concurrent buttons/CLI edits. No I2C ownership changes or electrical diagnostics UI. |
|
||||
| **8D.15 — Bounded network diagnostics** | Secret-free network status and a narrowly bounded diagnostic workflow through existing network facilities. | Diagnostic start/result/cancel/expiry behavior and concurrent CLI use are bounded; callbacks do not format/send HTTP directly. No unbounded result/history buffer or new generic jobs framework. Split asynchronous ping from read-only status if necessary. |
|
||||
| **8D.16 — Broker client visibility and writer transfer** | Admin-only detailed client snapshot plus explicit confirmed writer assignment using existing broker APIs; smallest broker change only if authoritative generation-safe validation is missing. | Stale/disconnected/reused target fails without changing the current lease; exactly one writer; normal users cannot obtain management details or transfer. Test concurrent USB/SSH/browser requests. No transfer on page open or selection alone. |
|
||||
| **8D.17 — Serial/Wi-Fi quick popovers** | UI-only reuse of completed typed endpoints, with full-page links and shared validation; start in `web_ui`. | Hover, focus, click/tap parity, Escape/outside-click dismissal, no mutation on opening, explicit apply/save, no secret exposure. No duplicate backend or new settings scope. |
|
||||
| **8D.18 — Client/writer contextual dialogs** | Reuse 8D.16 for live client popover and confirmed Active writer dialog. | Accessible pointer/keyboard/touch paths; refresh preserves explicit selection safely; stale confirmation is rejected visibly; normal users retain only ordinary status. No new writer policy. |
|
||||
| **8D.19 — Ordinary service/session controls** | Typed service status and targeted disconnect/start/stop controls, excluding actions that cut off the invoking HTTPS session; start in existing service APIs and generation-safe snapshots. | Explicit scope/confirmation, stale target rejection, owner-safe execution and failure isolation. Stop/start and disconnect do not clear settings/identities. Split by service if more than one owner adaptation is necessary. |
|
||||
| **8D.20 — Self-affecting service actions and reboot** | Confirmed typed operations for connection-losing HTTPS/Wi-Fi/service actions and reboot, reusing lifecycle behavior established in 8D.7/8D.13. | Acknowledgement/pending-action handling is bounded, no duplicate action on UI retry, connection loss is explained, recovery and reconnect work. No new unbounded queue or certificate/key rotation yet. |
|
||||
| **8D.21 — Security/danger-zone settings** | Carefully separated HTTPS/SSH identity rotation/reset and any explicitly retained recovery-secret operation through existing security APIs. Enumerate allowed operations first; split HTTPS and SSH work into separate requests if both need owner changes. | Confirmation, secret-safe one-time responses, no routine private-key export, expected trust/fingerprint changes, bounded self-disconnect, no accidental configuration wipe. Bootstrap/unavailable-database recovery remain UART0-only. NVS encryption, secure boot, OTA, and new factory-reset semantics stay out of scope. |
|
||||
|
||||
Dependencies: 8D.8 establishes only the minimal typed-request/UI pattern. 8D.9 follows 8D.8; 8D.11 follows 8D.10; 8D.13 follows 8D.12; 8D.17 follows 8D.9/8D.13; 8D.18 follows 8D.16; 8D.20 follows 8D.7/8D.13/8D.19; 8D.21 follows 8D.20. Independent domains can be reordered by the user, but should not be implemented concurrently against shared `web_server`/`web_ui` files.
|
||||
|
||||
## 8D.22 — Final integration acceptance and documentation
|
||||
|
||||
No new feature work. Run the complete [Phase 8D acceptance matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration), including the accumulated per-chunk regression checks and a bounded concurrent-transport soak at supported workloads. Record the exact revision/configuration, browser(s), client mix, baud rate, test duration/cycle counts, linked RAM/flash, runtime free/minimum/largest internal heap and PSRAM, relevant stack margins, and queue/drop observations. Compare against 8D.0 and milestone measurements. Known high-load serial drops are not permission for new unbounded blocking, hidden lease loss, login failure, or resource exhaustion.
|
||||
|
||||
Verify optional web-session/admin/settings initialization failures preserve UART0 and native USB; admin-only failure must not disable otherwise working serial web access. Check logout/expiry/revocation across every added route, and verify no retained Basic path or direct typed endpoint bypasses policy. Review all routine snapshots/logs/DOM status for secret exposure and confirm changes did not regenerate unrelated vendor assets.
|
||||
|
||||
Update the roadmap and user/command documentation to distinguish completed features, explicit restrictions, build results, and tests actually passed. Update durable agent architecture/code-map notes only for implemented ownership/contracts. If a check fails, open a bounded repair chunk and rerun affected checks; do not append features or declare the whole phase done with unrecorded failures.
|
||||
|
||||
## Progress and next-request template
|
||||
|
||||
Progress: **8D.0–8D.6 and M1 validated by user sign-off; 8D.7 implemented scope validated and M2 explicitly signed off by the user on 2026-09-07. Deferred parity restrictions remain; numeric reserves/stack margins remain unapproved. Next is separately requested 8D.8 read-only settings entry and Serial page; no new implementation is authorized by sign-off alone.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds.
|
||||
|
||||
Suggested next request:
|
||||
|
||||
> Wait for a separate request for 8D.8 read-only settings entry and Serial page. Preserve the accepted 8D.7/M2 sign-offs without requiring revalidation, deferred browser command restrictions, permanently UART0-only bootstrap/recovery and existing resource bounds. Carry forward the accepted nonblocking, unresolved web admission issue and unapproved numeric reserves/stack margins. M2 sign-off alone does not authorize settings or another owner slice.
|
||||
|
||||
For later chunks:
|
||||
|
||||
> Work on Phase 8D.N only. First verify its prerequisites and last target-validation checkpoint. State allowed files, exclusions, resource budget, and focused checks. Split the chunk if it does not fit one session with validation time reserved. Build, report actual versus pending validation and memory deltas, update the handoff, and stop; do not continue to the next chunk.
|
||||
+19
-16
@@ -38,7 +38,7 @@ 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 (8A–8C complete; integrated web administration 8D planned)** |
|
||||
| 8 | Role-based users and administrative access | **In progress (8A–8C complete; current 8D guided workflows implemented, all 8D target validation pending)** |
|
||||
| 9 | Security and production hardening | **Planned** |
|
||||
| 10 | Authenticated, rollback-capable OTA | **Planned** |
|
||||
| 11 | BLE serial transport and provisioning evaluation | **Planned** |
|
||||
@@ -171,7 +171,7 @@ Implemented and target-hardware validated:
|
||||
|
||||
## Current and planned phases
|
||||
|
||||
Phase 8A through 8C are complete and target-hardware validated; integrated web administration in 8D remains planned. Later work remains planned or under evaluation. Optional features must not weaken the completed serial and recovery paths.
|
||||
Phase 8A through 8C are complete and target-hardware validated. The current Phase 8D browser-session, admin-shell, typed serial/user/Wi-Fi/display, broker-client, and writer-transfer implementation is present, but all Phase 8D target-hardware validation remains pending. Later work remains planned or under evaluation. Optional features must not weaken the completed serial and recovery paths.
|
||||
|
||||
### Phase 8 — Role-based users and administrative access
|
||||
|
||||
@@ -198,21 +198,24 @@ Implementation sequence:
|
||||
- `ssh sessions` and `ssh counters` identify broker versus admin-console routes, worker command state, queued admin output, admission failures, and input backpressure. `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort application-buffer draining. Admin sessions are checked for a current `admin` principal before command execution and during the active-session reconciliation.
|
||||
- Keep SFTP, SCP, `exec`, forwarding, subsystems, and unauthenticated shells disabled.
|
||||
- Target-hardware validation passed for route separation, history/Tab editing, interactive visible/hidden prompts, output/backpressure, generated and entered user/password/key management including the longest ECDSA P-256 import, ping event routing, deferred reboot/SSH lifecycle drain behavior, bootstrap/recovery rejection, targeted self/other-user revocation during queued work, UART0/SSH administration serialization, and concurrent USB/WebSocket/user-SSH/admin-SSH operation. Stress at 460800 baud with SSH and WebSocket clients caused substantial expected packet drops and slower display controls, but did not exhaust memory or require lowering the supported baud-rate range.
|
||||
4. **Phase 8D — Integrated web administration — Planned, staged delivery**
|
||||
- **Implementation checkpoint:** 8D.0–8D.6 and M1 validated by user sign-off; [8D.7 implemented scope validated and M2 explicitly signed off by the user on 2026-09-07](phase8d7_implementation.md) ("Jupp, sign M2 off"). This supersedes earlier M2-open statements without requiring revalidation. Browser selector, bounded stop/reboot, certificate rotation and other-account operations are implemented. User reports verified certificate rotation/web start-stop and full mix without broker drops up to 230400 baud after correcting external adapter baud. Intermittent supported two serial + one admin admission failures, recently not recurring, are accepted nonblocking, not fixed. Browser self/generated/key/legacy-credential and other owner command restrictions remain deferred; bootstrap/recovery remain permanently UART0-only. Full parity is not claimed; numeric memory reserves/stack margins remain unapproved. Next is separately requested 8D.8 read-only settings entry and Serial page; sign-off alone authorizes no implementation. The requirements below retain the full end-state scope, not additional conditions reopening accepted M2.
|
||||
- **Execution plan:** [Phase 8D incremental plan](phase8d_plan.md). Implement one numbered chunk per request, with a build, focused regression checks, memory accounting, and a handoff before stopping. The requirements below describe the final scope, not one implementation task.
|
||||
- **Milestones:** 8D.0–8D.3 establish a measured baseline and reliable login/logout with the existing serial UI; 8D.4–8D.7 add the shared browser admin shell and verify retained serial ownership; 8D.8–8D.21 add typed settings and contextual controls one domain at a time; 8D.22 performs final integration acceptance. Login and runtime-memory target validation gate the first two milestones; do not defer them until the entire phase is implemented. No wholesale import of the rolled-back experimental implementation.
|
||||
- Begin with integrated authentication: replace browser-facing HTTP Basic authentication with a same-origin HTTPS login page, explicit logout, and bounded opaque server-side sessions. Store only a digest of each random session token with a copied secret-free principal, expiry, CSRF state, and authentication-generation binding. Send the raw token only in a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain`, and an explicit lifetime; never retain passwords, Basic headers, raw tokens, verifiers, or SSH-key blobs in snapshots or logs.
|
||||
- Make logout invalidate the current server-side session, expire its cookie, close that session's serial and administrative WebSockets, and redirect to login. Password/role/key mutation, deletion, recreation, and explicit revocation invalidate the affected account's web sessions and tickets without disturbing unrelated accounts. Require the CSRF token plus strict same-origin checks for every state-changing request, including logout, and rate-limit login attempts with bounded secret-free accounting.
|
||||
- Add an admin-only **Serial terminal**/**Admin shell** selector. The administrative route uses a short-lived, single-use, admin-principal-bound ticket and a bounded WebSocket frontend for the same serialized command registry used by UART0 and admin SSH; it is not a generic HTTP command-execution endpoint. Normal users retain the existing serial interface and cannot mint, upgrade, or invoke administrative routes. Remote policy still rejects physical-only `user bootstrap` and `user recover --force`.
|
||||
- Switching the visible terminal between Serial and Admin changes only the displayed terminal route. It must not disconnect the browser's serial broker client, release its writer lease, or silently stop serial observation. Keep the writer/observer badge plus Request control/Release control visible in both modes so an administrator knows the retained state and cannot unintentionally lose the lease to another client. Explicit Disconnect, logout, revocation, session expiry, or an explicit release/transfer operation still performs normal broker cleanup.
|
||||
- Add an admin-only Settings area backed by typed, bounded subsystem APIs rather than generated CLI strings. Cover user/password/role/SSH-key management, serial configuration and persistence, Wi-Fi profiles/AP policy/secrets, service and session controls, display settings, network diagnostics, and carefully separated security/danger-zone operations. The admin shell provides full remote operational parity and the settings pages provide guided high-frequency workflows; unusual electrical/debug operations may remain shell-only.
|
||||
- Add contextual admin quick settings to the existing status cards. Hover, keyboard focus, or click on **Serial** opens a popover with current framing/lifecycle state, safe common edits, apply/save semantics, and a link to full Serial settings. The **Wi-Fi** card similarly exposes connection/profile controls and a link to full Network settings without revealing saved secrets by default. Touch and keyboard users must receive the same functionality as pointer hover, with Escape/outside-click dismissal and no action triggered merely by opening a popover.
|
||||
- Hover, focus, or click on **Broker clients** opens a live, secret-free client list with IDs, transport, writer/observer state, and bounded queue/drop information. Activating **Active writer** opens an admin-only transfer dialog listing current eligible clients; transfer is an explicit confirmed generation-safe administrative assignment, never a side effect of opening or hovering. Stale/disconnected targets fail visibly without changing the current lease. Normal users may retain ordinary aggregate status but receive neither client-management details nor mutation controls.
|
||||
- Preserve strict CSP, no-referrer/frame-denial policy, no-store responses for login/session/admin material, secret-safe JSON encoding, one-time generated-password display, and bounded request/response bodies. Keep UART0 recovery, native USB UART1 access, and existing SSH behavior available if web sessions or administration cannot initialize.
|
||||
- Add secret-free counters/snapshots for session capacity, expiry, login failure/backoff, logout, invalidation, CSRF/origin rejection, admin-console admission/backpressure, typed-API failure, and writer-transfer races. Compatibility HTTP Basic, if retained temporarily during migration, must be explicit, separately constrained, and unable to bypass logout or revocation.
|
||||
4. **Phase 8D — Integrated web administration — In progress**
|
||||
- **Implemented, validation pending:** replace browser-facing HTTP Basic authentication with a same-origin HTTPS login page, explicit logout, and bounded opaque server-side sessions. Store only a digest of each random session token with a copied secret-free principal, expiry, CSRF state, and authentication-generation binding. Send the raw token only in a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain`, and an explicit lifetime; never retain passwords, Basic headers, raw tokens, verifiers, or SSH-key blobs in snapshots or logs.
|
||||
- **Implemented, validation pending:** logout invalidates the current server-side session, expires its cookie, closes that session's serial and administrative WebSockets, and redirects to login. Password/role/key mutation, deletion, recreation, and explicit revocation invalidate the affected account's web sessions and tickets without disturbing unrelated accounts. Require the CSRF token plus strict same-origin checks for every state-changing request, including logout, and rate-limit login attempts with bounded secret-free accounting.
|
||||
- **Implemented, validation pending:** an admin-only **Serial terminal**/**Admin shell** selector. The administrative route uses a short-lived, single-use, admin-principal-bound ticket and a bounded WebSocket frontend for the same serialized command registry used by UART0 and admin SSH; it is not a generic HTTP command-execution endpoint. Normal users retain the existing serial interface and cannot mint, upgrade, or invoke administrative routes. Remote policy still rejects physical-only `user bootstrap` and `user recover --force`.
|
||||
- **Implemented, validation pending:** switching the visible terminal between Serial and Admin changes only the displayed terminal route. It must not disconnect the browser's serial broker client, release its writer lease, or silently stop serial observation. Keep the writer/observer badge plus Request control/Release control visible in both modes so an administrator knows the retained state and cannot unintentionally lose the lease to another client. Explicit Disconnect, logout, revocation, session expiry, or an explicit release/transfer operation still performs normal broker cleanup.
|
||||
- **Implemented, validation pending:** the admin-only Settings area uses typed, bounded APIs rather than generated CLI strings. Full serial framing/lifecycle/load/default/save controls and Wi-Fi lifecycle/profile-rotation actions remain available.
|
||||
- **Implemented, validation pending:** `/api/admin/users` provides guided account CRUD, role changes, entered or one-time generated passwords, and authorized Ed25519/P-256 key add/remove. Existing-account mutations carry both the optimistic database generation and stable user ID, so stale state and delete/recreate races fail without retargeting. `user_admin_service` serializes CLI and web mutations with `admin_command_gate`; after a committed mutation it requests best-effort web and SSH revocation without rolling back the database if notification fails.
|
||||
- **Implemented, validation pending:** `/api/admin/wifi-config` provides typed station profile, enable/disable/delete, AP policy/SSID/channel, write-only station/AP secret, and Save operations. Reads disclose only `secret_set` flags. Every edit is a full validated compare-and-swap against the expected nonzero working generation, Save persists exactly the expected generation, and generation conflicts or exhaustion fail closed.
|
||||
- **Implemented, validation pending:** `/api/admin/display` provides typed display-aging Apply/Save/Load/Defaults/Reset operations with the same validation and persistence semantics as the console. All five operations serialize with console display writers through `admin_command_gate`.
|
||||
- **Implemented, validation pending:** the common body-backed URL-form parser decodes in place and remains bounded to 512 bytes and 10 unique fields. The browser clears entered and generated secrets when Settings closes or an operation fails; stale user and Wi-Fi editors reload current state without replaying the mutation.
|
||||
- **Implemented, validation pending:** contextual admin quick settings on the existing status cards. Hover, keyboard focus, or click on **Serial** opens a popover with current framing/lifecycle state, safe common edits, apply/save semantics, and a link to full Serial settings. The **Wi-Fi** card similarly exposes connection/profile controls and a link to full Network settings without revealing saved secrets by default. Touch and keyboard users must receive the same functionality as pointer hover, with Escape/outside-click dismissal and no action triggered merely by opening a popover.
|
||||
- **Implemented, validation pending:** hover, focus, or click on **Broker clients** opens a live, secret-free client list with IDs, transport, writer/observer state, and bounded queue/drop information. Activating **Active writer** opens an admin-only transfer dialog listing current eligible clients; transfer is an explicit confirmed generation-safe administrative assignment, never a side effect of opening or hovering. Stale/disconnected targets fail visibly without changing the current lease. Normal users may retain ordinary aggregate status but receive neither client-management details nor mutation controls.
|
||||
- **Implemented, validation pending:** HTTPS lifecycle operations are fully serialized and generation-tagged. TLS certificate/material replacement requires a post-commit TLS refresh; explicit newer start/stop intent wins races. Admin-transport HTTPD API work is disabled and tracked during teardown, failed HTTPD stop retains ownership for retry, and incomplete post-stop admin finalization remains pending and is retried before a later start.
|
||||
- **Implemented, validation pending:** preserve strict CSP, no-referrer/frame-denial policy, no-store responses for login/session/admin material, secret-safe JSON encoding, one-time generated-password display, and bounded request/response bodies. Keep UART0 recovery, native USB UART1 access, and existing SSH behavior available if web sessions or administration cannot initialize.
|
||||
- **Implemented, validation pending:** secret-free counters/snapshots cover session capacity, expiry, login failure/backoff, logout, invalidation, CSRF/origin rejection, admin-console admission/backpressure, typed-API failure, and writer-transfer races.
|
||||
- **Not provided as guided forms:** broader service/session administration, network diagnostics, security and danger-zone operations, and unusual hardware/debug commands remain available through the canonical Admin shell under its existing remote policy.
|
||||
|
||||
Completion requires login/logout and account switching without browser credential-cache dependence; stale-session, CSRF, origin, capacity, expiry, and revocation tests; hidden and server-rejected normal-user administration; shared admin-console serialization and backpressure; terminal switching that demonstrably preserves the browser broker client and writer lease; accessible Serial/Wi-Fi/client/writer popovers; generation-safe explicit writer transfer; typed settings and secret-handling tests; concurrent USB/WebSocket/user-SSH/admin-SSH/browser-admin operation; and continued UART0 recovery.
|
||||
Completion requires login/logout and account switching without browser credential-cache dependence; stale-session, CSRF, origin, capacity, expiry, and revocation tests; hidden and server-rejected normal-user administration; shared admin-console serialization and backpressure; terminal switching that demonstrably preserves the browser broker client and writer lease; accessible Serial/Wi-Fi/client/writer popovers; generation-safe explicit writer transfer; guided user/key, Wi-Fi secret/CAS, display validation, bounded-parser, and HTTPS teardown/finalizer tests; concurrent USB/WebSocket/user-SSH/admin-SSH/browser-admin operation; and continued UART0 recovery. None of the current Phase 8D target-hardware checks has passed yet.
|
||||
|
||||
### Phase 9 — Security and production hardening
|
||||
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
# User administration and authentication tests
|
||||
|
||||
This document retains phase-specific regression procedures. The Phase 8A and 8B sections describe the behavior of those historical implementation baselines; they are not the current end-to-end acceptance behavior. In current Phase 8C firmware, HTTPS and SSH authenticate through the user database, role-`user` SSH sessions receive the broker-backed serial stream, and role-`admin` SSH sessions receive the administration shell. Use the Phase 8C section for current routing and shared-console validation. Never include generated or entered passwords in test logs.
|
||||
|
||||
## Current Network settings regression procedure — 8D.12/8D.13
|
||||
|
||||
Both phases were authorized and implemented together. The [implementation record](phase8d12_13_implementation.md) defines the complete API, limits, result states, exclusions and pending target checklist. Backend/cookie Network PASS, UI agent97+renderer/CSP/review PASS and lifecycle agent21 PASS are reported evidence; final parent build/tests and target sign-off remain pending. Do not treat this procedure as executed or infer M3 completion/8D.14 authorization.
|
||||
|
||||
- Verify admin-only Settings/Network and direct-route normal-user denial, current cookie/principal, body/query/framing/Origin/CSRF checks, unavailable/contended snapshots and generation races against CLI/local controls.
|
||||
- Round-trip UTF-8 and arbitrary SSID bytes through text/hex, including NUL/BOM/non-UTF-8 and 32-byte boundaries. No saved PSK or length may appear in responses, status/logs/completion/local display. Keep omits credentials; Replace never accepts blank; disabled-STA Clear (including disable+clear) works; enabled-STA/AP clear is denied even when AP policy is off. Check transient-input expiry and context/session clearing.
|
||||
- Distinguish RAM Apply, explicit Save, stored-only Wi-Fi Load and reboot persistence. Missing/invalid/failing Wi-Fi storage must not install generated defaults or new AP secrets. Exercise stale generations, queue failure/drop accounting and NVS failures without secret logging. No Wi-Fi reset/default/export action exists.
|
||||
- Prepare UART0 and USB before confirming disruptive actions. Test Start/Stop (including RAM boot policy), Reconnect/Next, AP policies and stopped no-ops. The selected profile is an edit target, not explicit connection selection; Next uses canonical priority/wrap. Cancel confirmations and exercise lost ACK/401/disconnect, manual Check Result/Refresh, another-tab result replacement and no automatic replay. `accepted` is not online; delivery before disconnection is not guaranteed. Reconnect via STA/AP; UART0 administers recovery, USB preserves independent UART1 access.
|
||||
- Test mDNS generation/Set/Save/Load/Defaults, live/offline reannouncement, next STA IP, init/live failure isolation and `applied_not_queued`. Verify actual client DNS and changed-hostname browser trust/login, not merely `announced`. Confirm no unintentional Wi-Fi secret reset.
|
||||
- With USB/two web serial/SSH serial and both admin routes, verify hidden output draining and writer/observer preservation through Settings navigation. Separate actual network-disruption losses from serial/broker regressions. Exercise optional Network route/timer failure and stop/restart without taking down unrelated routes. Browser-shell restrictions remain unchanged.
|
||||
- Capture boot/full-mix internal/DMA/PSRAM free/minimum/largest blocks and memory floors during TLS/Network operations; timer heap/slot costs, repeated-operation cleanup/soak and **HTTPD/dispatcher stack margins** remain required. Record exact revision/client mix and nonsecret counters, including broker and manager queue drops. Host tests do not establish target reserve or hard scheduling/cancellation guarantees.
|
||||
|
||||
## Current legacy-removal regression procedure
|
||||
|
||||
The legacy bootstrap/credential/reconciliation instructions in the phase baselines below are **historical only**, superseded by [legacy credential removal](legacy_credential_removal.md) and the current [command reference](command_reference.md). Basic authentication is also historical; current HTTPS uses cookie login. The current overrides apply to later Phase 8C/browser procedures too: no `user bootstrap` or `web credentials` command remains, first-admin creation uses normal UART0 `user add`, and recovery rebuilds empty. Never treat the checklist below as evidence of execution.
|
||||
|
||||
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.
|
||||
3. Load a valid existing v1 user image and verify accounts, roles, IDs, auth generations, verifiers and keys are unchanged, including a formerly migrated role-`user` account. No bootstrap status should appear and no account should be silently promoted. No web material change may synchronize a verifier.
|
||||
4. On a disposable malformed user image, confirm authentication fails closed without automatically overwriting storage. On UART0 run `user recover --force`, confirm empty storage, then `user add maint admin`. Recovery must refuse healthy databases, including healthy empty storage, and be unavailable through SSH/browser. Verify serial/Wi-Fi configuration and TLS/SSH identities remain intact. Inject read/write/commit failures where available and check failure isolation and complete committed records, not partial live mutations.
|
||||
5. Upgrade valid 1,392-byte v1 `web_sec/material`; verify persisted 1,340-byte TLS-only v2 and exact certificate/key DER, fingerprint and generation retention in a controlled fixture without logging private data. Reboot and confirm identity continuity. Malformed/unknown/cryptographically invalid records and migration read/write/commit failures must fail closed without fallback replacement. Do not interpret a failed commit as proof that no flash write occurred.
|
||||
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.
|
||||
|
||||
Evidence: implementing agents report security 15 groups with real installed mbedTLS and account tests PASS; final integration/build and this target checklist are pending. The user's report that idle cleanup worked is separate, with no additional soak or broad sign-off inferred.
|
||||
This document retains phase-specific regression procedures. The Phase 8A and 8B sections describe historical baselines rather than the current end-to-end behavior. Phase 8C's shared UART0/admin-SSH shell is validated. The current Phase 8D worktree adds explicit browser sessions, the canonical browser admin shell, typed serial/user/Wi-Fi/display workflows, and broker controls; use the Phase 8D section for pending browser validation. Never include generated or entered passwords, Wi-Fi secrets, private keys, tickets, cookies, CSRF values, or verifier material in test logs.
|
||||
|
||||
## Historical Phase 8A baseline — role-based database and UART0 administration
|
||||
|
||||
@@ -192,19 +166,9 @@ While an administrative command is queued or running, use UART0 to change that a
|
||||
|
||||
Finally, issue commands concurrently from UART0 and admin SSH, including `user list`, long `help` output, and one UART0 interactive password or key prompt while an SSH command waits. Confirm the single dispatcher serializes all `esp_console_run()` calls, UART0 retains its line editing/history/completion, prompt input is consumed only from UART0, outputs are not mixed between transports, and there is no stack overflow, corrupted argument parsing, database damage, or broker disruption.
|
||||
|
||||
## Planned Phase 8D integrated web administration
|
||||
## Phase 8D integrated web administration
|
||||
|
||||
These are acceptance requirements; listing an item does not claim its execution. The [8D.3/M1 checkpoint](phase8d3_implementation.md) is host-tested/build-verified and validated by explicit user sign-off on 2026-09-06 after both-role login, mixed-client operation and post-soak evidence. Unrecorded individual checks remain coverage/evidence limitations, not blockers to that sign-off. Browser administration remains planned. Execute them incrementally using the [Phase 8D work-unit plan](phase8d_plan.md), not only at the end of the phase.
|
||||
|
||||
Validation checkpoints:
|
||||
|
||||
- **8D.0:** Establish working browser login/serial, UART0/USB/SSH, and measured build/runtime memory baselines before changing authentication. Record the revision/configuration, workload, numeric resource budgets, and repetition counts.
|
||||
- **8D.1–8D.3 / M1:** Exercise session primitives and session-specific cleanup as they land, then run section 1's authentication checks against the real login page and existing serial UI. Administration-denial checks apply as each admin route is added. Test new and previously Basic-authenticated browser profiles and direct-IP/mDNS access where available. **Stop for target/browser validation before adding the admin shell.**
|
||||
- **8D.4–8D.7 / M2:** Regress UART0/admin SSH after the console-boundary change; then run section 2 and the applicable section 5 concurrency/failure checks with both browser WebSockets active. **Stop for target/browser and memory validation before settings.**
|
||||
- **8D.8–8D.21:** Run sections 3–4 one settings/control domain at a time, including direct server-side role/CSRF/origin denial, malformed/oversized input, persistence, and applicable lifecycle/revocation checks. Reuse previously verified endpoints for popovers rather than postponing API validation until popovers exist.
|
||||
- **8D.22:** Run all sections together plus the bounded concurrent soak. This is cumulative acceptance, not a substitute for the earlier checkpoints.
|
||||
|
||||
Every runtime-changing chunk requires a bounded `pio run`, the small transport/login smoke check, and its focused acceptance checks. Record **implemented**, **build-verified**, and **target-verified** separately. Measure UART0 `memory` internal/PSRAM free, minimum-free, and largest-block values at settled boot, connected load, and after repeated logout/disconnect/reconnect; include stack margins where available and compare incremental and cumulative resource costs. Record pending hardware checks or timed-out commands honestly; neither is a pass. The incremental plan defines the stop/split policy if a resource budget or validation gate fails.
|
||||
**Validation status: all current Phase 8D target-hardware validation is pending.** The browser-session/admin-shell foundation and typed serial, user, Wi-Fi, display, client, and writer workflows are implemented. None of the checks below should be marked passed until exercised on target hardware.
|
||||
|
||||
### 1. Integrated login and authorization
|
||||
|
||||
@@ -214,7 +178,7 @@ Authenticate as both roles through the same-origin login page, explicitly log ou
|
||||
|
||||
As an administrator, connect the browser serial terminal, acquire the writer lease, send and observe serial data, then switch repeatedly between **Serial terminal** and **Admin shell**. The visible terminal contents and input route must change, but `broker clients`, the displayed browser client ID, and active writer ID must remain unchanged. Request control/Release control and writer/observer state must stay visible in both modes. While Admin shell is selected, have a normal user request the writer lease and confirm the retained browser lease prevents unintended takeover. Only explicit Release control, confirmed writer transfer, Disconnect, logout, revocation, expiry, or connection failure may release it.
|
||||
|
||||
Verify the browser admin shell executes the canonical registry through the single dispatcher, preserves bounded history/completion/prompts and backpressure, and does not itself become a second broker client. Physical-only bootstrap/recovery commands remain rejected. Closing only the admin-console route must leave the browser serial client and its lease intact.
|
||||
Verify the browser admin shell executes the canonical registry through the single dispatcher, preserves bounded history/completion/prompts and backpressure, and does not itself become a second broker client. Paste a line longer than 256 bytes followed by a dangerous valid-command suffix before the newline; the whole overlong line must be discarded through CR/LF and the suffix must not execute. Physical-only bootstrap/recovery commands remain rejected. Closing only the admin-console route must leave the browser serial client and its lease intact.
|
||||
|
||||
### 3. Quick settings and client popovers
|
||||
|
||||
@@ -222,10 +186,39 @@ For an administrator, open the **Serial** and **Wi-Fi** card popovers by hover,
|
||||
|
||||
Open **Broker clients** and confirm its secret-free list matches authoritative broker snapshots: client ID, transport/name, writer/observer role, and bounded queue/drop state. Open **Active writer**, choose another currently connected eligible client, confirm the transfer explicitly, and verify exactly one writer remains. Disconnect or recycle the target before confirmation and confirm its stale generation/ID is rejected without releasing or reassigning the current writer. Hover/focus alone must never transfer ownership.
|
||||
|
||||
### 4. Typed settings and destructive operations
|
||||
### 4. Guided users, roles, passwords, keys, and revocation
|
||||
|
||||
Exercise user/password/role/key management, serial settings and persistence, Wi-Fi profiles/AP policy/secrets, service/session controls, display settings, and network diagnostics through typed bounded APIs. Compare resulting subsystem state with the equivalent canonical CLI behavior without routing API requests through command strings. Generated passwords appear once in no-store responses; destructive or self-terminating HTTPS/SSH/reboot/security actions require explicit confirmation and explain the expected connection loss.
|
||||
1. Through Settings, create disposable `user` and `admin` accounts with both entered and generated passwords. Confirm the generated value is displayed once, is absent from subsequent account reads, and is cleared when Settings closes or an operation fails. Exercise list/select, role change, entered password replacement, account deletion, and recreation.
|
||||
2. Add disposable Ed25519 and ECDSA P-256 public keys. Confirm only type, slot, and fingerprint are returned afterward. Re-adding a key to the same account must return a conflict without changing its generation or key list; adding that same key to a second account must succeed independently. Remove each key and verify the matching private key no longer authenticates for that account.
|
||||
3. Open the same account editor in two administrator sessions. Commit a mutation in the first, then submit the stale form from the second. It must receive a conflict, reload the current user list, clear entered password/key material, and not replay the request. Delete and recreate the username before submitting another stale form; the stable user-ID check must prevent it from targeting the replacement account.
|
||||
4. Keep affected browser and SSH sessions plus unrelated-account sessions active. After each committed password, role, key, or delete mutation, confirm best-effort web/SSH revocation is attempted, affected principals lose access, and unrelated sessions continue. Where revocation-hook failure can be injected, confirm the database commit remains authoritative and stale sessions close at their next currentness check rather than rolling back the mutation.
|
||||
5. With at least two administrators, change the signed-in administrator's entered password or role and confirm self-revocation prevents further admin requests. Generated replacement of the current remote administrator must be rejected. Then reduce the database to one administrator and verify attempts to demote or delete that final administrator are rejected without a generation change or session revocation.
|
||||
|
||||
### 5. Concurrency and failure isolation
|
||||
### 5. Guided Wi-Fi configuration and secret handling
|
||||
|
||||
Run USB, browser serial, browser admin shell, user SSH, admin SSH, UART0, and active UART1 traffic concurrently. Alternate explicit writer transfers while issuing administrative commands and opening/closing popovers. Verify one writer, isolated observers, bounded memory/queues, principal revocation, no mixed admin output, no hidden lease loss during terminal switching, and continued UART0/native-USB recovery if web-session or admin-console initialization fails.
|
||||
1. Read `/api/admin/wifi-config` and inspect browser state, HTTP responses, URLs, routine logs, and status endpoints. Saved station/AP secrets must never appear; only `secret_set` flags may indicate their presence.
|
||||
2. Exercise station SSID/priority/security apply, enable, disable, delete, and secret replacement for each slot. Exercise AP policy, SSID, channel, and secret replacement. Confirm disabled-profile-only edits remain staged without disrupting the active radio, while effective-policy changes follow the existing asynchronous restart behavior.
|
||||
3. Save a known working generation, reboot, and confirm it persists. With two editors at the same generation, let one commit and then have the stale editor submit a profile/AP edit or Save. The stale request must return a conflict without changing RAM or NVS. The browser must clear both secret fields, reload current state, and require explicit re-entry rather than replaying the secret-bearing request. Where generation exhaustion can be injected, confirm edit and Save both fail closed.
|
||||
4. Close Settings and force server, authorization, validation, and conflict failures after entering station/AP secrets. Confirm entered values are cleared and do not reappear when Settings is reopened.
|
||||
|
||||
### 6. Guided display aging
|
||||
|
||||
Exercise typed Apply, Save, Load saved, Defaults, and confirmed Reset. Verify valid zero-disabled transitions and valid increasing dim/off delays, then reject values above 86400 and any case where both transitions are enabled but off is not greater than dim. Invalid requests must leave the active configuration unchanged. Confirm Apply is RAM-only, Save survives reboot, Load restores the saved value, Defaults applies 300/600 seconds without persistence, and Reset applies and persists those defaults. Concurrently submit browser operations and display-writer commands from UART0 or a remote Admin shell; confirm `admin_command_gate` serializes each complete operation, with no mixed RAM/NVS result or lost update. Repeat with the local-UI configuration service unavailable and confirm a bounded failure without affecting UART0, USB, serial, Wi-Fi, or HTTPS.
|
||||
|
||||
### 7. Bounded typed requests and HTTPS lifecycle failures
|
||||
|
||||
For `/api/admin/serial`, `/api/admin/users`, `/api/admin/wifi-config`, `/api/admin/display`, and the writer endpoint, send malformed URL encoding, unknown/missing fields, duplicate fields, 11 fields, 513-byte bodies, stale sessions, wrong CSRF, wrong Origin, and normal-user requests. Confirm the 512-byte/10-unique-field body parser rejects them without side effects and that secret-bearing values never enter the request URL.
|
||||
|
||||
Run `web stop`, `web certificate rotate --force`, and `web reset --force` from the browser Admin shell. Administrative output must use bounded best-effort draining; certificate/material replacement must still perform the mandatory TLS refresh if the invoking frontend disappears or its drain times out. Verify the new certificate is served after reconnect and that a newer explicit start/stop intent wins a lifecycle-generation race.
|
||||
|
||||
Exercise these injected teardown paths separately:
|
||||
|
||||
1. Force admin-transport detach timeout followed by successful HTTPD stop. Confirm post-stop finalization runs only after HTTPD destruction; if finalization times out, the next start retries it and does not attach a new server until it succeeds.
|
||||
2. Force HTTPD stop failure. Confirm the handle remains owned, no second HTTPS server starts, transport-owned HTTPD calls stay disabled/tracked, and a later Stop can retry safely.
|
||||
3. Hold an admin HTTPD API operation in flight during detach. Confirm teardown does not free or finalize its server state early and no retired work calls HTTPD after successful destruction.
|
||||
|
||||
Service/session controls beyond the guided serial/Wi-Fi actions, network diagnostics, security/danger operations, and unusual hardware/debug commands have no guided forms; validate them through the canonical Admin shell under existing remote-command policy.
|
||||
|
||||
### 8. Concurrency and failure isolation
|
||||
|
||||
Run USB, browser serial, browser admin shell, user SSH, admin SSH, UART0, and active UART1 traffic concurrently. Alternate explicit writer transfers while issuing administrative commands and opening/closing Settings and popovers. Verify one writer, isolated observers, bounded memory/queues, serialized user mutations, principal revocation, no mixed admin output, no hidden lease loss during terminal switching, and continued UART0/native-USB recovery if web-session or admin-console initialization fails.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
Import("env")
|
||||
|
||||
env.Replace(COMPILATIONDB_INCLUDE_TOOLCHAIN=True)
|
||||
@@ -13,5 +13,3 @@ board_build.partitions = partitions.csv
|
||||
|
||||
monitor_speed = 115200
|
||||
monitor_filters = esp32_exception_decoder
|
||||
|
||||
extra_scripts = pre:extra_script.py
|
||||
|
||||
+2
-21
@@ -28,24 +28,15 @@ idf_component_register(
|
||||
"usb_cdc_transport.c"
|
||||
"usb_console.c"
|
||||
"user_database.c"
|
||||
"user_admin_service.c"
|
||||
"user_console.c"
|
||||
"web_security.c"
|
||||
"web_session.c"
|
||||
"web_serial_transport.c"
|
||||
"web_serial_settings.c"
|
||||
"web_account_settings.c"
|
||||
"web_network_settings.c"
|
||||
"web_admin_tickets.c"
|
||||
"web_admin_transport.c"
|
||||
"web_assets_data.c"
|
||||
"web_ui.c"
|
||||
"web_server.c"
|
||||
"web_diagnostics.c"
|
||||
"web_session_store.c"
|
||||
"web_auth_parse.c"
|
||||
"web_httpd_adapter.c"
|
||||
"web_httpd_idle.c"
|
||||
"web_cookie_auth.c"
|
||||
"web_login_ui.c"
|
||||
"web_console.c"
|
||||
"wifi_config.c"
|
||||
"wifi_manager.c"
|
||||
@@ -79,16 +70,6 @@ idf_component_register(
|
||||
wolfssl__wolfssl
|
||||
)
|
||||
|
||||
# Only web_httpd_adapter.c uses this private, version-checked boundary.
|
||||
target_include_directories(${COMPONENT_LIB} PRIVATE
|
||||
"$ENV{IDF_PATH}/components/esp_http_server/src"
|
||||
"$ENV{IDF_PATH}/components/esp_http_server/src/port/esp32")
|
||||
|
||||
# HTTPD debug logs include header values; URI warnings include ticket queries.
|
||||
# Compile those out, independently of runtime log-level changes.
|
||||
idf_component_get_property(httpd_lib esp_http_server COMPONENT_LIB)
|
||||
target_compile_definitions(${httpd_lib} PRIVATE LOG_LOCAL_LEVEL=ESP_LOG_ERROR)
|
||||
|
||||
# Public wolfSSH headers include wolfCrypt configuration from user_settings.h.
|
||||
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
||||
WOLFSSL_USER_SETTINGS
|
||||
|
||||
+218
-364
@@ -1,5 +1,5 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Serialized, bounded administrative SSH command worker. */
|
||||
/* Serialized, bounded remote administrative command worker. */
|
||||
|
||||
#include "admin_ssh_console.h"
|
||||
|
||||
@@ -9,18 +9,17 @@
|
||||
|
||||
#include "console_completion.h"
|
||||
#include "esp_console.h"
|
||||
#include "esp_system.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "linenoise/linenoise.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "user_database.h"
|
||||
#include "web_serial_settings.h"
|
||||
#include "web_account_settings.h"
|
||||
#include "web_network_settings.h"
|
||||
#include "web_server.h"
|
||||
|
||||
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
|
||||
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
|
||||
#define ADMIN_SSH_CONSOLE_RESPONSE_RESERVE 512U
|
||||
#define ADMIN_SSH_CONSOLE_REQUEST_QUEUE_LENGTH 4U
|
||||
@@ -30,7 +29,8 @@
|
||||
#define ADMIN_UART_CONSOLE_TASK_PRIORITY 3U
|
||||
#define ADMIN_SSH_CONSOLE_MAX_ARGUMENTS 10U
|
||||
#define ADMIN_SSH_CONSOLE_HISTORY_DEPTH 4U
|
||||
#define ADMIN_SSH_CONTROL_QUEUE_LENGTH 2U
|
||||
/* One deferred request per fixed remote console slot can be pending. */
|
||||
#define ADMIN_SSH_CONTROL_QUEUE_LENGTH ADMIN_SSH_CONSOLE_SLOT_COUNT
|
||||
#define ADMIN_SSH_CONTROL_TASK_STACK_SIZE 4096U
|
||||
#define ADMIN_SSH_CONTROL_TASK_PRIORITY 3U
|
||||
|
||||
@@ -48,8 +48,8 @@ typedef struct {
|
||||
bool executing;
|
||||
bool deferred_action_pending;
|
||||
admin_ssh_console_token_t token;
|
||||
const admin_console_owner_t *owner;
|
||||
user_principal_t principal;
|
||||
admin_ssh_console_frontend_ops_t frontend_ops;
|
||||
size_t input_length;
|
||||
size_t input_cursor;
|
||||
uint8_t input[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
@@ -63,6 +63,7 @@ typedef struct {
|
||||
uint8_t escape_parameters[4];
|
||||
size_t escape_parameter_length;
|
||||
bool discard_next_lf;
|
||||
bool discard_overlong_line;
|
||||
admin_prompt_state_t prompt_state;
|
||||
bool prompt_hidden;
|
||||
size_t prompt_capacity;
|
||||
@@ -73,40 +74,29 @@ typedef struct {
|
||||
uint8_t output[ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY];
|
||||
} admin_session_t;
|
||||
|
||||
typedef struct {
|
||||
admin_ssh_deferred_action_type_t action;
|
||||
admin_ssh_console_token_t token;
|
||||
const admin_console_owner_t *owner;
|
||||
uint32_t argument;
|
||||
} admin_control_request_t;
|
||||
|
||||
typedef enum {
|
||||
ADMIN_REQUEST_SSH = 0,
|
||||
ADMIN_REQUEST_REMOTE = 0,
|
||||
ADMIN_REQUEST_UART0,
|
||||
ADMIN_REQUEST_DEFERRED,
|
||||
ADMIN_REQUEST_SERIAL_SETTINGS,
|
||||
ADMIN_REQUEST_ACCOUNT_SETTINGS,
|
||||
ADMIN_REQUEST_NETWORK_SETTINGS,
|
||||
} admin_request_origin_t;
|
||||
|
||||
typedef struct {
|
||||
admin_request_origin_t origin;
|
||||
admin_ssh_console_token_t token;
|
||||
user_principal_t principal;
|
||||
admin_ssh_console_frontend_ops_t frontend_ops;
|
||||
TaskHandle_t completion_task;
|
||||
union {
|
||||
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
admin_control_request_t deferred;
|
||||
uint32_t serial_settings_id;
|
||||
uint32_t account_settings_id;
|
||||
uint32_t network_settings_id;
|
||||
};
|
||||
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
} admin_request_t;
|
||||
|
||||
typedef struct {
|
||||
admin_ssh_deferred_action_type_t action;
|
||||
admin_ssh_console_token_t token;
|
||||
admin_ssh_console_frontend_ops_t frontend_ops;
|
||||
uint32_t argument;
|
||||
} admin_control_request_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_MAX_SESSIONS];
|
||||
/* Claimed under s_lock, used outside it; competing TAB input is backpressured. */
|
||||
static bool s_completion_busy;
|
||||
static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_SLOT_COUNT];
|
||||
static char s_completion_output[CONSOLE_COMPLETION_OUTPUT_CAPACITY];
|
||||
|
||||
static StaticQueue_t s_request_queue_storage;
|
||||
@@ -119,6 +109,8 @@ static uint8_t s_control_queue_bytes[ADMIN_SSH_CONTROL_QUEUE_LENGTH *
|
||||
static QueueHandle_t s_control_queue;
|
||||
static StaticSemaphore_t s_prompt_done_storage;
|
||||
static SemaphoreHandle_t s_prompt_done;
|
||||
static StaticSemaphore_t s_feed_mutex_storage;
|
||||
static SemaphoreHandle_t s_feed_mutex;
|
||||
static TaskHandle_t s_task;
|
||||
static TaskHandle_t s_uart_task;
|
||||
static TaskHandle_t s_control_task;
|
||||
@@ -130,15 +122,24 @@ static bool s_dispatch_output_previous_cr;
|
||||
static admin_ssh_console_token_t s_dispatch_token;
|
||||
static user_principal_t s_dispatch_principal;
|
||||
|
||||
bool admin_ssh_console_is_ready(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_initialized && s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ready;
|
||||
}
|
||||
|
||||
bool admin_ssh_console_dispatch_is_remote(void)
|
||||
{
|
||||
return xTaskGetCurrentTaskHandle() == s_task && s_dispatch_remote;
|
||||
}
|
||||
|
||||
bool admin_ssh_console_dispatch_is_web(void)
|
||||
admin_ssh_console_frontend_t admin_ssh_console_dispatch_frontend(void)
|
||||
{
|
||||
return admin_ssh_console_dispatch_is_remote() &&
|
||||
s_dispatch_token.transport == ADMIN_CONSOLE_TRANSPORT_WEB;
|
||||
return admin_ssh_console_dispatch_is_remote()
|
||||
? s_dispatch_token.frontend
|
||||
: ADMIN_SSH_CONSOLE_FRONTEND_NONE;
|
||||
}
|
||||
|
||||
const user_principal_t *admin_ssh_console_dispatch_principal(void)
|
||||
@@ -148,16 +149,29 @@ const user_principal_t *admin_ssh_console_dispatch_principal(void)
|
||||
|
||||
static bool token_valid(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
return token != NULL && token->slot_index < ADMIN_SSH_CONSOLE_MAX_SESSIONS &&
|
||||
token->session_id != 0U && token->slot_generation != 0U;
|
||||
if (token == NULL || token->session_id == 0U || token->slot_generation == 0U) {
|
||||
return false;
|
||||
}
|
||||
if (token->frontend == ADMIN_SSH_CONSOLE_FRONTEND_SSH) {
|
||||
return token->slot_index < ADMIN_SSH_CONSOLE_SSH_SLOT_COUNT;
|
||||
}
|
||||
return token->frontend == ADMIN_SSH_CONSOLE_FRONTEND_WEB &&
|
||||
token->slot_index == ADMIN_SSH_CONSOLE_WEB_SLOT_INDEX;
|
||||
}
|
||||
|
||||
static bool frontend_ops_valid(const admin_ssh_console_frontend_ops_t *frontend_ops)
|
||||
{
|
||||
return frontend_ops != NULL && frontend_ops->binding_is_current != NULL &&
|
||||
frontend_ops->transport_output_is_drained != NULL &&
|
||||
frontend_ops->request_disconnect != NULL;
|
||||
}
|
||||
|
||||
static bool token_identity_matches(const admin_session_t *session,
|
||||
const admin_ssh_console_token_t *token)
|
||||
{
|
||||
return token_valid(token) && session->token.session_id == token->session_id &&
|
||||
session->token.transport == token->transport &&
|
||||
return token_valid(token) && session->token.frontend == token->frontend &&
|
||||
session->token.slot_index == token->slot_index &&
|
||||
session->token.session_id == token->session_id &&
|
||||
session->token.slot_generation == token->slot_generation;
|
||||
}
|
||||
|
||||
@@ -167,37 +181,6 @@ static bool token_matches(const admin_session_t *session,
|
||||
return session->active && token_identity_matches(session, token);
|
||||
}
|
||||
|
||||
static bool session_is_current(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[token->slot_index];
|
||||
const admin_console_owner_t *owner = token_matches(session, token)
|
||||
? session->owner : NULL;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (owner == NULL) {
|
||||
return false;
|
||||
}
|
||||
bool account_current = false;
|
||||
bool current = principal->role == USER_ROLE_ADMIN &&
|
||||
user_database_principal_is_current(principal, &account_current) == ESP_OK &&
|
||||
account_current && owner->is_current(token, principal);
|
||||
/* External checks may close/reuse a slot. Never act on its replacement. */
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool matched = token_matches(session, token) && session->owner == owner;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (matched && !current) {
|
||||
admin_ssh_console_close(token);
|
||||
}
|
||||
return matched && current;
|
||||
}
|
||||
|
||||
bool admin_ssh_console_dispatch_is_current(void)
|
||||
{
|
||||
return xTaskGetCurrentTaskHandle() == s_task &&
|
||||
(!s_dispatch_remote || session_is_current(&s_dispatch_token, &s_dispatch_principal));
|
||||
}
|
||||
|
||||
static bool append_output_locked(admin_session_t *session,
|
||||
const uint8_t *data, size_t length)
|
||||
{
|
||||
@@ -358,9 +341,6 @@ esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
*output_length = 0U;
|
||||
memset(output, 0, capacity);
|
||||
(void)xSemaphoreTake(s_prompt_done, 0U);
|
||||
if (!session_is_current(&s_dispatch_token, &s_dispatch_principal)) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[s_dispatch_token.slot_index];
|
||||
@@ -383,36 +363,27 @@ esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
if (!published) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
for (;;) {
|
||||
/* The semaphore is only a hint: delayed/stale wakes cannot submit input. */
|
||||
(void)xSemaphoreTake(s_prompt_done, pdMS_TO_TICKS(250U));
|
||||
bool current = session_is_current(&s_dispatch_token, &s_dispatch_principal);
|
||||
esp_err_t result = ESP_ERR_INVALID_STATE;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
session = &s_sessions[s_dispatch_token.slot_index];
|
||||
if (!token_identity_matches(session, &s_dispatch_token)) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
if (current && session->active && session->prompt_state == ADMIN_PROMPT_WAITING) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
continue;
|
||||
}
|
||||
if (!current || !session->active || session->prompt_state == ADMIN_PROMPT_DISCONNECTED) {
|
||||
result = ESP_ERR_NOT_FOUND;
|
||||
} else if (session->prompt_state == ADMIN_PROMPT_SUBMITTED) {
|
||||
memcpy(output, session->prompt_input, session->prompt_length);
|
||||
*output_length = session->prompt_length;
|
||||
result = ESP_OK;
|
||||
}
|
||||
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
|
||||
session->prompt_length = 0U;
|
||||
session->prompt_capacity = 0U;
|
||||
session->prompt_hidden = false;
|
||||
session->prompt_state = ADMIN_PROMPT_NONE;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return result;
|
||||
if (xSemaphoreTake(s_prompt_done, portMAX_DELAY) != pdTRUE) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
esp_err_t result = ESP_ERR_INVALID_STATE;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
session = &s_sessions[s_dispatch_token.slot_index];
|
||||
if (session->prompt_state == ADMIN_PROMPT_SUBMITTED) {
|
||||
memcpy(output, session->prompt_input, session->prompt_length);
|
||||
*output_length = session->prompt_length;
|
||||
result = ESP_OK;
|
||||
} else if (session->prompt_state == ADMIN_PROMPT_DISCONNECTED) {
|
||||
result = ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
|
||||
session->prompt_length = 0U;
|
||||
session->prompt_capacity = 0U;
|
||||
session->prompt_hidden = false;
|
||||
session->prompt_state = ADMIN_PROMPT_NONE;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_dispatch_defer(
|
||||
@@ -421,18 +392,14 @@ esp_err_t admin_ssh_console_dispatch_defer(
|
||||
if (!admin_ssh_console_dispatch_is_remote() || action == ADMIN_SSH_DEFER_NONE) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
admin_ssh_console_frontend_ops_t frontend_ops = {0};
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[s_dispatch_token.slot_index];
|
||||
bool valid = token_matches(session, &s_dispatch_token) &&
|
||||
!session->deferred_action_pending;
|
||||
const admin_console_owner_t *owner = valid ? session->owner : NULL;
|
||||
if (valid && ((unsigned)action > ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE ||
|
||||
!(owner->supported_actions & (1U << action)))) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
if (valid) {
|
||||
session->deferred_action_pending = true;
|
||||
frontend_ops = session->frontend_ops;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!valid) {
|
||||
@@ -441,7 +408,7 @@ esp_err_t admin_ssh_console_dispatch_defer(
|
||||
admin_control_request_t request = {
|
||||
.action = action,
|
||||
.token = s_dispatch_token,
|
||||
.owner = owner,
|
||||
.frontend_ops = frontend_ops,
|
||||
.argument = argument,
|
||||
};
|
||||
if (xQueueSend(s_control_queue, &request, 0U) == pdTRUE) {
|
||||
@@ -456,7 +423,7 @@ esp_err_t admin_ssh_console_dispatch_defer(
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
static int ssh_output_write(void *cookie, const char *buffer, int length)
|
||||
static int remote_output_write(void *cookie, const char *buffer, int length)
|
||||
{
|
||||
const admin_ssh_console_token_t *token = cookie;
|
||||
if (!token_valid(token) || buffer == NULL || length <= 0) {
|
||||
@@ -503,34 +470,6 @@ static int ssh_output_write(void *cookie, const char *buffer, int length)
|
||||
return length;
|
||||
}
|
||||
|
||||
bool admin_ssh_console_web_user_command_allowed(
|
||||
size_t argc, char **argv, const user_principal_t *principal)
|
||||
{
|
||||
if (argc == 0U || strcmp(argv[0], "user") != 0) return false;
|
||||
if (argc == 1U ||
|
||||
(argc == 2U && (strcmp(argv[1], "status") == 0 ||
|
||||
strcmp(argv[1], "list") == 0)) ||
|
||||
(argc == 3U && strcmp(argv[1], "show") == 0)) return true;
|
||||
|
||||
bool role_valid = argc >= 4U &&
|
||||
(strcmp(argv[3], "user") == 0 || strcmp(argv[3], "admin") == 0);
|
||||
bool mutation =
|
||||
(argc == 4U && strcmp(argv[1], "add") == 0 && role_valid) ||
|
||||
(argc == 3U && strcmp(argv[1], "password") == 0) ||
|
||||
(argc == 4U && strcmp(argv[1], "delete") == 0 &&
|
||||
strcmp(argv[3], "--force") == 0) ||
|
||||
(argc == 5U && strcmp(argv[1], "role") == 0 && role_valid &&
|
||||
strcmp(argv[4], "--force") == 0);
|
||||
/* Parsed names use the database's exact, case-sensitive identity. Reject
|
||||
* self even for no-op role changes; their handler still requests revocation.
|
||||
* Generation/output and key workflows remain outside this bounded slice. */
|
||||
return mutation && principal != NULL && principal->role == USER_ROLE_ADMIN &&
|
||||
principal->username_length > 0U &&
|
||||
principal->username_length <= USER_DATABASE_USERNAME_CAPACITY &&
|
||||
!(strlen(argv[2]) == principal->username_length &&
|
||||
memcmp(argv[2], principal->username, principal->username_length) == 0);
|
||||
}
|
||||
|
||||
static bool remote_command_allowed(const admin_request_t *request)
|
||||
{
|
||||
char copy[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
@@ -538,39 +477,11 @@ static bool remote_command_allowed(const admin_request_t *request)
|
||||
char *argv[ADMIN_SSH_CONSOLE_MAX_ARGUMENTS] = {0};
|
||||
/* Use exactly the same quote/escape parser as esp_console_run(). */
|
||||
size_t argc = esp_console_split_argv(copy, argv, ADMIN_SSH_CONSOLE_MAX_ARGUMENTS);
|
||||
/* Empty input is handled quietly by esp_console_run(), not UART0 policy. */
|
||||
bool allowed = true;
|
||||
if (argc >= 2U && strcmp(argv[0], "user") == 0 &&
|
||||
strcmp(argv[1], "recover") == 0) {
|
||||
bool allowed = argc > 0U;
|
||||
if (allowed && strcmp(argv[0], "user") == 0 && argc >= 2U &&
|
||||
(strcmp(argv[1], "bootstrap") == 0 || strcmp(argv[1], "recover") == 0)) {
|
||||
allowed = false;
|
||||
}
|
||||
/* Temporary browser policy until lifecycle acknowledgements/revocation are
|
||||
* coordinated (8D.7). Classify parsed canonical arguments, not raw prefixes.
|
||||
* User mutations remain available through UART0/SSH, subject to their policy.
|
||||
*/
|
||||
if (request->token.transport == ADMIN_CONSOLE_TRANSPORT_WEB && argc > 0U) {
|
||||
if (strcmp(argv[0], "web") == 0) {
|
||||
allowed = (argc == 2U && (strcmp(argv[1], "status") == 0 ||
|
||||
strcmp(argv[1], "stop") == 0)) ||
|
||||
(argc == 4U && strcmp(argv[1], "certificate") == 0 &&
|
||||
strcmp(argv[2], "rotate") == 0 && strcmp(argv[3], "--force") == 0);
|
||||
} else if (strcmp(argv[0], "wifi") == 0 || strcmp(argv[0], "mdns") == 0) {
|
||||
allowed = argc == 2U && strcmp(argv[1], "status") == 0;
|
||||
} else if (strcmp(argv[0], "user") == 0) {
|
||||
allowed = admin_ssh_console_web_user_command_allowed(
|
||||
argc, argv, &request->principal);
|
||||
} else if (strcmp(argv[0], "reboot") == 0) {
|
||||
allowed = argc == 1U;
|
||||
} else if (strcmp(argv[0], "ssh") == 0 && argc >= 2U) {
|
||||
/* SSH-specific deferred actions are not yet supported by WEB. */
|
||||
if (strcmp(argv[1], "stop") == 0 || strcmp(argv[1], "disconnect") == 0 ||
|
||||
strcmp(argv[1], "reset") == 0 ||
|
||||
(strcmp(argv[1], "host-key") == 0 &&
|
||||
!(argc == 3U && strcmp(argv[2], "info") == 0))) {
|
||||
allowed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
secure_wipe(copy, sizeof(copy));
|
||||
return allowed;
|
||||
}
|
||||
@@ -595,19 +506,18 @@ static int command_exit(int argc, char **argv)
|
||||
return 1;
|
||||
}
|
||||
if (!admin_ssh_console_dispatch_is_remote()) {
|
||||
printf("The exit command is available only from an administrative SSH session.\n");
|
||||
printf("The exit command is available only from a remote administrative session.\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
esp_err_t error = admin_ssh_console_dispatch_defer(
|
||||
ADMIN_CONSOLE_DEFER_SELF_CLOSE, s_dispatch_token.session_id);
|
||||
ADMIN_SSH_DEFER_FRONTEND_DISCONNECT, s_dispatch_token.session_id);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not schedule %s session close: %s\n",
|
||||
s_dispatch_token.transport == 0U ? "SSH" : "remote", esp_err_to_name(error));
|
||||
printf("Could not schedule administrative session close: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("%s session close scheduled after output drains.\n",
|
||||
s_dispatch_token.transport == 0U ? "SSH" : "Remote");
|
||||
printf("Administrative session close scheduled after output drains.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -616,8 +526,8 @@ static void dispatch_registered_command(admin_request_t *request)
|
||||
FILE *saved_stdout = stdout;
|
||||
FILE *saved_stderr = stderr;
|
||||
FILE *remote_stream = NULL;
|
||||
if (request->origin == ADMIN_REQUEST_SSH) {
|
||||
remote_stream = funopen(&request->token, NULL, ssh_output_write, NULL, NULL);
|
||||
if (request->origin == ADMIN_REQUEST_REMOTE) {
|
||||
remote_stream = funopen(&request->token, NULL, remote_output_write, NULL, NULL);
|
||||
if (remote_stream == NULL) {
|
||||
(void)worker_write(&request->token, "Could not create command output stream.\r\n");
|
||||
return;
|
||||
@@ -635,11 +545,8 @@ static void dispatch_registered_command(admin_request_t *request)
|
||||
}
|
||||
|
||||
int command_result = 0;
|
||||
if (request->origin == ADMIN_REQUEST_UART0 ||
|
||||
session_is_current(&request->token, &request->principal)) {
|
||||
esp_err_t error = esp_console_run((const char *)request->line, &command_result);
|
||||
report_command_result(error, command_result);
|
||||
}
|
||||
esp_err_t error = esp_console_run((const char *)request->line, &command_result);
|
||||
report_command_result(error, command_result);
|
||||
fflush(stdout);
|
||||
|
||||
s_dispatch_remote = false;
|
||||
@@ -653,38 +560,6 @@ static void dispatch_registered_command(admin_request_t *request)
|
||||
}
|
||||
}
|
||||
|
||||
static void dispatch_deferred_request(admin_request_t *request);
|
||||
|
||||
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready || !id) return ESP_ERR_INVALID_STATE;
|
||||
admin_request_t request = {.origin = ADMIN_REQUEST_SERIAL_SETTINGS, .serial_settings_id = id};
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready || !id) return ESP_ERR_INVALID_STATE;
|
||||
admin_request_t request = {.origin = ADMIN_REQUEST_ACCOUNT_SETTINGS, .account_settings_id = id};
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready || !id) return ESP_ERR_INVALID_STATE;
|
||||
admin_request_t request = {.origin = ADMIN_REQUEST_NETWORK_SETTINGS, .network_settings_id = id};
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
static void worker_task(void *context)
|
||||
{
|
||||
(void)context;
|
||||
@@ -693,19 +568,6 @@ static void worker_task(void *context)
|
||||
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
|
||||
continue;
|
||||
}
|
||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS ||
|
||||
request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) {
|
||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id);
|
||||
else web_network_settings_execute(request.network_settings_id);
|
||||
secure_wipe(&request, sizeof(request));
|
||||
continue;
|
||||
}
|
||||
if (request.origin == ADMIN_REQUEST_DEFERRED) {
|
||||
dispatch_deferred_request(&request);
|
||||
secure_wipe(&request, sizeof(request));
|
||||
continue;
|
||||
}
|
||||
if (request.origin == ADMIN_REQUEST_UART0) {
|
||||
dispatch_registered_command(&request);
|
||||
if (request.completion_task != NULL) {
|
||||
@@ -715,33 +577,45 @@ static void worker_task(void *context)
|
||||
continue;
|
||||
}
|
||||
|
||||
bool current = session_is_current(&request.token, &request.principal);
|
||||
bool principal_current = false;
|
||||
esp_err_t auth_error = user_database_principal_is_current(
|
||||
&request.principal, &principal_current);
|
||||
bool command_allowed = request.principal.role == USER_ROLE_ADMIN &&
|
||||
remote_command_allowed(&request);
|
||||
bool binding_current = frontend_ops_valid(&request.frontend_ops) &&
|
||||
request.frontend_ops.binding_is_current(&request.token);
|
||||
bool authorized = auth_error == ESP_OK && principal_current &&
|
||||
command_allowed && binding_current;
|
||||
|
||||
bool active;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[request.token.slot_index];
|
||||
active = current && token_matches(session, &request.token) && session->command_pending &&
|
||||
active = token_matches(session, &request.token) && session->command_pending &&
|
||||
!session->executing;
|
||||
if (active) {
|
||||
session->executing = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
bool authorized = active && remote_command_allowed(&request);
|
||||
if (authorized) {
|
||||
|
||||
if (authorized && active) {
|
||||
dispatch_registered_command(&request);
|
||||
} else if (active) {
|
||||
(void)worker_write(&request.token,
|
||||
request.token.transport == ADMIN_CONSOLE_TRANSPORT_WEB
|
||||
? "Command is unavailable from the web console; use UART0 or SSH where permitted. Recovery requires UART0.\r\n"
|
||||
: "Command is restricted to physical UART0.\r\n");
|
||||
const char *message = "Command is restricted to physical UART0.\r\n";
|
||||
if (auth_error != ESP_OK || !principal_current ||
|
||||
request.principal.role != USER_ROLE_ADMIN) {
|
||||
message = "Administrative authorization is no longer current; closing session.\r\n";
|
||||
} else if (!binding_current) {
|
||||
message = "Administrative session binding is no longer current; closing session.\r\n";
|
||||
}
|
||||
(void)worker_write(&request.token, message);
|
||||
}
|
||||
current = session_is_current(&request.token, &request.principal);
|
||||
bool prompt = false;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
session = &s_sessions[request.token.slot_index];
|
||||
if (token_matches(session, &request.token)) {
|
||||
session->executing = false;
|
||||
session->command_pending = false;
|
||||
prompt = current &&
|
||||
prompt = auth_error == ESP_OK && principal_current && binding_current &&
|
||||
request.principal.role == USER_ROLE_ADMIN &&
|
||||
!session->deferred_action_pending;
|
||||
} else if (!session->active && session->executing &&
|
||||
@@ -761,18 +635,17 @@ static void finish_deferred_request(const admin_control_request_t *request,
|
||||
esp_err_t result, bool cancelled)
|
||||
{
|
||||
char message[160];
|
||||
const char *transport = request->token.transport == 0U ? "SSH" : "remote";
|
||||
if (cancelled) {
|
||||
snprintf(message, sizeof(message),
|
||||
"Deferred action cancelled before %s output drained.\r\nadmin@serial-tool> ",
|
||||
transport);
|
||||
"Deferred action cancelled before administrative output drained.\r\n"
|
||||
"admin@serial-tool> ");
|
||||
} else if (result == ESP_OK) {
|
||||
snprintf(message, sizeof(message),
|
||||
"Deferred %s action completed.\r\nadmin@serial-tool> ", transport);
|
||||
"Deferred administrative action completed.\r\nadmin@serial-tool> ");
|
||||
} else {
|
||||
snprintf(message, sizeof(message),
|
||||
"Deferred %s action failed: %s\r\nadmin@serial-tool> ",
|
||||
transport, esp_err_to_name(result));
|
||||
"Deferred administrative action failed: %s\r\nadmin@serial-tool> ",
|
||||
esp_err_to_name(result));
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[request->token.slot_index];
|
||||
@@ -783,34 +656,6 @@ static void finish_deferred_request(const admin_control_request_t *request,
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static void dispatch_deferred_request(admin_request_t *request)
|
||||
{
|
||||
const admin_control_request_t *action = &request->deferred;
|
||||
bool current = session_is_current(&action->token, &request->principal);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[action->token.slot_index];
|
||||
bool active = current && token_matches(session, &action->token) &&
|
||||
session->owner == action->owner && session->deferred_action_pending &&
|
||||
!session->command_pending && !session->executing;
|
||||
if (active) session->executing = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
/* Keep the slot reserved across lifecycle callbacks, including self-detach.
|
||||
* Recheck after reservation just as the canonical runner does. */
|
||||
esp_err_t result = ESP_ERR_NOT_FOUND;
|
||||
if (active && session_is_current(&action->token, &request->principal)) {
|
||||
result = action->owner->perform(&action->token, action->action, action->argument);
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
session = &s_sessions[action->token.slot_index];
|
||||
if (active && token_identity_matches(session, &action->token)) {
|
||||
if (session->active) session->executing = false;
|
||||
else secure_wipe(session, sizeof(*session));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
finish_deferred_request(action, result, false);
|
||||
}
|
||||
|
||||
static void control_task(void *context)
|
||||
{
|
||||
(void)context;
|
||||
@@ -820,6 +665,9 @@ static void control_task(void *context)
|
||||
continue;
|
||||
}
|
||||
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(10000U);
|
||||
bool must_execute =
|
||||
request.action == ADMIN_SSH_DEFER_WEB_TLS_REFRESH_STOPPED ||
|
||||
request.action == ADMIN_SSH_DEFER_WEB_TLS_REFRESH_RUNNING;
|
||||
bool drained = false;
|
||||
while ((int32_t)(xTaskGetTickCount() - deadline) < 0) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
@@ -829,45 +677,57 @@ static void control_task(void *context)
|
||||
session->output_length == 0U;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!current) {
|
||||
drained = must_execute;
|
||||
break;
|
||||
}
|
||||
bool transport_drained = console_drained &&
|
||||
request.owner->drained(&request.token);
|
||||
request.frontend_ops.transport_output_is_drained(&request.token);
|
||||
if (console_drained && transport_drained) {
|
||||
drained = true;
|
||||
break;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(10U));
|
||||
}
|
||||
if (!drained) {
|
||||
if (!drained && !must_execute) {
|
||||
finish_deferred_request(&request, ESP_ERR_TIMEOUT, true);
|
||||
secure_wipe(&request, sizeof(request));
|
||||
continue;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(200U));
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[request.token.slot_index];
|
||||
bool current = token_matches(session, &request.token) &&
|
||||
session->owner == request.owner && session->deferred_action_pending;
|
||||
admin_request_t queued = {
|
||||
.origin = ADMIN_REQUEST_DEFERRED,
|
||||
.deferred = request,
|
||||
};
|
||||
if (current) queued.principal = session->principal;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (current && (request.owner->dispatcher_actions & (1U << request.action))) {
|
||||
/* Nonblocking handoff: a full dispatcher queue fails before mutation.
|
||||
* Pending remains set until execution completes, not merely enqueue. */
|
||||
if (xQueueSend(s_request_queue, &queued, 0U) != pdTRUE) {
|
||||
finish_deferred_request(&request, ESP_ERR_TIMEOUT, false);
|
||||
}
|
||||
secure_wipe(&queued, sizeof(queued));
|
||||
secure_wipe(&request, sizeof(request));
|
||||
continue;
|
||||
esp_err_t result = ESP_OK;
|
||||
switch (request.action) {
|
||||
case ADMIN_SSH_DEFER_REBOOT:
|
||||
esp_restart();
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_STOP:
|
||||
result = ssh_transport_stop();
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_FRONTEND_DISCONNECT:
|
||||
result = request.frontend_ops.request_disconnect(
|
||||
&request.token, request.argument);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_SSH_DISCONNECT:
|
||||
result = ssh_transport_disconnect(request.argument);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_HOST_KEY_ROTATE:
|
||||
result = ssh_transport_replace_host_key(false);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_HOST_KEY_RESET:
|
||||
result = ssh_transport_replace_host_key(true);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_WEB_STOP:
|
||||
result = web_server_stop_if_generation(request.argument);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_WEB_TLS_REFRESH_STOPPED:
|
||||
result = web_server_refresh_tls(request.argument, false);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_WEB_TLS_REFRESH_RUNNING:
|
||||
result = web_server_refresh_tls(request.argument, true);
|
||||
break;
|
||||
default:
|
||||
result = ESP_ERR_NOT_SUPPORTED;
|
||||
break;
|
||||
}
|
||||
secure_wipe(&queued, sizeof(queued));
|
||||
esp_err_t result = current ? request.owner->perform(
|
||||
&request.token, request.action, request.argument) : ESP_ERR_NOT_FOUND;
|
||||
finish_deferred_request(&request, result, false);
|
||||
secure_wipe(&request, sizeof(request));
|
||||
}
|
||||
@@ -922,7 +782,9 @@ esp_err_t admin_ssh_console_init(void)
|
||||
sizeof(admin_control_request_t),
|
||||
s_control_queue_bytes, &s_control_queue_storage);
|
||||
s_prompt_done = xSemaphoreCreateBinaryStatic(&s_prompt_done_storage);
|
||||
if (s_request_queue == NULL || s_control_queue == NULL || s_prompt_done == NULL) {
|
||||
s_feed_mutex = xSemaphoreCreateMutexStatic(&s_feed_mutex_storage);
|
||||
if (s_request_queue == NULL || s_control_queue == NULL || s_prompt_done == NULL ||
|
||||
s_feed_mutex == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
if (xTaskCreate(worker_task, "admin_ssh_console", ADMIN_SSH_CONSOLE_TASK_STACK_SIZE,
|
||||
@@ -947,7 +809,7 @@ esp_err_t admin_ssh_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
.command = "exit",
|
||||
.help = "Close the current administrative remote session",
|
||||
.help = "Close the current remote administrative session",
|
||||
.hint = NULL,
|
||||
.func = &command_exit,
|
||||
.argtable = NULL,
|
||||
@@ -979,20 +841,16 @@ esp_err_t admin_ssh_console_start_uart_frontend(void)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t open_session(admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_console_owner_t *owner, bool available)
|
||||
esp_err_t admin_ssh_console_open(
|
||||
const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_ssh_console_frontend_ops_t *frontend_ops)
|
||||
{
|
||||
if (token == NULL || token->session_id == 0U || token->slot_generation == 0U ||
|
||||
(!available && !token_valid(token)) || principal == NULL || principal->role != USER_ROLE_ADMIN ||
|
||||
owner == NULL || owner->is_current == NULL ||
|
||||
owner->drained == NULL || owner->perform == NULL) {
|
||||
if (!token_valid(token) || principal == NULL || principal->role != USER_ROLE_ADMIN ||
|
||||
!frontend_ops_valid(frontend_ops)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_initialized && s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) {
|
||||
if (!admin_ssh_console_is_ready()) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
bool current = false;
|
||||
@@ -1000,59 +858,27 @@ static esp_err_t open_session(admin_ssh_console_token_t *token,
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
size_t index = token->slot_index;
|
||||
if (available) {
|
||||
for (index = 0U; index < ADMIN_SSH_CONSOLE_MAX_SESSIONS; ++index) {
|
||||
if (!s_sessions[index].active && !s_sessions[index].executing) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (index == ADMIN_SSH_CONSOLE_MAX_SESSIONS) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
}
|
||||
admin_session_t *session = &s_sessions[index];
|
||||
if (session->active || session->executing) {
|
||||
admin_session_t *session = &s_sessions[token->slot_index];
|
||||
if (session->executing) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
secure_wipe(session, sizeof(*session));
|
||||
session->active = true;
|
||||
session->history_position = -1;
|
||||
token->slot_index = (uint8_t)index;
|
||||
session->token = *token;
|
||||
session->owner = owner;
|
||||
session->principal = *principal;
|
||||
const char *banner = token->transport == 0U
|
||||
? "ESP32 Serial Swiss Army Knife administrative SSH shell\r\n"
|
||||
: "ESP32 Serial Swiss Army Knife administrative remote shell\r\n";
|
||||
session->frontend_ops = *frontend_ops;
|
||||
static const char banner[] =
|
||||
"ESP32 Serial Swiss Army Knife administrative shell\r\n";
|
||||
static const char prompt[] =
|
||||
"Run 'help' for supported remote administrative commands.\r\nadmin@serial-tool> ";
|
||||
(void)append_output_locked(session, (const uint8_t *)banner, strlen(banner));
|
||||
(void)append_output_locked(session, (const uint8_t *)banner, sizeof(banner) - 1U);
|
||||
(void)append_output_locked(session, (const uint8_t *)prompt, sizeof(prompt) - 1U);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_console_owner_t *owner)
|
||||
{
|
||||
return open_session(token, principal, owner, true);
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_console_owner_t *owner)
|
||||
{
|
||||
if (token == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
admin_ssh_console_token_t copy = *token;
|
||||
return open_session(©, principal, owner, false);
|
||||
}
|
||||
|
||||
void admin_ssh_console_close(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
if (!token_valid(token)) {
|
||||
@@ -1063,10 +889,8 @@ void admin_ssh_console_close(const admin_ssh_console_token_t *token)
|
||||
bool matched = token_matches(session, token);
|
||||
bool wake_prompt = false;
|
||||
if (matched) {
|
||||
if (session->prompt_state != ADMIN_PROMPT_NONE) {
|
||||
if (session->prompt_state == ADMIN_PROMPT_WAITING) {
|
||||
session->prompt_state = ADMIN_PROMPT_DISCONNECTED;
|
||||
secure_wipe(session->prompt_input, sizeof(session->prompt_input));
|
||||
session->prompt_length = 0U;
|
||||
wake_prompt = true;
|
||||
}
|
||||
session->active = false;
|
||||
@@ -1090,15 +914,14 @@ bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token)
|
||||
bool shell_input = !session->command_pending && !session->deferred_action_pending;
|
||||
bool prompt_input = session->command_pending && session->executing &&
|
||||
session->prompt_state == ADMIN_PROMPT_WAITING;
|
||||
bool accepts = token_matches(session, token) && !session->deferred_action_pending &&
|
||||
(shell_input || prompt_input) &&
|
||||
bool accepts = token_matches(session, token) && (shell_input || prompt_input) &&
|
||||
session->output_length <= ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY -
|
||||
ADMIN_SSH_CONSOLE_RESPONSE_RESERVE;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return accepts;
|
||||
}
|
||||
|
||||
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
static bool feed_input_serialized(const admin_ssh_console_token_t *token,
|
||||
const uint8_t *data, size_t length,
|
||||
size_t *consumed)
|
||||
{
|
||||
@@ -1111,7 +934,7 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
bool submit = false;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[token->slot_index];
|
||||
if (!token_matches(session, token) || session->deferred_action_pending) {
|
||||
if (!token_matches(session, token)) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return *consumed != 0U;
|
||||
}
|
||||
@@ -1175,6 +998,23 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
continue;
|
||||
}
|
||||
session->discard_next_lf = false;
|
||||
if (session->discard_overlong_line) {
|
||||
if (value == '\r' || value == '\n' || value == 0x03U) {
|
||||
session->discard_overlong_line = false;
|
||||
session->discard_next_lf = value == '\r';
|
||||
(void)append_output_locked(
|
||||
session,
|
||||
(const uint8_t *)(value == 0x03U
|
||||
? "^C\r\nadmin@serial-tool> "
|
||||
: "admin@serial-tool> "),
|
||||
value == 0x03U
|
||||
? sizeof("^C\r\nadmin@serial-tool> ") - 1U
|
||||
: sizeof("admin@serial-tool> ") - 1U);
|
||||
}
|
||||
++*consumed;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
continue;
|
||||
}
|
||||
if (session->escape_state != 0U) {
|
||||
if (session->escape_state == 1U) {
|
||||
if (value == '[') {
|
||||
@@ -1229,10 +1069,6 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
continue;
|
||||
}
|
||||
if (value == '\t') {
|
||||
if (s_completion_busy) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return *consumed != 0U;
|
||||
}
|
||||
if (session->input_cursor != session->input_length) {
|
||||
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
|
||||
++*consumed;
|
||||
@@ -1241,7 +1077,6 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
}
|
||||
char current[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
memcpy(current, session->input, sizeof(current));
|
||||
s_completion_busy = true;
|
||||
++*consumed;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
char completed[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U] = {0};
|
||||
@@ -1274,8 +1109,6 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
|
||||
}
|
||||
}
|
||||
secure_wipe(s_completion_output, sizeof(s_completion_output));
|
||||
s_completion_busy = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(current, sizeof(current));
|
||||
secure_wipe(completed, sizeof(completed));
|
||||
@@ -1290,9 +1123,10 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
history_commit_locked(session);
|
||||
memcpy(request.line, session->input, session->input_length);
|
||||
}
|
||||
request.origin = ADMIN_REQUEST_SSH;
|
||||
request.origin = ADMIN_REQUEST_REMOTE;
|
||||
request.token = *token;
|
||||
request.principal = session->principal;
|
||||
request.frontend_ops = session->frontend_ops;
|
||||
secure_wipe(session->input, sizeof(session->input));
|
||||
session->input_length = 0U;
|
||||
session->input_cursor = 0U;
|
||||
@@ -1324,11 +1158,17 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
}
|
||||
} else if (value >= 0x20U && value <= 0x7eU) {
|
||||
if (session->input_length >= ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY) {
|
||||
secure_wipe(session->input, sizeof(session->input));
|
||||
session->input_length = 0U;
|
||||
session->input_cursor = 0U;
|
||||
(void)append_output_locked(session, (const uint8_t *)
|
||||
"\r\nCommand too long; discarded.\r\nadmin@serial-tool> ",
|
||||
sizeof("\r\nCommand too long; discarded.\r\nadmin@serial-tool> ") - 1U);
|
||||
session->history_position = -1;
|
||||
session->escape_state = 0U;
|
||||
session->discard_overlong_line = true;
|
||||
(void)append_output_locked(
|
||||
session,
|
||||
(const uint8_t *)
|
||||
"\r\nCommand too long; discarding until end of line.\r\n",
|
||||
sizeof("\r\nCommand too long; discarding until end of line.\r\n") - 1U);
|
||||
} else {
|
||||
memmove(session->input + session->input_cursor + 1U,
|
||||
session->input + session->input_cursor,
|
||||
@@ -1361,6 +1201,23 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
const uint8_t *data, size_t length,
|
||||
size_t *consumed)
|
||||
{
|
||||
if (consumed == NULL) {
|
||||
return false;
|
||||
}
|
||||
*consumed = 0U;
|
||||
if (!token_valid(token) || (data == NULL && length != 0U) || s_feed_mutex == NULL ||
|
||||
xSemaphoreTake(s_feed_mutex, 0U) != pdTRUE) {
|
||||
return false;
|
||||
}
|
||||
bool accepted = feed_input_serialized(token, data, length, consumed);
|
||||
xSemaphoreGive(s_feed_mutex);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
|
||||
uint8_t *data, size_t capacity,
|
||||
size_t *received)
|
||||
@@ -1381,10 +1238,8 @@ esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
|
||||
first = ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_start;
|
||||
}
|
||||
memcpy(data, session->output + session->output_start, first);
|
||||
secure_wipe(session->output + session->output_start, first);
|
||||
if (copied > first) {
|
||||
memcpy(data + first, session->output, copied - first);
|
||||
secure_wipe(session->output, copied - first);
|
||||
}
|
||||
session->output_start = (session->output_start + copied) %
|
||||
ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY;
|
||||
@@ -1412,7 +1267,6 @@ esp_err_t admin_ssh_console_get_session_snapshot(
|
||||
snapshot->command_pending = session->command_pending;
|
||||
snapshot->input_pending = session->input_length != 0U;
|
||||
snapshot->output_pending = session->output_length != 0U;
|
||||
snapshot->deferred_action_pending = session->deferred_action_pending;
|
||||
snapshot->input_length = session->input_length;
|
||||
snapshot->output_length = session->output_length;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
+33
-88
@@ -1,5 +1,5 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded administrative dispatcher with a small remote-owner boundary. */
|
||||
/* Bounded, transport-neutral administrative command worker for remote sessions. */
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -14,103 +14,52 @@
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Nonblocking typed settings admission to the canonical dispatcher. */
|
||||
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id);
|
||||
|
||||
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
|
||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||
|
||||
#define ADMIN_CONSOLE_TRANSPORT_SSH 0U
|
||||
#define ADMIN_CONSOLE_TRANSPORT_WEB 1U
|
||||
/* SSH retains global slots 0 and 1; the web administration frontend owns slot 2. */
|
||||
#define ADMIN_SSH_CONSOLE_SSH_SLOT_COUNT 2U
|
||||
#define ADMIN_SSH_CONSOLE_WEB_SLOT_INDEX ADMIN_SSH_CONSOLE_SSH_SLOT_COUNT
|
||||
#define ADMIN_SSH_CONSOLE_SLOT_COUNT (ADMIN_SSH_CONSOLE_WEB_SLOT_INDEX + 1U)
|
||||
|
||||
typedef enum {
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_NONE = 0,
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_SSH,
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_WEB,
|
||||
} admin_ssh_console_frontend_t;
|
||||
|
||||
typedef struct {
|
||||
admin_ssh_console_frontend_t frontend;
|
||||
uint8_t slot_index;
|
||||
uint32_t session_id;
|
||||
uint32_t slot_generation;
|
||||
uint8_t transport; /* Zero is SSH, including legacy designated initializers. */
|
||||
} admin_ssh_console_token_t;
|
||||
|
||||
typedef struct {
|
||||
bool (*binding_is_current)(const admin_ssh_console_token_t *token);
|
||||
bool (*transport_output_is_drained)(const admin_ssh_console_token_t *token);
|
||||
esp_err_t (*request_disconnect)(const admin_ssh_console_token_t *token,
|
||||
uint32_t argument);
|
||||
} admin_ssh_console_frontend_ops_t;
|
||||
|
||||
typedef enum {
|
||||
ADMIN_SSH_DEFER_NONE = 0,
|
||||
ADMIN_SSH_DEFER_REBOOT,
|
||||
ADMIN_SSH_DEFER_STOP,
|
||||
ADMIN_SSH_DEFER_DISCONNECT,
|
||||
ADMIN_SSH_DEFER_FRONTEND_DISCONNECT,
|
||||
ADMIN_SSH_DEFER_SSH_DISCONNECT,
|
||||
ADMIN_SSH_DEFER_HOST_KEY_ROTATE,
|
||||
ADMIN_SSH_DEFER_HOST_KEY_RESET,
|
||||
ADMIN_CONSOLE_DEFER_SELF_CLOSE,
|
||||
ADMIN_CONSOLE_DEFER_WEB_STOP,
|
||||
ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
|
||||
ADMIN_SSH_DEFER_WEB_STOP,
|
||||
ADMIN_SSH_DEFER_WEB_TLS_REFRESH_STOPPED,
|
||||
ADMIN_SSH_DEFER_WEB_TLS_REFRESH_RUNNING,
|
||||
} admin_ssh_deferred_action_type_t;
|
||||
|
||||
/* Small owner boundary; module/API names are retained for existing SSH callers.
|
||||
* Exactly two shared console slots, not two per transport. slot_index addresses
|
||||
* this pool; open_available atomically selects a free slot. Owners must not reuse an identity while
|
||||
* old work can exist. transport is a firmware-assigned namespace (0 = SSH).
|
||||
* An occupied or still-executing slot cannot be replaced by open_owned().
|
||||
*
|
||||
* The immutable adapter lives for firmware lifetime. Callbacks run on the
|
||||
* control task OUTSIDE console locks for drained/perform, except perform actions
|
||||
* selected by dispatcher_actions run serialized on the existing 12KiB dispatcher
|
||||
* after drain/delay and queued identity/principal revalidation (no command replay).
|
||||
* Zero dispatcher_actions preserves legacy control-task execution. Required is_current
|
||||
* runs on the dispatcher outside console locks; it must be bounded and validate
|
||||
* full transport identity, originating-session liveness and principal binding,
|
||||
* without calling socket libraries or handlers. Core separately checks accounts.
|
||||
* drained must be nonblocking, validate the full identity and include pending
|
||||
* owner output. perform must revalidate identity and marshal lifecycle work to
|
||||
* its owner, never call socket libraries here. Neither callback may call console
|
||||
* handlers. supported_actions is a bitmask (1U << action); reject unsupported
|
||||
* actions before side effects. Legacy STOP/DISCONNECT/key actions mean SSH;
|
||||
* SELF_CLOSE means this frontend; WEB_STOP means HTTPS, not SSH.
|
||||
* WEB_CERTIFICATE_ROTATE replaces the HTTPS identity and restarts HTTPS.
|
||||
* These WEB actions and SELF_CLOSE ignore argument.
|
||||
*
|
||||
* One owner serializes feed calls per session; different owners may feed in
|
||||
* parallel. Shared completion scratch is nonblocking/serialized by the core.
|
||||
* The owner alone consumes output, maintains authentication/session liveness,
|
||||
* and calls close on disconnect/revocation. Core copies/rechecks principals at
|
||||
* admission and dispatch. Dispatch and prompts also check owner currentness;
|
||||
* blocked prompts recheck every 250ms (plus check/scheduling latency). This does
|
||||
* not cancel or roll back arbitrary executing handlers. Admission remains the
|
||||
* owner's responsibility; is_current need not accept unpublished admission.
|
||||
* Close wakes prompts; executing state is retained until the handler returns.
|
||||
* Output remains bounded (5s write backpressure); deferred work waits at most
|
||||
* 10s for application drain plus 200ms, NOT peer-delivery confirmation.
|
||||
* Dispatcher actions then wait behind queued commands/prompts, with input gated
|
||||
* until completion or cancellation; the drain bound is not an execution deadline.
|
||||
* No new tasks, queues, slots, or browser endpoint are provided by this API.
|
||||
*/
|
||||
typedef struct {
|
||||
uint32_t supported_actions;
|
||||
uint32_t dispatcher_actions; /* Subset of supported_actions; immutable. */
|
||||
bool (*is_current)(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal);
|
||||
bool (*drained)(const admin_ssh_console_token_t *token);
|
||||
esp_err_t (*perform)(const admin_ssh_console_token_t *token,
|
||||
admin_ssh_deferred_action_type_t action, uint32_t argument);
|
||||
} admin_console_owner_t;
|
||||
|
||||
/* Selects any inactive, nonexecuting slot from the shared two-slot pool.
|
||||
* Input slot_index is ignored; only slot_index changes, and only on success.
|
||||
* Caller supplies transport/session_id/slot_generation and must retain the
|
||||
* returned token. Full pool returns ESP_ERR_INVALID_STATE, like open_owned.
|
||||
*/
|
||||
esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_console_owner_t *owner);
|
||||
|
||||
esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_console_owner_t *owner);
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
bool command_pending;
|
||||
bool input_pending;
|
||||
bool output_pending;
|
||||
bool deferred_action_pending;
|
||||
size_t input_length;
|
||||
size_t output_length;
|
||||
} admin_ssh_console_session_snapshot_t;
|
||||
@@ -121,38 +70,34 @@ esp_err_t admin_ssh_console_init(void);
|
||||
esp_err_t admin_ssh_console_register_commands(void);
|
||||
/* Called after all ESP-IDF commands are registered; starts the UART0 frontend. */
|
||||
esp_err_t admin_ssh_console_start_uart_frontend(void);
|
||||
bool admin_ssh_console_is_ready(void);
|
||||
|
||||
/* Valid only while a registered command callback runs on the dispatcher task. */
|
||||
bool admin_ssh_console_dispatch_is_remote(void);
|
||||
bool admin_ssh_console_dispatch_is_web(void);
|
||||
admin_ssh_console_frontend_t admin_ssh_console_dispatch_frontend(void);
|
||||
const user_principal_t *admin_ssh_console_dispatch_principal(void);
|
||||
/* Revalidate account, originating owner/session and token before side effects.
|
||||
* False outside the dispatcher; UART0 dispatch remains physically trusted. */
|
||||
bool admin_ssh_console_dispatch_is_current(void);
|
||||
/* Shared parsed browser account policy: dispatcher admission + handler defense. */
|
||||
bool admin_ssh_console_web_user_command_allowed(
|
||||
size_t argc, char **argv, const user_principal_t *principal);
|
||||
esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
const char *prompt, uint8_t *output, size_t capacity,
|
||||
bool hidden, size_t *output_length);
|
||||
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.
|
||||
/*
|
||||
* The token, principal, and ops table are copied. Callback code and any state it
|
||||
* references must have static lifetime; the console lock is not held during callbacks.
|
||||
*/
|
||||
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal);
|
||||
const user_principal_t *principal,
|
||||
const admin_ssh_console_frontend_ops_t *frontend_ops);
|
||||
void admin_ssh_console_close(const admin_ssh_console_token_t *token);
|
||||
|
||||
/* Called by the session owner. Returns false when input must be backpressured. */
|
||||
/* Called only by a frontend owner task. Returns false when input must be retried. */
|
||||
bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token);
|
||||
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
const uint8_t *data, size_t length,
|
||||
size_t *consumed);
|
||||
|
||||
/* Called by the session owner; copies already-produced output without blocking. */
|
||||
/* Called only by a frontend owner task; copies produced output without blocking. */
|
||||
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
|
||||
uint8_t *data, size_t capacity,
|
||||
size_t *received);
|
||||
|
||||
@@ -126,6 +126,8 @@ static const char *const s_completion_candidates[] = {
|
||||
"user status",
|
||||
"user list",
|
||||
"user show",
|
||||
"user bootstrap",
|
||||
"user bootstrap --generate",
|
||||
"user recover --force",
|
||||
"user add",
|
||||
"user delete",
|
||||
@@ -182,10 +184,10 @@ static const char *const s_completion_candidates[] = {
|
||||
"web stop",
|
||||
"web counters",
|
||||
"web clear-counters",
|
||||
"web diagnostics enable",
|
||||
"web diagnostics disable",
|
||||
"web diagnostics show",
|
||||
"web diagnostics clear",
|
||||
"web credentials",
|
||||
"web credentials show",
|
||||
"web credentials rotate",
|
||||
"web credentials rotate --force",
|
||||
"web certificate",
|
||||
"web certificate info",
|
||||
"web certificate rotate",
|
||||
|
||||
+15
-1
@@ -8,6 +8,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_command_gate.h"
|
||||
#include "esp_console.h"
|
||||
#include "local_display.h"
|
||||
#include "local_status_ui.h"
|
||||
@@ -113,7 +114,7 @@ static int apply_parameter(const char *parameter, const char *text)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_display(int argc, char **argv)
|
||||
static int command_display_inner(int argc, char **argv)
|
||||
{
|
||||
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0)) {
|
||||
return show_status();
|
||||
@@ -189,6 +190,19 @@ static int command_display(int argc, char **argv)
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int command_display(int argc, char **argv)
|
||||
{
|
||||
esp_err_t error = admin_command_gate_take();
|
||||
if (error != ESP_OK) {
|
||||
printf("Display administration unavailable: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
int result = command_display_inner(argc, argv);
|
||||
admin_command_gate_give();
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t local_ui_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
|
||||
+27
-10
@@ -1,3 +1,5 @@
|
||||
#include <string.h>
|
||||
|
||||
#include "driver/uart.h"
|
||||
#include "admin_ssh_console.h"
|
||||
#include "console_completion.h"
|
||||
@@ -130,16 +132,29 @@ void app_main(void)
|
||||
"HTTPS security material unavailable (%s); use UART0 'web reset --force' to replace it",
|
||||
esp_err_to_name(web_security_error));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Using %s HTTPS identity",
|
||||
web_security_source == WEB_SECURITY_LOAD_STORED
|
||||
? "stored"
|
||||
: (web_security_source == WEB_SECURITY_LOAD_MIGRATED_V1
|
||||
? "migrated v1"
|
||||
: "newly generated"));
|
||||
ESP_LOGI(TAG, "Using %s HTTPS identity and legacy recovery credential",
|
||||
web_security_source == WEB_SECURITY_LOAD_STORED ? "stored" : "newly generated");
|
||||
}
|
||||
|
||||
user_database_load_result_t user_database_source = USER_DATABASE_LOAD_EMPTY;
|
||||
esp_err_t user_database_error = user_database_init(&user_database_source);
|
||||
web_security_credentials_t legacy_credentials;
|
||||
memset(&legacy_credentials, 0, sizeof(legacy_credentials));
|
||||
user_database_legacy_credentials_t legacy = {0};
|
||||
const user_database_legacy_credentials_t *legacy_pointer = NULL;
|
||||
if (web_security_error == ESP_OK &&
|
||||
web_security_show_credentials(&legacy_credentials) == ESP_OK) {
|
||||
legacy = (user_database_legacy_credentials_t){
|
||||
.username = (const uint8_t *)legacy_credentials.username,
|
||||
.username_length = legacy_credentials.username_length,
|
||||
.password = (const uint8_t *)legacy_credentials.password,
|
||||
.password_length = legacy_credentials.password_length,
|
||||
};
|
||||
legacy_pointer = &legacy;
|
||||
}
|
||||
esp_err_t user_database_error =
|
||||
user_database_init(legacy_pointer, &user_database_source);
|
||||
secure_wipe(&legacy_credentials, sizeof(legacy_credentials));
|
||||
secure_wipe(&legacy, sizeof(legacy));
|
||||
if (user_database_error != ESP_OK) {
|
||||
ESP_LOGE(TAG, "User database unavailable: %s; HTTPS and SSH authentication will fail closed; use UART0 'user recover --force'",
|
||||
esp_err_to_name(user_database_error));
|
||||
@@ -147,7 +162,9 @@ void app_main(void)
|
||||
ESP_LOGI(TAG, "Using %s user database",
|
||||
user_database_source == USER_DATABASE_LOAD_STORED
|
||||
? "stored"
|
||||
: "new empty");
|
||||
: (user_database_source == USER_DATABASE_LOAD_MIGRATED_LEGACY
|
||||
? "newly migrated user-level"
|
||||
: "new empty"));
|
||||
}
|
||||
|
||||
esp_err_t web_runtime_error = web_server_init();
|
||||
@@ -250,8 +267,8 @@ void app_main(void)
|
||||
ESP_LOGI(TAG, "Authenticated HTTPS listening on TCP port 443");
|
||||
}
|
||||
}
|
||||
if (wifi_error == ESP_OK && ssh_security_error == ESP_OK &&
|
||||
ssh_runtime_error == ESP_OK) {
|
||||
if (wifi_error == ESP_OK && web_security_error == ESP_OK &&
|
||||
ssh_security_error == ESP_OK && ssh_runtime_error == ESP_OK) {
|
||||
esp_err_t start_error = ssh_transport_start();
|
||||
if (start_error != ESP_OK) {
|
||||
ESP_LOGE(TAG, "SSH startup failed: %s; UART0 recovery remains available",
|
||||
|
||||
+6
-51
@@ -12,7 +12,6 @@
|
||||
|
||||
static SemaphoreHandle_t s_mutex;
|
||||
static mdns_config_t s_config;
|
||||
static uint32_t s_config_generation;
|
||||
static bool s_component_initialized;
|
||||
static bool s_initialization_failed;
|
||||
static bool s_announced;
|
||||
@@ -46,7 +45,6 @@ esp_err_t mdns_service_init(const mdns_config_t *config)
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
s_config = *config;
|
||||
s_config_generation = 1;
|
||||
s_last_error = ESP_OK;
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -68,68 +66,27 @@ esp_err_t mdns_service_set_config(const mdns_config_t *config)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
lock_service();
|
||||
if (s_config_generation == UINT32_MAX) { unlock_service(); return ESP_ERR_INVALID_STATE; }
|
||||
s_config = *config;
|
||||
++s_config_generation;
|
||||
unlock_service();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void snapshot_locked(mdns_service_snapshot_t *snapshot)
|
||||
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL || s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
lock_service();
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
snapshot->config_generation = s_config_generation;
|
||||
snapshot->initialized = true;
|
||||
snapshot->announced = s_announced;
|
||||
memcpy(snapshot->suffix, s_config.suffix, s_config.suffix_len);
|
||||
make_hostname(&s_config, snapshot->hostname, sizeof(snapshot->hostname));
|
||||
snapshot->last_error = s_last_error;
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot || !s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
lock_service();
|
||||
snapshot_locked(snapshot);
|
||||
unlock_service();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot) return ESP_ERR_INVALID_ARG;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
snapshot_locked(snapshot);
|
||||
unlock_service();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
|
||||
const mdns_config_t *config, bool *stored)
|
||||
{
|
||||
if (!stored || action > MDNS_SETTINGS_DEFAULTS || action < MDNS_SETTINGS_SET ||
|
||||
(action == MDNS_SETTINGS_SET && mdns_config_validate(config) != ESP_OK)) return ESP_ERR_INVALID_ARG;
|
||||
*stored = true;
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
lock_service();
|
||||
if (!generation || generation != s_config_generation) { unlock_service(); return ESP_ERR_NOT_FOUND; }
|
||||
esp_err_t error = ESP_OK;
|
||||
mdns_config_t candidate = s_config;
|
||||
if (action == MDNS_SETTINGS_SAVE) error = mdns_config_save(&s_config);
|
||||
else if (s_config_generation == UINT32_MAX) error = ESP_ERR_INVALID_STATE;
|
||||
else {
|
||||
if (action == MDNS_SETTINGS_SET) candidate = *config;
|
||||
else if (action == MDNS_SETTINGS_LOAD) error = mdns_config_load(&candidate, stored);
|
||||
else mdns_config_defaults(&candidate);
|
||||
if (error == ESP_OK) error = mdns_config_validate(&candidate);
|
||||
if (error == ESP_OK) { s_config = candidate; ++s_config_generation; }
|
||||
}
|
||||
unlock_service();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_start(void)
|
||||
{
|
||||
if (s_mutex == NULL) {
|
||||
@@ -139,9 +96,7 @@ esp_err_t mdns_service_start(void)
|
||||
if (s_component_initialized) {
|
||||
s_announced = true;
|
||||
unlock_service();
|
||||
/* A suffix staged while offline must reach the already-created responder
|
||||
* when the next STA IP arrives, even if its reannounce command ran offline. */
|
||||
return mdns_service_reannounce();
|
||||
return ESP_OK;
|
||||
}
|
||||
if (s_initialization_failed) {
|
||||
esp_err_t error = s_last_error;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
#include "mdns_config.h"
|
||||
|
||||
typedef struct {
|
||||
uint32_t config_generation;
|
||||
bool initialized;
|
||||
bool announced;
|
||||
char suffix[MDNS_CONFIG_SUFFIX_MAX_LEN + 1U];
|
||||
@@ -22,16 +21,6 @@ esp_err_t mdns_service_get_config(mdns_config_t *config);
|
||||
esp_err_t mdns_service_set_config(const mdns_config_t *config);
|
||||
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot);
|
||||
|
||||
/* Zero-wait secret-free projection for HTTPD; ESP_ERR_TIMEOUT on contention. */
|
||||
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *snapshot);
|
||||
typedef enum { MDNS_SETTINGS_SET, MDNS_SETTINGS_SAVE, MDNS_SETTINGS_LOAD,
|
||||
MDNS_SETTINGS_DEFAULTS } mdns_settings_action_t;
|
||||
/* Dispatcher-only. Check generation and mutate/persist under the service mutex.
|
||||
* ESP_ERR_NOT_FOUND is stale. LOAD may select deterministic MAC defaults (stored
|
||||
* reports that distinction). Caller separately queues manager reannouncement. */
|
||||
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
|
||||
const mdns_config_t *config, bool *stored);
|
||||
|
||||
/* Only wifi_manager may call these lifecycle operations. */
|
||||
esp_err_t mdns_service_start(void);
|
||||
void mdns_service_stop(void);
|
||||
|
||||
+35
-1
@@ -6,6 +6,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_command_gate.h"
|
||||
#include "esp_console.h"
|
||||
#include "esp_err.h"
|
||||
#include "rs232_port_owner.h"
|
||||
@@ -191,7 +192,7 @@ static int set_parameter(const char *parameter, const char *value)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_serial(int argc, char **argv)
|
||||
static int command_serial_impl(int argc, char **argv)
|
||||
{
|
||||
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0)) {
|
||||
return show_status();
|
||||
@@ -290,6 +291,39 @@ static int command_serial(int argc, char **argv)
|
||||
return 1;
|
||||
}
|
||||
|
||||
static bool command_serial_requires_gate(int argc, char **argv)
|
||||
{
|
||||
if (argc == 4 && strcmp(argv[1], "set") == 0) {
|
||||
return true;
|
||||
}
|
||||
if (argc != 2) {
|
||||
return false;
|
||||
}
|
||||
return strcmp(argv[1], "start") == 0 ||
|
||||
strcmp(argv[1], "stop") == 0 ||
|
||||
strcmp(argv[1], "save") == 0 ||
|
||||
strcmp(argv[1], "load") == 0 ||
|
||||
strcmp(argv[1], "defaults") == 0 ||
|
||||
strcmp(argv[1], "reset") == 0;
|
||||
}
|
||||
|
||||
static int command_serial(int argc, char **argv)
|
||||
{
|
||||
if (!command_serial_requires_gate(argc, argv)) {
|
||||
return command_serial_impl(argc, argv);
|
||||
}
|
||||
|
||||
esp_err_t error = admin_command_gate_take();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not serialize serial administration: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
int result = command_serial_impl(argc, argv);
|
||||
admin_command_gate_give();
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t serial_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
|
||||
@@ -646,18 +646,6 @@ esp_err_t serial_service_get_config(serial_config_t *config)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t serial_service_get_snapshot(serial_service_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) return ESP_ERR_INVALID_ARG;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
if (!s_initialized) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_state_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
snapshot->config = s_config;
|
||||
snapshot->running = atomic_load(&s_running);
|
||||
xSemaphoreGive(s_state_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
size_t serial_service_read(uint8_t *data, size_t size)
|
||||
{
|
||||
if (!s_initialized || data == NULL || size == 0) {
|
||||
|
||||
@@ -48,14 +48,6 @@ bool serial_service_is_running(void);
|
||||
esp_err_t serial_service_apply_config(const serial_config_t *config);
|
||||
esp_err_t serial_service_get_config(serial_config_t *config);
|
||||
|
||||
typedef struct {
|
||||
serial_config_t config;
|
||||
bool running;
|
||||
} serial_service_snapshot_t;
|
||||
|
||||
/* Nonblocking, consistent working configuration/state; no hardware or NVS IO. */
|
||||
esp_err_t serial_service_get_snapshot(serial_service_snapshot_t *snapshot);
|
||||
|
||||
/*
|
||||
* Access is intentionally nonblocking. The session broker is the sole
|
||||
* logical RX consumer and TX producer; calls are serialized internally to
|
||||
|
||||
@@ -518,6 +518,60 @@ esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_compare_exchange_writer(
|
||||
session_broker_client_id_t expected_writer_id,
|
||||
session_broker_client_id_t target_client_id)
|
||||
{
|
||||
if (!s_initialized) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (target_client_id == SESSION_BROKER_NO_CLIENT) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
if (s_writer_id != expected_writer_id) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
session_broker_slot_t *new_writer = find_slot_locked(target_client_id);
|
||||
if (new_writer == NULL) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
if (expected_writer_id == target_client_id) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
session_broker_slot_t *old_writer =
|
||||
find_slot_locked(expected_writer_id);
|
||||
if (expected_writer_id != SESSION_BROKER_NO_CLIENT && old_writer == NULL) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
s_writer_id = target_client_id;
|
||||
if (old_writer != NULL) {
|
||||
++old_writer->counters.writer_revocations;
|
||||
++old_writer->counters.writer_changes;
|
||||
++s_counters.writer_revocations;
|
||||
}
|
||||
++new_writer->counters.writer_grants;
|
||||
++new_writer->counters.writer_changes;
|
||||
++s_counters.writer_grants;
|
||||
++s_counters.writer_changes;
|
||||
|
||||
if (old_writer != NULL) {
|
||||
broadcast_event_locked(SESSION_BROKER_EVENT_WRITER_REVOKED,
|
||||
expected_writer_id, s_writer_id);
|
||||
}
|
||||
broadcast_event_locked(SESSION_BROKER_EVENT_WRITER_GRANTED,
|
||||
target_client_id, s_writer_id);
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_force_release_writer(
|
||||
session_broker_client_id_t expected_writer_id)
|
||||
{
|
||||
|
||||
@@ -130,6 +130,14 @@ esp_err_t session_broker_disconnect(session_broker_client_id_t client_id);
|
||||
esp_err_t session_broker_request_writer(session_broker_client_id_t client_id);
|
||||
esp_err_t session_broker_release_writer(session_broker_client_id_t client_id);
|
||||
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id);
|
||||
/*
|
||||
* Atomically replace exactly the expected current writer with a connected target.
|
||||
* expected_writer_id may be zero; target_client_id must identify a live client.
|
||||
* A changed current writer returns ESP_ERR_INVALID_STATE without altering the lease.
|
||||
*/
|
||||
esp_err_t session_broker_compare_exchange_writer(
|
||||
session_broker_client_id_t expected_writer_id,
|
||||
session_broker_client_id_t target_client_id);
|
||||
/* Revoke only if the expected client still owns the writer lease. */
|
||||
esp_err_t session_broker_force_release_writer(session_broker_client_id_t expected_writer_id);
|
||||
session_broker_client_id_t session_broker_get_writer_id(void);
|
||||
|
||||
+1
-1
@@ -324,7 +324,7 @@ static int command_ssh(int argc, char **argv)
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = admin_ssh_console_dispatch_defer(
|
||||
ADMIN_SSH_DEFER_DISCONNECT, session_id);
|
||||
ADMIN_SSH_DEFER_SSH_DISCONNECT, session_id);
|
||||
}
|
||||
} else {
|
||||
error = ssh_transport_disconnect(session_id);
|
||||
|
||||
+71
-128
@@ -12,7 +12,6 @@
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
@@ -50,6 +49,9 @@
|
||||
#define SSH_TRANSPORT_GENERATION_MAX 0x3fffffffU
|
||||
#define SSH_TRANSPORT_WOLFSSH_READ_BUDGET 2048U
|
||||
|
||||
_Static_assert(SSH_TRANSPORT_MAX_SESSIONS == ADMIN_SSH_CONSOLE_SSH_SLOT_COUNT,
|
||||
"SSH admin slots must retain global indexes 0 and 1");
|
||||
|
||||
static const char *TAG = "ssh_transport";
|
||||
|
||||
typedef struct {
|
||||
@@ -66,7 +68,6 @@ typedef struct {
|
||||
bool pending_principal_valid;
|
||||
bool authenticated;
|
||||
bool shell_requested;
|
||||
uint8_t console_slot_index;
|
||||
uint8_t authentication_attempts;
|
||||
word32 io_read_budget;
|
||||
bool writer;
|
||||
@@ -88,9 +89,6 @@ static ssh_slot_t s_slots[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static ssh_transport_session_snapshot_t
|
||||
s_session_snapshots[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static uint32_t s_external_close_id[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
/* Published with snapshots; dispatcher never reads owner-task slot storage. */
|
||||
static user_principal_t s_console_principals[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static uint8_t s_console_slot_indices[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static ssh_transport_counters_t s_counters;
|
||||
static SemaphoreHandle_t s_command_mutex;
|
||||
static bool s_initializing;
|
||||
@@ -136,14 +134,78 @@ static void notify_task(void)
|
||||
static admin_ssh_console_token_t admin_console_token(const ssh_slot_t *slot,
|
||||
size_t slot_index)
|
||||
{
|
||||
(void)slot_index; /* Physical SSH index is not the shared console index. */
|
||||
return (admin_ssh_console_token_t){
|
||||
.slot_index = slot->console_slot_index,
|
||||
.frontend = ADMIN_SSH_CONSOLE_FRONTEND_SSH,
|
||||
.slot_index = (uint8_t)slot_index,
|
||||
.session_id = slot->session_id,
|
||||
.slot_generation = slot->generation,
|
||||
};
|
||||
}
|
||||
|
||||
static bool admin_console_token_is_ssh(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
return token != NULL && token->frontend == ADMIN_SSH_CONSOLE_FRONTEND_SSH &&
|
||||
token->slot_index < SSH_TRANSPORT_MAX_SESSIONS &&
|
||||
token->session_id != 0U && token->slot_generation != 0U;
|
||||
}
|
||||
|
||||
static bool admin_console_snapshot_matches_locked(
|
||||
const admin_ssh_console_token_t *token)
|
||||
{
|
||||
const ssh_transport_session_snapshot_t *snapshot =
|
||||
&s_session_snapshots[token->slot_index];
|
||||
return s_initialized && snapshot->active &&
|
||||
snapshot->session_id == token->session_id &&
|
||||
snapshot->generation == token->slot_generation &&
|
||||
snapshot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
|
||||
}
|
||||
|
||||
static bool admin_console_binding_is_current(
|
||||
const admin_ssh_console_token_t *token)
|
||||
{
|
||||
if (!admin_console_token_is_ssh(token)) {
|
||||
return false;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
const ssh_transport_session_snapshot_t *snapshot =
|
||||
&s_session_snapshots[token->slot_index];
|
||||
bool current = admin_console_snapshot_matches_locked(token) &&
|
||||
snapshot->state == SSH_TRANSPORT_SESSION_ACTIVE &&
|
||||
snapshot->authenticated && snapshot->principal_valid &&
|
||||
!snapshot->close_requested &&
|
||||
s_external_close_id[token->slot_index] != token->session_id;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return current;
|
||||
}
|
||||
|
||||
static bool admin_console_transport_output_is_drained(
|
||||
const admin_ssh_console_token_t *token)
|
||||
{
|
||||
if (!admin_console_token_is_ssh(token)) {
|
||||
return false;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool drained = admin_console_snapshot_matches_locked(token) &&
|
||||
!s_session_snapshots[token->slot_index].tx_pending;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return drained;
|
||||
}
|
||||
|
||||
static esp_err_t admin_console_request_disconnect(
|
||||
const admin_ssh_console_token_t *token, uint32_t session_id)
|
||||
{
|
||||
if (!admin_console_token_is_ssh(token)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
return ssh_transport_disconnect(session_id);
|
||||
}
|
||||
|
||||
static const admin_ssh_console_frontend_ops_t s_admin_console_frontend_ops = {
|
||||
.binding_is_current = admin_console_binding_is_current,
|
||||
.transport_output_is_drained = admin_console_transport_output_is_drained,
|
||||
.request_disconnect = admin_console_request_disconnect,
|
||||
};
|
||||
|
||||
static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
|
||||
{
|
||||
ssh_transport_session_snapshot_t snapshot = {
|
||||
@@ -182,132 +244,14 @@ static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_session_snapshots[slot_index] = snapshot;
|
||||
s_console_slot_indices[slot_index] =
|
||||
snapshot.active && slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE
|
||||
? slot->console_slot_index : UINT8_MAX;
|
||||
if (slot->principal_valid) {
|
||||
s_console_principals[slot_index] = slot->principal;
|
||||
} else {
|
||||
secure_wipe(&s_console_principals[slot_index], sizeof(user_principal_t));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
/* Caller holds s_lock. Match session identity first, then the assigned console
|
||||
* binding; callbacks must never index physical SSH storage by console slot.
|
||||
*/
|
||||
static size_t admin_console_snapshot_index_locked(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
if (token == NULL || token->transport != ADMIN_CONSOLE_TRANSPORT_SSH ||
|
||||
token->session_id == 0U || token->slot_generation == 0U) {
|
||||
return SSH_TRANSPORT_MAX_SESSIONS;
|
||||
}
|
||||
for (size_t i = 0U; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
|
||||
const ssh_transport_session_snapshot_t *slot = &s_session_snapshots[i];
|
||||
if (slot->active && slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE &&
|
||||
slot->session_id == token->session_id &&
|
||||
slot->generation == token->slot_generation &&
|
||||
s_console_slot_indices[i] != UINT8_MAX &&
|
||||
s_console_slot_indices[i] == token->slot_index) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return SSH_TRANSPORT_MAX_SESSIONS;
|
||||
}
|
||||
|
||||
/* Dispatcher/control adapters: published state only, no runtime wolfSSH calls. */
|
||||
static bool admin_console_is_current(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
if (principal == NULL) {
|
||||
return false;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
size_t index = admin_console_snapshot_index_locked(token);
|
||||
if (index == SSH_TRANSPORT_MAX_SESSIONS) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return false;
|
||||
}
|
||||
const ssh_transport_session_snapshot_t *slot = &s_session_snapshots[index];
|
||||
const user_principal_t *bound = &s_console_principals[index];
|
||||
bool current = slot->active && slot->authenticated && slot->principal_valid &&
|
||||
slot->state == SSH_TRANSPORT_SESSION_ACTIVE &&
|
||||
slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE && !slot->close_requested &&
|
||||
s_external_close_id[index] != token->session_id &&
|
||||
slot->session_id == token->session_id && slot->generation == token->slot_generation &&
|
||||
bound->user_id == principal->user_id && bound->auth_generation == principal->auth_generation &&
|
||||
bound->role == USER_ROLE_ADMIN && bound->role == principal->role &&
|
||||
bound->method == principal->method && bound->username_length == principal->username_length &&
|
||||
bound->username_length <= USER_DATABASE_USERNAME_CAPACITY &&
|
||||
memcmp(bound->username, principal->username, bound->username_length) == 0;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return current;
|
||||
}
|
||||
|
||||
static bool admin_console_drained(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
size_t index = admin_console_snapshot_index_locked(token);
|
||||
bool drained = index < SSH_TRANSPORT_MAX_SESSIONS &&
|
||||
!s_session_snapshots[index].tx_pending;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return drained;
|
||||
}
|
||||
|
||||
static esp_err_t admin_console_perform(const admin_ssh_console_token_t *token,
|
||||
admin_ssh_deferred_action_type_t action,
|
||||
uint32_t argument)
|
||||
{
|
||||
if (!admin_console_drained(token)) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
switch (action) {
|
||||
case ADMIN_SSH_DEFER_REBOOT:
|
||||
esp_restart();
|
||||
return ESP_OK;
|
||||
case ADMIN_SSH_DEFER_STOP:
|
||||
return ssh_transport_stop();
|
||||
case ADMIN_CONSOLE_DEFER_SELF_CLOSE:
|
||||
return ssh_transport_disconnect(token->session_id);
|
||||
case ADMIN_SSH_DEFER_DISCONNECT:
|
||||
return ssh_transport_disconnect(argument);
|
||||
case ADMIN_SSH_DEFER_HOST_KEY_ROTATE:
|
||||
return ssh_transport_replace_host_key(false);
|
||||
case ADMIN_SSH_DEFER_HOST_KEY_RESET:
|
||||
return ssh_transport_replace_host_key(true);
|
||||
default:
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
}
|
||||
|
||||
static const admin_console_owner_t s_admin_console_owner = {
|
||||
.supported_actions = (1U << ADMIN_SSH_DEFER_REBOOT) |
|
||||
(1U << ADMIN_SSH_DEFER_STOP) | (1U << ADMIN_SSH_DEFER_DISCONNECT) |
|
||||
(1U << ADMIN_SSH_DEFER_HOST_KEY_ROTATE) |
|
||||
(1U << ADMIN_SSH_DEFER_HOST_KEY_RESET) | (1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE),
|
||||
.drained = admin_console_drained,
|
||||
.is_current = admin_console_is_current,
|
||||
.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);
|
||||
bool requested = s_external_close_id[slot_index] != 0U &&
|
||||
s_external_close_id[slot_index] == slot->session_id;
|
||||
if (requested) {
|
||||
/* Keep close intent visible while the owner begins cleanup. */
|
||||
s_session_snapshots[slot_index].close_requested = true;
|
||||
}
|
||||
if (requested || slot->state == SSH_TRANSPORT_SESSION_FREE) {
|
||||
s_external_close_id[slot_index] = 0U;
|
||||
}
|
||||
@@ -1048,14 +992,13 @@ static void process_handshake(ssh_slot_t *slot, size_t slot_index)
|
||||
slot->route = SSH_TRANSPORT_ROUTE_BROKER;
|
||||
} else if (slot->principal.role == USER_ROLE_ADMIN) {
|
||||
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
|
||||
error = admin_ssh_console_open_available(&token, &slot->principal,
|
||||
&s_admin_console_owner);
|
||||
error = admin_ssh_console_open(
|
||||
&token, &slot->principal, &s_admin_console_frontend_ops);
|
||||
if (error != ESP_OK) {
|
||||
add_counter(&s_counters.admin_console_admission_failures, 1U);
|
||||
request_slot_close(slot, false);
|
||||
return;
|
||||
}
|
||||
slot->console_slot_index = token.slot_index;
|
||||
slot->route = SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
|
||||
add_counter(&s_counters.admin_console_admissions, 1U);
|
||||
} else {
|
||||
|
||||
@@ -54,7 +54,7 @@ static int command_reboot(int argc, char **argv)
|
||||
printf("Could not schedule reboot: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("Reboot scheduled after console output drains; unsaved changes will be lost.\n");
|
||||
printf("Reboot scheduled after SSH output drains; unsaved changes will be lost.\n");
|
||||
return 0;
|
||||
}
|
||||
printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n");
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Typed, serialized user-administration mutations with transport revocation. */
|
||||
|
||||
#include "user_admin_service.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_command_gate.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "web_server.h"
|
||||
|
||||
typedef enum {
|
||||
USER_ADMIN_OPERATION_CREATE = 0,
|
||||
USER_ADMIN_OPERATION_CREATE_GENERATED,
|
||||
USER_ADMIN_OPERATION_DELETE,
|
||||
USER_ADMIN_OPERATION_SET_ROLE,
|
||||
USER_ADMIN_OPERATION_SET_PASSWORD,
|
||||
USER_ADMIN_OPERATION_GENERATE_PASSWORD,
|
||||
USER_ADMIN_OPERATION_ADD_SSH_KEY,
|
||||
USER_ADMIN_OPERATION_REMOVE_SSH_KEY,
|
||||
USER_ADMIN_OPERATION_CLEAR_SSH_KEYS,
|
||||
} user_admin_operation_type_t;
|
||||
|
||||
typedef struct {
|
||||
user_admin_operation_type_t type;
|
||||
const uint8_t *username;
|
||||
size_t username_length;
|
||||
user_role_t role;
|
||||
const uint8_t *password;
|
||||
size_t password_length;
|
||||
const uint8_t *key_type;
|
||||
size_t key_type_length;
|
||||
const uint8_t *key_blob;
|
||||
size_t key_blob_length;
|
||||
uint8_t key_index;
|
||||
uint8_t *added_key_index;
|
||||
user_database_generated_password_t *generated_password;
|
||||
} user_admin_operation_t;
|
||||
|
||||
/* Every access is protected by admin_command_gate. Keep this large snapshot off task stacks. */
|
||||
static user_database_snapshot_t s_snapshot;
|
||||
|
||||
static const user_database_user_snapshot_t *find_snapshot_user(
|
||||
const user_admin_operation_t *operation)
|
||||
{
|
||||
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) {
|
||||
const user_database_user_snapshot_t *user = &s_snapshot.users[index];
|
||||
if (user->active && user->username_length == operation->username_length &&
|
||||
memcmp(user->username, operation->username, operation->username_length) == 0) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool role_valid(user_role_t role)
|
||||
{
|
||||
return role == USER_ROLE_USER || role == USER_ROLE_ADMIN;
|
||||
}
|
||||
|
||||
static bool operation_arguments_valid(const user_admin_operation_t *operation)
|
||||
{
|
||||
if (operation == NULL ||
|
||||
!user_database_username_valid(operation->username, operation->username_length)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operation->type) {
|
||||
case USER_ADMIN_OPERATION_CREATE:
|
||||
return role_valid(operation->role) &&
|
||||
user_database_password_valid(operation->password,
|
||||
operation->password_length);
|
||||
case USER_ADMIN_OPERATION_CREATE_GENERATED:
|
||||
return role_valid(operation->role) && operation->generated_password != NULL;
|
||||
case USER_ADMIN_OPERATION_DELETE:
|
||||
return true;
|
||||
case USER_ADMIN_OPERATION_SET_ROLE:
|
||||
return role_valid(operation->role);
|
||||
case USER_ADMIN_OPERATION_SET_PASSWORD:
|
||||
return user_database_password_valid(operation->password,
|
||||
operation->password_length);
|
||||
case USER_ADMIN_OPERATION_GENERATE_PASSWORD:
|
||||
return operation->generated_password != NULL;
|
||||
case USER_ADMIN_OPERATION_ADD_SSH_KEY:
|
||||
return operation->added_key_index != NULL;
|
||||
case USER_ADMIN_OPERATION_REMOVE_SSH_KEY:
|
||||
return operation->key_index < USER_DATABASE_MAX_SSH_KEYS_PER_USER;
|
||||
case USER_ADMIN_OPERATION_CLEAR_SSH_KEYS:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool operation_expected_to_commit(
|
||||
const user_admin_operation_t *operation,
|
||||
const user_database_user_snapshot_t *user)
|
||||
{
|
||||
if (operation->type == USER_ADMIN_OPERATION_SET_ROLE && user != NULL) {
|
||||
return user->role != operation->role;
|
||||
}
|
||||
if (operation->type == USER_ADMIN_OPERATION_CLEAR_SSH_KEYS && user != NULL) {
|
||||
return user->public_key_count != 0U;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static esp_err_t apply_database_operation(const user_admin_operation_t *operation)
|
||||
{
|
||||
switch (operation->type) {
|
||||
case USER_ADMIN_OPERATION_CREATE:
|
||||
return user_database_create(
|
||||
operation->username, operation->username_length, operation->role,
|
||||
operation->password, operation->password_length);
|
||||
case USER_ADMIN_OPERATION_CREATE_GENERATED:
|
||||
return user_database_create_generated(
|
||||
operation->username, operation->username_length, operation->role,
|
||||
operation->generated_password);
|
||||
case USER_ADMIN_OPERATION_DELETE:
|
||||
return user_database_delete(operation->username, operation->username_length);
|
||||
case USER_ADMIN_OPERATION_SET_ROLE:
|
||||
return user_database_set_role(operation->username, operation->username_length,
|
||||
operation->role);
|
||||
case USER_ADMIN_OPERATION_SET_PASSWORD:
|
||||
return user_database_set_password(
|
||||
operation->username, operation->username_length,
|
||||
operation->password, operation->password_length);
|
||||
case USER_ADMIN_OPERATION_GENERATE_PASSWORD:
|
||||
return user_database_generate_password(
|
||||
operation->username, operation->username_length,
|
||||
operation->generated_password);
|
||||
case USER_ADMIN_OPERATION_ADD_SSH_KEY:
|
||||
return user_database_add_ssh_key(
|
||||
operation->username, operation->username_length,
|
||||
operation->key_type, operation->key_type_length,
|
||||
operation->key_blob, operation->key_blob_length,
|
||||
operation->added_key_index);
|
||||
case USER_ADMIN_OPERATION_REMOVE_SSH_KEY:
|
||||
return user_database_remove_ssh_key(
|
||||
operation->username, operation->username_length, operation->key_index);
|
||||
case USER_ADMIN_OPERATION_CLEAR_SSH_KEYS:
|
||||
return user_database_clear_ssh_keys(
|
||||
operation->username, operation->username_length);
|
||||
default:
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
}
|
||||
|
||||
static void revoke_committed_user(const user_admin_operation_t *operation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
result->revocation.attempted = true;
|
||||
result->revocation.web_error = web_server_revoke_user(
|
||||
operation->username, operation->username_length);
|
||||
result->revocation.ssh_error = ssh_transport_revoke_user(
|
||||
operation->username, operation->username_length);
|
||||
}
|
||||
|
||||
static bool operation_is_create(const user_admin_operation_t *operation)
|
||||
{
|
||||
return operation->type == USER_ADMIN_OPERATION_CREATE ||
|
||||
operation->type == USER_ADMIN_OPERATION_CREATE_GENERATED;
|
||||
}
|
||||
|
||||
static bool operation_advances_auth_generation(
|
||||
const user_admin_operation_t *operation)
|
||||
{
|
||||
return operation->type == USER_ADMIN_OPERATION_SET_ROLE ||
|
||||
operation->type == USER_ADMIN_OPERATION_SET_PASSWORD ||
|
||||
operation->type == USER_ADMIN_OPERATION_GENERATE_PASSWORD ||
|
||||
operation->type == USER_ADMIN_OPERATION_ADD_SSH_KEY ||
|
||||
operation->type == USER_ADMIN_OPERATION_REMOVE_SSH_KEY ||
|
||||
operation->type == USER_ADMIN_OPERATION_CLEAR_SSH_KEYS;
|
||||
}
|
||||
|
||||
static user_admin_service_failure_t classify_failure(
|
||||
const user_admin_operation_t *operation,
|
||||
const user_database_user_snapshot_t *user, esp_err_t error)
|
||||
{
|
||||
if (error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_DUPLICATE_KEY;
|
||||
}
|
||||
if (error == ESP_ERR_NO_MEM) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_CAPACITY;
|
||||
}
|
||||
if (error != ESP_ERR_INVALID_STATE) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_NONE;
|
||||
}
|
||||
if (operation_is_create(operation) && user != NULL) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_DUPLICATE_USERNAME;
|
||||
}
|
||||
if (user != NULL && user->role == USER_ROLE_ADMIN &&
|
||||
s_snapshot.admin_count <= 1U &&
|
||||
(operation->type == USER_ADMIN_OPERATION_DELETE ||
|
||||
(operation->type == USER_ADMIN_OPERATION_SET_ROLE &&
|
||||
operation->role != USER_ROLE_ADMIN))) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_FINAL_ADMIN;
|
||||
}
|
||||
if (s_snapshot.generation == UINT32_MAX ||
|
||||
(user != NULL && operation_advances_auth_generation(operation) &&
|
||||
user->auth_generation == UINT32_MAX)) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_GENERATION_EXHAUSTED;
|
||||
}
|
||||
return USER_ADMIN_SERVICE_FAILURE_STATE;
|
||||
}
|
||||
|
||||
static esp_err_t execute_operation(
|
||||
const user_admin_operation_t *operation,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
if (result == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
memset(result, 0, sizeof(*result));
|
||||
if (!operation_arguments_valid(operation) ||
|
||||
(operation_is_create(operation) && expectation != NULL &&
|
||||
expectation->user_id != 0U)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (operation->added_key_index != NULL) {
|
||||
*operation->added_key_index = 0U;
|
||||
}
|
||||
|
||||
esp_err_t error = admin_command_gate_take();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
error = user_database_get_snapshot(&s_snapshot);
|
||||
if (error != ESP_OK) {
|
||||
admin_command_gate_give();
|
||||
return error;
|
||||
}
|
||||
|
||||
const uint32_t before_generation = s_snapshot.generation;
|
||||
const user_database_user_snapshot_t *user = find_snapshot_user(operation);
|
||||
result->database_generation = before_generation;
|
||||
if (user != NULL) {
|
||||
result->user_id = user->user_id;
|
||||
}
|
||||
|
||||
if (expectation != NULL && expectation->database_generation != 0U &&
|
||||
expectation->database_generation != before_generation) {
|
||||
result->conflict = USER_ADMIN_SERVICE_CONFLICT_DATABASE_GENERATION;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
} else if (!operation_is_create(operation) && expectation != NULL &&
|
||||
expectation->user_id != 0U &&
|
||||
(user == NULL || expectation->user_id != user->user_id)) {
|
||||
result->conflict = USER_ADMIN_SERVICE_CONFLICT_USER_ID;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
} else {
|
||||
const bool expected_commit = operation_expected_to_commit(operation, user);
|
||||
error = apply_database_operation(operation);
|
||||
if (error != ESP_OK) {
|
||||
result->failure = classify_failure(operation, user, error);
|
||||
} else {
|
||||
esp_err_t snapshot_error = user_database_get_snapshot(&s_snapshot);
|
||||
if (snapshot_error == ESP_OK) {
|
||||
result->database_generation = s_snapshot.generation;
|
||||
result->mutation_committed =
|
||||
s_snapshot.generation != before_generation;
|
||||
const user_database_user_snapshot_t *updated_user =
|
||||
find_snapshot_user(operation);
|
||||
if (updated_user != NULL) {
|
||||
result->user_id = updated_user->user_id;
|
||||
}
|
||||
} else {
|
||||
result->mutation_committed = expected_commit;
|
||||
if (expected_commit) {
|
||||
result->database_generation = before_generation + 1U;
|
||||
}
|
||||
}
|
||||
if (result->mutation_committed) {
|
||||
revoke_committed_user(operation, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
admin_command_gate_give();
|
||||
if (error != ESP_OK && operation->generated_password != NULL) {
|
||||
secure_wipe(operation->generated_password,
|
||||
sizeof(*operation->generated_password));
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_create(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_CREATE,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.role = role,
|
||||
.password = password,
|
||||
.password_length = password_length,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_create_generated(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_database_generated_password_t *generated_password,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
if (generated_password != NULL) {
|
||||
secure_wipe(generated_password, sizeof(*generated_password));
|
||||
}
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_CREATE_GENERATED,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.role = role,
|
||||
.generated_password = generated_password,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_delete(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_DELETE,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_set_role(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_SET_ROLE,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.role = role,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_set_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_SET_PASSWORD,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.password = password,
|
||||
.password_length = password_length,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_generate_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_database_generated_password_t *generated_password,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
if (generated_password != NULL) {
|
||||
secure_wipe(generated_password, sizeof(*generated_password));
|
||||
}
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_GENERATE_PASSWORD,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.generated_password = generated_password,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_add_ssh_key(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *key_type, size_t key_type_length,
|
||||
const uint8_t *key_blob, size_t key_blob_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
uint8_t *key_index, user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_ADD_SSH_KEY,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.key_type = key_type,
|
||||
.key_type_length = key_type_length,
|
||||
.key_blob = key_blob,
|
||||
.key_blob_length = key_blob_length,
|
||||
.added_key_index = key_index,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_remove_ssh_key(
|
||||
const uint8_t *username, size_t username_length, uint8_t key_index,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_REMOVE_SSH_KEY,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.key_index = key_index,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_clear_ssh_keys(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_CLEAR_SSH_KEYS,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Typed, serialized user-administration mutations with transport revocation. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "user_database.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
/* Zero disables the corresponding optimistic check. */
|
||||
uint32_t database_generation;
|
||||
uint32_t user_id;
|
||||
} user_admin_service_expectation_t;
|
||||
|
||||
typedef enum {
|
||||
USER_ADMIN_SERVICE_CONFLICT_NONE = 0,
|
||||
USER_ADMIN_SERVICE_CONFLICT_DATABASE_GENERATION,
|
||||
USER_ADMIN_SERVICE_CONFLICT_USER_ID,
|
||||
} user_admin_service_conflict_t;
|
||||
|
||||
typedef struct {
|
||||
bool attempted;
|
||||
esp_err_t web_error;
|
||||
esp_err_t ssh_error;
|
||||
} user_admin_service_revocation_t;
|
||||
|
||||
typedef enum {
|
||||
USER_ADMIN_SERVICE_FAILURE_NONE = 0,
|
||||
USER_ADMIN_SERVICE_FAILURE_DUPLICATE_USERNAME,
|
||||
USER_ADMIN_SERVICE_FAILURE_DUPLICATE_KEY,
|
||||
USER_ADMIN_SERVICE_FAILURE_CAPACITY,
|
||||
USER_ADMIN_SERVICE_FAILURE_FINAL_ADMIN,
|
||||
USER_ADMIN_SERVICE_FAILURE_GENERATION_EXHAUSTED,
|
||||
USER_ADMIN_SERVICE_FAILURE_STATE,
|
||||
} user_admin_service_failure_t;
|
||||
|
||||
typedef struct {
|
||||
/* True only when the database generation advanced for this operation. */
|
||||
bool mutation_committed;
|
||||
/* Current generation observed before, or immediately after, the operation. */
|
||||
uint32_t database_generation;
|
||||
/* Target account ID observed before the operation, or after a successful create. */
|
||||
uint32_t user_id;
|
||||
user_admin_service_conflict_t conflict;
|
||||
user_admin_service_failure_t failure;
|
||||
user_admin_service_revocation_t revocation;
|
||||
} user_admin_service_result_t;
|
||||
|
||||
/*
|
||||
* Every operation serializes its snapshot check and database mutation with
|
||||
* admin_command_gate. A generation or user-ID mismatch returns
|
||||
* ESP_ERR_INVALID_STATE and identifies the mismatch in result->conflict.
|
||||
*
|
||||
* A NULL expectation disables both optimistic checks. For existing-account
|
||||
* operations, zero fields also disable their individual checks. Create
|
||||
* operations require expectation->user_id to be zero because no prior account
|
||||
* identity can be targeted.
|
||||
*
|
||||
* After a committed mutation, both transport revocation hooks are attempted.
|
||||
* Their exact outcomes are returned separately and never replace ESP_OK from a
|
||||
* successful database mutation. No-op role and key-clear requests do not
|
||||
* advance the generation and do not trigger revocation.
|
||||
*/
|
||||
esp_err_t user_admin_service_create(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
/* On success, the caller owns generated_password and must securely wipe it. */
|
||||
esp_err_t user_admin_service_create_generated(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_database_generated_password_t *generated_password,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_delete(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_set_role(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_set_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
/* On success, the caller owns generated_password and must securely wipe it. */
|
||||
esp_err_t user_admin_service_generate_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_database_generated_password_t *generated_password,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_add_ssh_key(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *key_type, size_t key_type_length,
|
||||
const uint8_t *key_blob, size_t key_blob_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
uint8_t *key_index, user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_remove_ssh_key(
|
||||
const uint8_t *username, size_t username_length, uint8_t key_index,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_clear_ssh_keys(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+116
-61
@@ -14,8 +14,10 @@
|
||||
#include "mbedtls/base64.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "user_admin_service.h"
|
||||
#include "user_database.h"
|
||||
#include "web_serial_transport.h"
|
||||
#include "web_security.h"
|
||||
#include "web_server.h"
|
||||
|
||||
#define USER_CONSOLE_KEY_LINE_CAPACITY 256U
|
||||
|
||||
@@ -27,6 +29,7 @@ static void print_usage(void)
|
||||
printf("Usage:\n");
|
||||
printf(" user status|list\n");
|
||||
printf(" user show <username>\n");
|
||||
printf(" user bootstrap [--generate]\n");
|
||||
printf(" user recover --force\n");
|
||||
printf(" user add <username> <user|admin> [--generate]\n");
|
||||
printf(" user delete <username> --force\n");
|
||||
@@ -38,15 +41,10 @@ static void print_usage(void)
|
||||
printf(" user key clear <username> --force\n");
|
||||
}
|
||||
|
||||
static void revoke_user_network_sessions(const char *username)
|
||||
static void print_revocation_warnings(esp_err_t web_error, esp_err_t ssh_error)
|
||||
{
|
||||
size_t username_length = strlen(username);
|
||||
esp_err_t web_error = web_serial_transport_revoke_user(
|
||||
(const uint8_t *)username, username_length);
|
||||
esp_err_t ssh_error = ssh_transport_revoke_user(
|
||||
(const uint8_t *)username, username_length);
|
||||
if (web_error != ESP_OK && web_error != ESP_ERR_INVALID_STATE) {
|
||||
printf("Warning: WebSocket revocation failed: %s\n",
|
||||
printf("Warning: Web session revocation failed: %s\n",
|
||||
esp_err_to_name(web_error));
|
||||
}
|
||||
if (ssh_error != ESP_OK && ssh_error != ESP_ERR_INVALID_STATE) {
|
||||
@@ -54,6 +52,25 @@ static void revoke_user_network_sessions(const char *username)
|
||||
}
|
||||
}
|
||||
|
||||
static void print_service_revocation_warnings(
|
||||
const user_admin_service_result_t *result)
|
||||
{
|
||||
if (result->revocation.attempted) {
|
||||
print_revocation_warnings(result->revocation.web_error,
|
||||
result->revocation.ssh_error);
|
||||
}
|
||||
}
|
||||
|
||||
static void revoke_user_network_sessions(const char *username)
|
||||
{
|
||||
size_t username_length = strlen(username);
|
||||
esp_err_t web_error = web_server_revoke_user(
|
||||
(const uint8_t *)username, username_length);
|
||||
esp_err_t ssh_error = ssh_transport_revoke_user(
|
||||
(const uint8_t *)username, username_length);
|
||||
print_revocation_warnings(web_error, ssh_error);
|
||||
}
|
||||
|
||||
static void print_fingerprint(const uint8_t fingerprint[USER_DATABASE_SHA256_LENGTH])
|
||||
{
|
||||
uint8_t encoded[48] = {0};
|
||||
@@ -98,11 +115,12 @@ static int show_users(const char *selected)
|
||||
return 1;
|
||||
}
|
||||
if (selected == NULL) {
|
||||
printf("User database: generation=%lu users=%u/%u admins=%u\n",
|
||||
printf("User database: generation=%lu users=%u/%u admins=%u bootstrapped=%s\n",
|
||||
(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,
|
||||
s_user_snapshot.admin_bootstrapped ? "yes" : "no");
|
||||
}
|
||||
bool found = false;
|
||||
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) {
|
||||
@@ -120,8 +138,8 @@ static int show_users(const char *selected)
|
||||
printf("User '%s' not found.\n", selected);
|
||||
return 1;
|
||||
}
|
||||
if (s_user_snapshot.admin_count == 0U) {
|
||||
printf("No administrators; use 'user add <username> admin' on UART0.\n");
|
||||
if (!s_user_snapshot.admin_bootstrapped) {
|
||||
printf("Administrative network access is not bootstrapped; use 'user bootstrap'.\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -167,23 +185,53 @@ static void show_generated_password(const char *username,
|
||||
|
||||
static int recover_database(void)
|
||||
{
|
||||
esp_err_t error = user_database_recover_empty();
|
||||
web_security_credentials_t credentials;
|
||||
memset(&credentials, 0, sizeof(credentials));
|
||||
esp_err_t error = web_security_show_credentials(&credentials);
|
||||
if (error == ESP_OK) {
|
||||
const user_database_legacy_credentials_t legacy = {
|
||||
.username = (const uint8_t *)credentials.username,
|
||||
.username_length = credentials.username_length,
|
||||
.password = (const uint8_t *)credentials.password,
|
||||
.password_length = credentials.password_length,
|
||||
};
|
||||
error = user_database_recover_from_legacy(&legacy);
|
||||
}
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not recover user database: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("User database rebuilt empty; no credentials imported.\n");
|
||||
printf("Use 'user add <username> admin' on UART0 to create an administrator.\n");
|
||||
printf("User database replaced from the current legacy network credential.\n");
|
||||
printf("The imported account has role user; run 'user bootstrap' to establish an administrator.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static esp_err_t mutation_currentness(void)
|
||||
static int bootstrap(bool generated)
|
||||
{
|
||||
if (admin_ssh_console_dispatch_is_remote() &&
|
||||
!admin_ssh_console_dispatch_is_current()) {
|
||||
return ESP_ERR_NOT_ALLOWED;
|
||||
esp_err_t error;
|
||||
if (generated) {
|
||||
user_database_generated_password_t password;
|
||||
error = user_database_bootstrap_admin_generated(&password);
|
||||
if (error == ESP_OK) {
|
||||
show_generated_password("admin", &password);
|
||||
}
|
||||
} else {
|
||||
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
|
||||
size_t password_length = 0U;
|
||||
error = read_password(password, &password_length);
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_bootstrap_admin(password, password_length);
|
||||
}
|
||||
secure_wipe(password, sizeof(password));
|
||||
}
|
||||
return ESP_OK;
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not bootstrap administrator: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions("admin");
|
||||
printf("Administrator account bootstrapped. Role-aware HTTPS and SSH authentication is active.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int add_user(const char *username, const char *role_text, bool generated)
|
||||
@@ -195,10 +243,12 @@ static int add_user(const char *username, const char *role_text, bool generated)
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error;
|
||||
user_admin_service_result_t result;
|
||||
if (generated) {
|
||||
user_database_generated_password_t password;
|
||||
error = user_database_create_generated((const uint8_t *)username,
|
||||
strlen(username), role, &password);
|
||||
error = user_admin_service_create_generated(
|
||||
(const uint8_t *)username, strlen(username), role, NULL,
|
||||
&password, &result);
|
||||
if (error == ESP_OK) {
|
||||
show_generated_password(username, &password);
|
||||
}
|
||||
@@ -206,10 +256,10 @@ static int add_user(const char *username, const char *role_text, bool generated)
|
||||
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
|
||||
size_t password_length = 0U;
|
||||
error = read_password(password, &password_length);
|
||||
if (error == ESP_OK) error = mutation_currentness();
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_create((const uint8_t *)username, strlen(username),
|
||||
role, password, password_length);
|
||||
error = user_admin_service_create(
|
||||
(const uint8_t *)username, strlen(username), role,
|
||||
password, password_length, NULL, &result);
|
||||
}
|
||||
secure_wipe(password, sizeof(password));
|
||||
}
|
||||
@@ -217,7 +267,7 @@ static int add_user(const char *username, const char *role_text, bool generated)
|
||||
printf("Could not add user: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(username);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("User '%s' added with role %s.\n", username, user_role_to_string(role));
|
||||
return 0;
|
||||
}
|
||||
@@ -225,10 +275,11 @@ static int add_user(const char *username, const char *role_text, bool generated)
|
||||
static int change_password(const char *username, bool generated)
|
||||
{
|
||||
esp_err_t error;
|
||||
user_admin_service_result_t result;
|
||||
if (generated) {
|
||||
user_database_generated_password_t password;
|
||||
error = user_database_generate_password((const uint8_t *)username,
|
||||
strlen(username), &password);
|
||||
error = user_admin_service_generate_password(
|
||||
(const uint8_t *)username, strlen(username), NULL, &password, &result);
|
||||
if (error == ESP_OK) {
|
||||
show_generated_password(username, &password);
|
||||
}
|
||||
@@ -236,11 +287,10 @@ static int change_password(const char *username, bool generated)
|
||||
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
|
||||
size_t password_length = 0U;
|
||||
error = read_password(password, &password_length);
|
||||
if (error == ESP_OK) error = mutation_currentness();
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_set_password((const uint8_t *)username,
|
||||
strlen(username),
|
||||
password, password_length);
|
||||
error = user_admin_service_set_password(
|
||||
(const uint8_t *)username, strlen(username),
|
||||
password, password_length, NULL, &result);
|
||||
}
|
||||
secure_wipe(password, sizeof(password));
|
||||
}
|
||||
@@ -248,7 +298,7 @@ static int change_password(const char *username, bool generated)
|
||||
printf("Could not change password: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(username);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("Password changed; affected network sessions are now stale and will be revoked.\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -286,9 +336,10 @@ static int add_key_parts(const char *username,
|
||||
}
|
||||
|
||||
uint8_t key_index = 0U;
|
||||
esp_err_t error = user_database_add_ssh_key(
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_add_ssh_key(
|
||||
(const uint8_t *)username, strlen(username), type, type_length,
|
||||
blob, blob_length, &key_index);
|
||||
blob, blob_length, NULL, &key_index, &result);
|
||||
secure_wipe(blob, sizeof(blob));
|
||||
if (error != ESP_OK) {
|
||||
if (error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY) {
|
||||
@@ -301,7 +352,7 @@ static int add_key_parts(const char *username,
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(username);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("SSH public key added at index %u. Public-key login is active.\n",
|
||||
(unsigned int)key_index);
|
||||
return 0;
|
||||
@@ -357,14 +408,6 @@ static int command_user_inner(int argc, char **argv)
|
||||
{
|
||||
bool remote = admin_ssh_console_dispatch_is_remote();
|
||||
const user_principal_t *principal = admin_ssh_console_dispatch_principal();
|
||||
/* Repeat admission on canonical parsed arguments: direct handler calls must
|
||||
* not bypass self-target, generated-secret or UART0-only restrictions. */
|
||||
if (admin_ssh_console_dispatch_is_web() &&
|
||||
(!admin_ssh_console_web_user_command_allowed((size_t)argc, argv, principal) ||
|
||||
!admin_ssh_console_dispatch_is_current())) {
|
||||
printf("Browser account command restricted or session no longer current.\n");
|
||||
return 1;
|
||||
}
|
||||
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0) ||
|
||||
(argc == 2 && strcmp(argv[1], "list") == 0)) {
|
||||
return show_users(NULL);
|
||||
@@ -380,6 +423,18 @@ static int command_user_inner(int argc, char **argv)
|
||||
}
|
||||
return recover_database();
|
||||
}
|
||||
if ((argc == 2 || argc == 3) && strcmp(argv[1], "bootstrap") == 0) {
|
||||
bool generated = argc == 3 && strcmp(argv[2], "--generate") == 0;
|
||||
if (argc == 3 && !generated) {
|
||||
print_usage();
|
||||
return 1;
|
||||
}
|
||||
if (remote) {
|
||||
printf("Administrator bootstrap is restricted to physical UART0.\n");
|
||||
return 1;
|
||||
}
|
||||
return bootstrap(generated);
|
||||
}
|
||||
if ((argc == 4 || argc == 5) && strcmp(argv[1], "add") == 0) {
|
||||
bool generated = argc == 5 && strcmp(argv[4], "--generate") == 0;
|
||||
if (argc == 5 && !generated) {
|
||||
@@ -390,16 +445,15 @@ static int command_user_inner(int argc, char **argv)
|
||||
}
|
||||
if (argc == 4 && strcmp(argv[1], "delete") == 0 &&
|
||||
strcmp(argv[3], "--force") == 0) {
|
||||
esp_err_t error = mutation_currentness();
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_delete((const uint8_t *)argv[2], strlen(argv[2]));
|
||||
}
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_delete(
|
||||
(const uint8_t *)argv[2], strlen(argv[2]), NULL, &result);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not delete user (the final admin is protected): %s\n",
|
||||
printf("Could not delete user (the migrated or final admin is protected): %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(argv[2]);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("User '%s' deleted.\n", argv[2]);
|
||||
return 0;
|
||||
}
|
||||
@@ -411,16 +465,15 @@ static int command_user_inner(int argc, char **argv)
|
||||
printf("Role must be user or admin.\n");
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error = mutation_currentness();
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_set_role((const uint8_t *)argv[2], strlen(argv[2]), role);
|
||||
}
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_set_role(
|
||||
(const uint8_t *)argv[2], strlen(argv[2]), role, NULL, &result);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not change role (the final admin is protected): %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(argv[2]);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("User '%s' role changed to %s.\n", argv[2], user_role_to_string(role));
|
||||
return 0;
|
||||
}
|
||||
@@ -454,25 +507,27 @@ static int command_user_inner(int argc, char **argv)
|
||||
printf("Key index must be 0..2.\n");
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error = user_database_remove_ssh_key(
|
||||
(const uint8_t *)argv[3], strlen(argv[3]), index);
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_remove_ssh_key(
|
||||
(const uint8_t *)argv[3], strlen(argv[3]), index, NULL, &result);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not delete SSH key: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(argv[3]);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("SSH key %u deleted for '%s'.\n", (unsigned int)index, argv[3]);
|
||||
return 0;
|
||||
}
|
||||
if (argc == 5 && strcmp(argv[1], "key") == 0 &&
|
||||
strcmp(argv[2], "clear") == 0 && strcmp(argv[4], "--force") == 0) {
|
||||
esp_err_t error = user_database_clear_ssh_keys(
|
||||
(const uint8_t *)argv[3], strlen(argv[3]));
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_clear_ssh_keys(
|
||||
(const uint8_t *)argv[3], strlen(argv[3]), NULL, &result);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not clear SSH keys: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(argv[3]);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("SSH keys cleared for '%s'.\n", argv[3]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
+182
-195
@@ -25,6 +25,7 @@
|
||||
|
||||
static const uint8_t s_generated_alphabet[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
static const uint8_t s_admin_username[] = "admin";
|
||||
static const uint8_t s_ed25519_type[] = "ssh-ed25519";
|
||||
static const uint8_t s_ecdsa_type[] = "ecdsa-sha2-nistp256";
|
||||
static const uint8_t s_ecdsa_curve[] = "nistp256";
|
||||
@@ -57,8 +58,7 @@ typedef struct {
|
||||
uint32_t version;
|
||||
uint32_t size;
|
||||
uint32_t generation;
|
||||
/* Retain the v1 wire byte/layout; derived by recount, never policy state. */
|
||||
uint8_t v1_admin_marker;
|
||||
uint8_t admin_bootstrapped;
|
||||
uint8_t user_count;
|
||||
uint8_t admin_count;
|
||||
uint8_t reserved;
|
||||
@@ -295,7 +295,7 @@ static esp_err_t set_record_password(stored_user_t *user,
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_generate_password_value(user_database_generated_password_t *generated)
|
||||
static esp_err_t generate_password(user_database_generated_password_t *generated)
|
||||
{
|
||||
if (generated == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
@@ -351,7 +351,7 @@ static esp_err_t validate_database(const stored_database_t *database)
|
||||
{
|
||||
if (database->version != USER_DATABASE_SCHEMA_VERSION ||
|
||||
database->size != sizeof(*database) || database->generation == 0U ||
|
||||
database->v1_admin_marker > 1U || database->reserved != 0U) {
|
||||
database->admin_bootstrapped > 1U || database->reserved != 0U) {
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
uint8_t users = 0U;
|
||||
@@ -428,7 +428,7 @@ static esp_err_t validate_database(const stored_database_t *database)
|
||||
}
|
||||
}
|
||||
if (users != database->user_count || admins != database->admin_count ||
|
||||
(database->v1_admin_marker != 0U) != (admins > 0U)) {
|
||||
(database->admin_bootstrapped != 0U) != (admins > 0U)) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
return ESP_OK;
|
||||
@@ -446,7 +446,6 @@ static void recount(stored_database_t *database)
|
||||
}
|
||||
}
|
||||
}
|
||||
database->v1_admin_marker = database->admin_count > 0U ? 1U : 0U;
|
||||
}
|
||||
|
||||
static esp_err_t next_generation(uint32_t *generation)
|
||||
@@ -519,7 +518,58 @@ static esp_err_t initialize_user(stored_user_t *user,
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_init(user_database_load_result_t *load_result)
|
||||
static bool legacy_credentials_valid(const user_database_legacy_credentials_t *legacy)
|
||||
{
|
||||
return legacy != NULL && legacy->username != NULL && legacy->password != NULL &&
|
||||
user_database_username_valid(legacy->username, legacy->username_length) &&
|
||||
user_database_password_valid(legacy->password, legacy->password_length);
|
||||
}
|
||||
|
||||
static esp_err_t synchronize_legacy_locked(
|
||||
const user_database_legacy_credentials_t *legacy, bool *synchronized)
|
||||
{
|
||||
*synchronized = false;
|
||||
if (s_database.admin_bootstrapped != 0U) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
int index = find_user(&s_database, legacy->username, legacy->username_length);
|
||||
if (index < 0) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
const stored_user_t *stored = &s_database.users[index];
|
||||
uint8_t derived[USER_DATABASE_PASSWORD_HASH_LENGTH] = {0};
|
||||
esp_err_t error = derive_password(legacy->password, legacy->password_length,
|
||||
stored->password_salt,
|
||||
stored->password_iterations, derived);
|
||||
bool already_current = error == ESP_OK &&
|
||||
constant_time_equal(derived, stored->password_hash,
|
||||
sizeof(derived));
|
||||
secure_wipe(derived, sizeof(derived));
|
||||
if (error != ESP_OK || already_current) {
|
||||
*synchronized = already_current;
|
||||
return error;
|
||||
}
|
||||
|
||||
*s_candidate = s_database;
|
||||
stored_user_t *candidate_user = &s_candidate->users[index];
|
||||
error = set_record_password(candidate_user, legacy->password,
|
||||
legacy->password_length);
|
||||
if (error == ESP_OK) {
|
||||
error = next_generation(&candidate_user->auth_generation);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = commit_candidate_locked();
|
||||
} else {
|
||||
discard_candidate();
|
||||
}
|
||||
*synchronized = error == ESP_OK;
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
|
||||
user_database_load_result_t *load_result)
|
||||
{
|
||||
if (load_result == NULL || s_mutex != NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
@@ -559,6 +609,10 @@ esp_err_t user_database_init(user_database_load_result_t *load_result)
|
||||
if (error == ESP_OK && !storage_missing) {
|
||||
error = validate_database(&s_database);
|
||||
}
|
||||
if (error == ESP_OK && !storage_missing && legacy_credentials_valid(legacy)) {
|
||||
bool synchronized = false;
|
||||
error = synchronize_legacy_locked(legacy, &synchronized);
|
||||
}
|
||||
if (error == ESP_OK && !storage_missing) {
|
||||
error = initialize_dummy_verifier();
|
||||
if (error == ESP_OK) {
|
||||
@@ -576,7 +630,7 @@ esp_err_t user_database_init(user_database_load_result_t *load_result)
|
||||
goto init_failed;
|
||||
}
|
||||
|
||||
if (!storage_missing) {
|
||||
if (!storage_missing || !legacy_credentials_valid(legacy)) {
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
goto init_failed;
|
||||
}
|
||||
@@ -585,6 +639,13 @@ esp_err_t user_database_init(user_database_load_result_t *load_result)
|
||||
s_database.version = USER_DATABASE_SCHEMA_VERSION;
|
||||
s_database.size = sizeof(s_database);
|
||||
s_database.generation = 1U;
|
||||
error = initialize_user(&s_database.users[0], legacy->username,
|
||||
legacy->username_length, USER_ROLE_USER,
|
||||
legacy->password, legacy->password_length);
|
||||
if (error != ESP_OK) {
|
||||
goto init_failed;
|
||||
}
|
||||
*load_result = USER_DATABASE_LOAD_MIGRATED_LEGACY;
|
||||
recount(&s_database);
|
||||
*s_candidate = s_database;
|
||||
error = commit_candidate_locked();
|
||||
@@ -608,8 +669,28 @@ init_failed:
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_recover_empty(void)
|
||||
esp_err_t user_database_sync_legacy_credentials(
|
||||
const user_database_legacy_credentials_t *legacy, bool *synchronized)
|
||||
{
|
||||
if (synchronized == NULL || !legacy_credentials_valid(legacy)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
*synchronized = false;
|
||||
if (!s_initialized || s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
esp_err_t error = synchronize_legacy_locked(legacy, synchronized);
|
||||
xSemaphoreGive(s_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_recover_from_legacy(
|
||||
const user_database_legacy_credentials_t *legacy)
|
||||
{
|
||||
if (!legacy_credentials_valid(legacy)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_initialized || s_mutex != NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
@@ -634,7 +715,14 @@ esp_err_t user_database_recover_empty(void)
|
||||
s_candidate->version = USER_DATABASE_SCHEMA_VERSION;
|
||||
s_candidate->size = sizeof(*s_candidate);
|
||||
s_candidate->generation = 1U;
|
||||
error = commit_candidate_locked();
|
||||
error = initialize_user(&s_candidate->users[0], legacy->username,
|
||||
legacy->username_length, USER_ROLE_USER,
|
||||
legacy->password, legacy->password_length);
|
||||
if (error == ESP_OK) {
|
||||
error = commit_candidate_locked();
|
||||
} else {
|
||||
discard_candidate();
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = initialize_dummy_verifier();
|
||||
}
|
||||
@@ -664,6 +752,7 @@ esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot)
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
snapshot->initialized = true;
|
||||
snapshot->admin_bootstrapped = s_database.admin_bootstrapped != 0U;
|
||||
snapshot->generation = s_database.generation;
|
||||
snapshot->user_count = s_database.user_count;
|
||||
snapshot->admin_count = s_database.admin_count;
|
||||
@@ -699,25 +788,6 @@ esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t user_database_get_accounts(user_database_accounts_t *accounts)
|
||||
{
|
||||
if (accounts == NULL) return ESP_ERR_INVALID_ARG;
|
||||
memset(accounts, 0, sizeof(*accounts));
|
||||
if (!s_initialized || s_mutex == NULL) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
for (size_t i = 0; i < USER_DATABASE_MAX_USERS; ++i) {
|
||||
const stored_user_t *user = &s_database.users[i];
|
||||
if (!user->active) continue;
|
||||
user_database_account_t *out = &accounts->users[accounts->count++];
|
||||
out->user_id = user->user_id;
|
||||
out->auth_generation = user->auth_generation;
|
||||
out->role = (user_role_t)user->role;
|
||||
memcpy(out->username, user->username, user->username_length);
|
||||
}
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void fill_principal(const stored_user_t *user, user_auth_method_t method,
|
||||
user_principal_t *principal)
|
||||
{
|
||||
@@ -883,6 +953,9 @@ static esp_err_t create_locked(const uint8_t *username, size_t username_length,
|
||||
*s_candidate = s_database;
|
||||
esp_err_t error = initialize_user(&s_candidate->users[free_index], username,
|
||||
username_length, role, password, password_length);
|
||||
if (error == ESP_OK && role == USER_ROLE_ADMIN) {
|
||||
s_candidate->admin_bootstrapped = 1U;
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
return commit_candidate_locked();
|
||||
}
|
||||
@@ -913,7 +986,7 @@ esp_err_t user_database_create_generated(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
user_database_generated_password_t *generated_password)
|
||||
{
|
||||
esp_err_t error = user_database_generate_password_value(generated_password);
|
||||
esp_err_t error = generate_password(generated_password);
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_create(username, username_length, role,
|
||||
generated_password->password,
|
||||
@@ -925,6 +998,56 @@ esp_err_t user_database_create_generated(
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_bootstrap_admin(const uint8_t *password,
|
||||
size_t password_length)
|
||||
{
|
||||
if (!s_initialized || s_mutex == NULL ||
|
||||
!user_database_password_valid(password, password_length)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
if (s_database.admin_bootstrapped != 0U) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
int index = find_user(&s_database, s_admin_username, sizeof(s_admin_username) - 1U);
|
||||
esp_err_t error;
|
||||
if (index < 0) {
|
||||
error = create_locked(s_admin_username, sizeof(s_admin_username) - 1U,
|
||||
USER_ROLE_ADMIN, password, password_length);
|
||||
} else {
|
||||
*s_candidate = s_database;
|
||||
stored_user_t *user = &s_candidate->users[index];
|
||||
error = set_record_password(user, password, password_length);
|
||||
if (error == ESP_OK) {
|
||||
user->role = USER_ROLE_ADMIN;
|
||||
error = next_generation(&user->auth_generation);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
s_candidate->admin_bootstrapped = 1U;
|
||||
error = commit_candidate_locked();
|
||||
} else {
|
||||
discard_candidate();
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(s_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_bootstrap_admin_generated(
|
||||
user_database_generated_password_t *generated_password)
|
||||
{
|
||||
esp_err_t error = generate_password(generated_password);
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_bootstrap_admin(generated_password->password,
|
||||
generated_password->password_length);
|
||||
}
|
||||
if (error != ESP_OK && generated_password != NULL) {
|
||||
secure_wipe(generated_password, sizeof(*generated_password));
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t mutate_user_begin(const uint8_t *username, size_t username_length,
|
||||
int *index)
|
||||
{
|
||||
@@ -939,67 +1062,23 @@ static esp_err_t mutate_user_begin(const uint8_t *username, size_t username_leng
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static bool target_matches_locked(const uint8_t *username, size_t length,
|
||||
const user_database_account_t *expected)
|
||||
{
|
||||
if (!expected) return true;
|
||||
int index = find_user(&s_database, username, length);
|
||||
return index >= 0 && expected->user_id != 0 && expected->auth_generation != 0 &&
|
||||
s_database.users[index].user_id == expected->user_id &&
|
||||
s_database.users[index].auth_generation == expected->auth_generation;
|
||||
}
|
||||
|
||||
esp_err_t user_database_get_account_keys(const user_database_account_t *expected,
|
||||
user_database_user_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot) return ESP_ERR_INVALID_ARG;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
if (!expected) return ESP_ERR_INVALID_ARG;
|
||||
size_t length = strnlen(expected->username, sizeof(expected->username));
|
||||
if (!user_database_username_valid((const uint8_t *)expected->username, length))
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
if (!s_initialized || !s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
esp_err_t error = ESP_ERR_NOT_FOUND;
|
||||
if (target_matches_locked((const uint8_t *)expected->username, length, expected)) {
|
||||
const stored_user_t *user = &s_database.users[find_user(&s_database,
|
||||
(const uint8_t *)expected->username, length)];
|
||||
snapshot->active = true;
|
||||
snapshot->user_id = user->user_id;
|
||||
snapshot->auth_generation = user->auth_generation;
|
||||
snapshot->role = (user_role_t)user->role;
|
||||
snapshot->username_length = length;
|
||||
memcpy(snapshot->username, user->username, length);
|
||||
snapshot->public_key_count = user->key_count;
|
||||
for (size_t i = 0; i < USER_DATABASE_MAX_SSH_KEYS_PER_USER; ++i) {
|
||||
const stored_key_t *key = &user->keys[i];
|
||||
if (!key->active) continue;
|
||||
user_database_key_snapshot_t *out = &snapshot->public_keys[i];
|
||||
out->active = true;
|
||||
out->index = (uint8_t)i;
|
||||
out->key_type_length = key->type_length;
|
||||
memcpy(out->key_type, key->type, key->type_length);
|
||||
memcpy(out->sha256_fingerprint, key->fingerprint, sizeof(out->sha256_fingerprint));
|
||||
}
|
||||
error = ESP_OK;
|
||||
}
|
||||
xSemaphoreGive(s_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t delete_user(const uint8_t *username, size_t username_length,
|
||||
const user_database_account_t *expected)
|
||||
esp_err_t user_database_delete(const uint8_t *username, size_t username_length)
|
||||
{
|
||||
if (!s_initialized || s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
int index;
|
||||
esp_err_t error = target_matches_locked(username, username_length, expected)
|
||||
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
|
||||
esp_err_t error = mutate_user_begin(username, username_length, &index);
|
||||
if (error == ESP_OK) {
|
||||
const stored_user_t *user = &s_database.users[index];
|
||||
if (user->role == USER_ROLE_ADMIN && s_database.admin_count <= 1U) {
|
||||
bool protected_migrated_admin =
|
||||
s_database.admin_bootstrapped == 0U &&
|
||||
user->username_length == sizeof(s_admin_username) - 1U &&
|
||||
memcmp(user->username, s_admin_username,
|
||||
sizeof(s_admin_username) - 1U) == 0;
|
||||
if (protected_migrated_admin ||
|
||||
(user->role == USER_ROLE_ADMIN && s_database.admin_count <= 1U)) {
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
discard_candidate();
|
||||
} else {
|
||||
@@ -1011,8 +1090,8 @@ static esp_err_t delete_user(const uint8_t *username, size_t username_length,
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t set_role(const uint8_t *username, size_t username_length,
|
||||
user_role_t role, const user_database_account_t *expected)
|
||||
esp_err_t user_database_set_role(const uint8_t *username, size_t username_length,
|
||||
user_role_t role)
|
||||
{
|
||||
if (!s_initialized || s_mutex == NULL ||
|
||||
(role != USER_ROLE_USER && role != USER_ROLE_ADMIN)) {
|
||||
@@ -1020,8 +1099,7 @@ static esp_err_t set_role(const uint8_t *username, size_t username_length,
|
||||
}
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
int index;
|
||||
esp_err_t error = target_matches_locked(username, username_length, expected)
|
||||
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
|
||||
esp_err_t error = mutate_user_begin(username, username_length, &index);
|
||||
if (error == ESP_OK) {
|
||||
stored_user_t *user = &s_candidate->users[index];
|
||||
if (user->role == role) {
|
||||
@@ -1033,6 +1111,9 @@ static esp_err_t set_role(const uint8_t *username, size_t username_length,
|
||||
} else {
|
||||
user->role = (uint8_t)role;
|
||||
error = next_generation(&user->auth_generation);
|
||||
if (error == ESP_OK && role == USER_ROLE_ADMIN) {
|
||||
s_candidate->admin_bootstrapped = 1U;
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = commit_candidate_locked();
|
||||
} else {
|
||||
@@ -1044,37 +1125,8 @@ static esp_err_t set_role(const uint8_t *username, size_t username_length,
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_delete(const uint8_t *username, size_t length)
|
||||
{
|
||||
return delete_user(username, length, NULL);
|
||||
}
|
||||
|
||||
esp_err_t user_database_set_role(const uint8_t *username, size_t length, user_role_t role)
|
||||
{
|
||||
return set_role(username, length, role, NULL);
|
||||
}
|
||||
|
||||
esp_err_t user_database_delete_current(const user_database_account_t *expected)
|
||||
{
|
||||
if (!expected) return ESP_ERR_INVALID_ARG;
|
||||
size_t length = strnlen(expected->username, sizeof(expected->username));
|
||||
if (!user_database_username_valid((const uint8_t *)expected->username, length))
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
return delete_user((const uint8_t *)expected->username, length, expected);
|
||||
}
|
||||
|
||||
esp_err_t user_database_set_role_current(const user_database_account_t *expected, user_role_t role)
|
||||
{
|
||||
if (!expected) return ESP_ERR_INVALID_ARG;
|
||||
size_t length = strnlen(expected->username, sizeof(expected->username));
|
||||
if (!user_database_username_valid((const uint8_t *)expected->username, length))
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
return set_role((const uint8_t *)expected->username, length, role, expected);
|
||||
}
|
||||
|
||||
static esp_err_t set_password(const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_database_account_t *expected)
|
||||
esp_err_t user_database_set_password(const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length)
|
||||
{
|
||||
if (!s_initialized || s_mutex == NULL ||
|
||||
!user_database_password_valid(password, password_length)) {
|
||||
@@ -1082,8 +1134,7 @@ static esp_err_t set_password(const uint8_t *username, size_t username_length,
|
||||
}
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
int index;
|
||||
esp_err_t error = target_matches_locked(username, username_length, expected)
|
||||
? mutate_user_begin(username, username_length, &index) : ESP_ERR_NOT_FOUND;
|
||||
esp_err_t error = mutate_user_begin(username, username_length, &index);
|
||||
if (error == ESP_OK) {
|
||||
stored_user_t *user = &s_candidate->users[index];
|
||||
error = set_record_password(user, password, password_length);
|
||||
@@ -1100,27 +1151,11 @@ static esp_err_t set_password(const uint8_t *username, size_t username_length,
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_set_password(const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length)
|
||||
{
|
||||
return set_password(username, username_length, password, password_length, NULL);
|
||||
}
|
||||
|
||||
esp_err_t user_database_set_password_current(const user_database_account_t *expected,
|
||||
const uint8_t *password, size_t password_length)
|
||||
{
|
||||
if (!expected) return ESP_ERR_INVALID_ARG;
|
||||
size_t length = strnlen(expected->username, sizeof(expected->username));
|
||||
if (!user_database_username_valid((const uint8_t *)expected->username, length))
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
return set_password((const uint8_t *)expected->username, length, password, password_length, expected);
|
||||
}
|
||||
|
||||
esp_err_t user_database_generate_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
user_database_generated_password_t *generated_password)
|
||||
{
|
||||
esp_err_t error = user_database_generate_password_value(generated_password);
|
||||
esp_err_t error = generate_password(generated_password);
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_set_password(username, username_length,
|
||||
generated_password->password,
|
||||
@@ -1132,11 +1167,11 @@ esp_err_t user_database_generate_password(
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t add_ssh_key(
|
||||
esp_err_t user_database_add_ssh_key(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *key_type, size_t key_type_length,
|
||||
const uint8_t *key_blob, size_t key_blob_length,
|
||||
uint8_t *key_index, const user_database_account_t *expected)
|
||||
uint8_t *key_index)
|
||||
{
|
||||
if (!s_initialized || s_mutex == NULL || key_index == NULL ||
|
||||
!user_database_key_valid(key_type, key_type_length, key_blob, key_blob_length)) {
|
||||
@@ -1144,8 +1179,7 @@ static esp_err_t add_ssh_key(
|
||||
}
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
int user_index;
|
||||
esp_err_t error = target_matches_locked(username, username_length, expected)
|
||||
? mutate_user_begin(username, username_length, &user_index) : ESP_ERR_NOT_FOUND;
|
||||
esp_err_t error = mutate_user_begin(username, username_length, &user_index);
|
||||
if (error == ESP_OK) {
|
||||
stored_user_t *user = &s_candidate->users[user_index];
|
||||
int free_index = -1;
|
||||
@@ -1196,9 +1230,9 @@ static esp_err_t add_ssh_key(
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t remove_ssh_key(const uint8_t *username,
|
||||
size_t username_length, uint8_t key_index,
|
||||
const user_database_account_t *expected)
|
||||
esp_err_t user_database_remove_ssh_key(const uint8_t *username,
|
||||
size_t username_length,
|
||||
uint8_t key_index)
|
||||
{
|
||||
if (!s_initialized || s_mutex == NULL ||
|
||||
key_index >= USER_DATABASE_MAX_SSH_KEYS_PER_USER) {
|
||||
@@ -1206,8 +1240,7 @@ static esp_err_t remove_ssh_key(const uint8_t *username,
|
||||
}
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
int user_index;
|
||||
esp_err_t error = target_matches_locked(username, username_length, expected)
|
||||
? mutate_user_begin(username, username_length, &user_index) : ESP_ERR_NOT_FOUND;
|
||||
esp_err_t error = mutate_user_begin(username, username_length, &user_index);
|
||||
if (error == ESP_OK) {
|
||||
stored_user_t *user = &s_candidate->users[user_index];
|
||||
if (user->keys[key_index].active == 0U) {
|
||||
@@ -1228,17 +1261,15 @@ static esp_err_t remove_ssh_key(const uint8_t *username,
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t clear_ssh_keys(const uint8_t *username,
|
||||
size_t username_length,
|
||||
const user_database_account_t *expected)
|
||||
esp_err_t user_database_clear_ssh_keys(const uint8_t *username,
|
||||
size_t username_length)
|
||||
{
|
||||
if (!s_initialized || s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
int user_index;
|
||||
esp_err_t error = target_matches_locked(username, username_length, expected)
|
||||
? mutate_user_begin(username, username_length, &user_index) : ESP_ERR_NOT_FOUND;
|
||||
esp_err_t error = mutate_user_begin(username, username_length, &user_index);
|
||||
if (error == ESP_OK) {
|
||||
stored_user_t *user = &s_candidate->users[user_index];
|
||||
if (user->key_count == 0U) {
|
||||
@@ -1258,47 +1289,3 @@ static esp_err_t clear_ssh_keys(const uint8_t *username,
|
||||
xSemaphoreGive(s_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_database_add_ssh_key(const uint8_t *username, size_t length,
|
||||
const uint8_t *type, size_t type_length, const uint8_t *blob, size_t blob_length,
|
||||
uint8_t *index)
|
||||
{
|
||||
return add_ssh_key(username, length, type, type_length, blob, blob_length, index, NULL);
|
||||
}
|
||||
|
||||
esp_err_t user_database_remove_ssh_key(const uint8_t *username, size_t length, uint8_t index)
|
||||
{
|
||||
return remove_ssh_key(username, length, index, NULL);
|
||||
}
|
||||
|
||||
esp_err_t user_database_clear_ssh_keys(const uint8_t *username, size_t length)
|
||||
{
|
||||
return clear_ssh_keys(username, length, NULL);
|
||||
}
|
||||
|
||||
static bool key_target_valid(const user_database_account_t *expected)
|
||||
{
|
||||
return expected && user_database_username_valid((const uint8_t *)expected->username,
|
||||
strnlen(expected->username, sizeof(expected->username)));
|
||||
}
|
||||
|
||||
esp_err_t user_database_add_ssh_key_current(const user_database_account_t *expected,
|
||||
const uint8_t *type, size_t type_length, const uint8_t *blob, size_t blob_length,
|
||||
uint8_t *index)
|
||||
{
|
||||
if (!key_target_valid(expected)) return ESP_ERR_INVALID_ARG;
|
||||
return add_ssh_key((const uint8_t *)expected->username, strlen(expected->username),
|
||||
type, type_length, blob, blob_length, index, expected);
|
||||
}
|
||||
|
||||
esp_err_t user_database_remove_ssh_key_current(const user_database_account_t *expected, uint8_t index)
|
||||
{
|
||||
if (!key_target_valid(expected)) return ESP_ERR_INVALID_ARG;
|
||||
return remove_ssh_key((const uint8_t *)expected->username, strlen(expected->username), index, expected);
|
||||
}
|
||||
|
||||
esp_err_t user_database_clear_ssh_keys_current(const user_database_account_t *expected)
|
||||
{
|
||||
if (!key_target_valid(expected)) return ESP_ERR_INVALID_ARG;
|
||||
return clear_ssh_keys((const uint8_t *)expected->username, strlen(expected->username), expected);
|
||||
}
|
||||
|
||||
+25
-38
@@ -38,9 +38,17 @@ typedef enum {
|
||||
|
||||
typedef enum {
|
||||
USER_DATABASE_LOAD_STORED = 0,
|
||||
USER_DATABASE_LOAD_MIGRATED_LEGACY,
|
||||
USER_DATABASE_LOAD_EMPTY,
|
||||
} user_database_load_result_t;
|
||||
|
||||
typedef struct {
|
||||
const uint8_t *username;
|
||||
size_t username_length;
|
||||
const uint8_t *password;
|
||||
size_t password_length;
|
||||
} user_database_legacy_credentials_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t user_id;
|
||||
uint32_t auth_generation;
|
||||
@@ -76,52 +84,27 @@ typedef struct {
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
bool admin_bootstrapped;
|
||||
uint32_t generation;
|
||||
uint8_t user_count;
|
||||
uint8_t admin_count;
|
||||
user_database_user_snapshot_t users[USER_DATABASE_MAX_USERS];
|
||||
} user_database_snapshot_t;
|
||||
|
||||
/* Missing storage is persisted empty; valid v1 records load unchanged.
|
||||
* Corrupt/unsupported storage fails closed and is never automatically replaced. */
|
||||
esp_err_t user_database_init(user_database_load_result_t *load_result);
|
||||
/* Explicit UART0 recovery only; refuses an initialized database. No credentials
|
||||
* are imported or created. Caller enforces physical-console authorization. */
|
||||
esp_err_t user_database_recover_empty(void);
|
||||
esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
|
||||
user_database_load_result_t *load_result);
|
||||
/*
|
||||
* Before the first administrator is established, keep the migrated account in
|
||||
* sync with the legacy recovery credential. Once bootstrapped, that credential
|
||||
* remains independent and no longer authenticates Phase 8B network services.
|
||||
*/
|
||||
esp_err_t user_database_sync_legacy_credentials(
|
||||
const user_database_legacy_credentials_t *legacy, bool *synchronized);
|
||||
/* Explicit UART0 recovery: replace unavailable user storage with one legacy user. */
|
||||
esp_err_t user_database_recover_from_legacy(
|
||||
const user_database_legacy_credentials_t *legacy);
|
||||
esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot);
|
||||
|
||||
/* Compact secret-free list, zero-wait mutex acquisition; no key material. */
|
||||
typedef struct {
|
||||
uint32_t user_id, auth_generation;
|
||||
user_role_t role;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
||||
} user_database_account_t;
|
||||
typedef struct {
|
||||
size_t count;
|
||||
user_database_account_t users[USER_DATABASE_MAX_USERS];
|
||||
} user_database_accounts_t;
|
||||
esp_err_t user_database_get_accounts(user_database_accounts_t *accounts);
|
||||
/* Zero-wait, identity-conditional projection; fingerprints only, no key blobs.
|
||||
* Output is cleared on failure; absent/stale identity returns NOT_FOUND. */
|
||||
esp_err_t user_database_get_account_keys(const user_database_account_t *expected,
|
||||
user_database_user_snapshot_t *snapshot);
|
||||
esp_err_t user_database_add_ssh_key_current(const user_database_account_t *expected,
|
||||
const uint8_t *key_type, size_t key_type_length,
|
||||
const uint8_t *key_blob, size_t key_blob_length, uint8_t *key_index);
|
||||
esp_err_t user_database_remove_ssh_key_current(const user_database_account_t *expected,
|
||||
uint8_t key_index);
|
||||
esp_err_t user_database_clear_ssh_keys_current(const user_database_account_t *expected);
|
||||
/* Compare target identity under the mutation lock, before candidate/commit.
|
||||
* ESP_ERR_NOT_FOUND means absent or stale; existing account invariants apply. */
|
||||
esp_err_t user_database_delete_current(const user_database_account_t *expected);
|
||||
esp_err_t user_database_set_role_current(const user_database_account_t *expected,
|
||||
user_role_t role);
|
||||
esp_err_t user_database_set_password_current(const user_database_account_t *expected,
|
||||
const uint8_t *password, size_t password_length);
|
||||
/* RNG only: no database initialization, account mutation or persistence. Caller
|
||||
* owns/wipes successful output; failures clear it. Same generator as CLI. */
|
||||
esp_err_t user_database_generate_password_value(user_database_generated_password_t *generated);
|
||||
|
||||
esp_err_t user_database_authenticate_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
@@ -134,6 +117,10 @@ esp_err_t user_database_authorize_ssh_public_key(
|
||||
esp_err_t user_database_principal_is_current(const user_principal_t *principal,
|
||||
bool *current);
|
||||
|
||||
esp_err_t user_database_bootstrap_admin(const uint8_t *password,
|
||||
size_t password_length);
|
||||
esp_err_t user_database_bootstrap_admin_generated(
|
||||
user_database_generated_password_t *generated_password);
|
||||
esp_err_t user_database_create(const uint8_t *username, size_t username_length,
|
||||
user_role_t role,
|
||||
const uint8_t *password, size_t password_length);
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_account_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "secure_random.h"
|
||||
#include "mbedtls/base64.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_auth_parse.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "web_serial_transport.h"
|
||||
|
||||
enum { IDLE, PENDING, OK, FAILED, CANCELLED, STALE, PROTECTED, DUPLICATE, FULL };
|
||||
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "stale", "protected", "duplicate", "full"};
|
||||
typedef enum { ACTION_ROLE, ACTION_DELETE, ACTION_CREATE, ACTION_PASSWORD,
|
||||
ACTION_KEY_ADD, ACTION_KEY_DELETE, ACTION_KEY_CLEAR } account_action_t;
|
||||
static const char *const s_actions[] = {"role", "delete", "create", "password", "key-add", "key-delete", "key-clear"};
|
||||
typedef struct {
|
||||
uint32_t id;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
user_database_account_t target;
|
||||
int64_t deadline;
|
||||
user_role_t role;
|
||||
unsigned state;
|
||||
account_action_t action;
|
||||
bool executing;
|
||||
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U];
|
||||
size_t password_length;
|
||||
char key_type[USER_DATABASE_SSH_KEY_TYPE_CAPACITY + 1U];
|
||||
uint8_t key_blob[USER_DATABASE_SSH_KEY_BLOB_CAPACITY];
|
||||
size_t key_blob_length;
|
||||
uint8_t key_index;
|
||||
} account_operation_t;
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static account_operation_t s_operation;
|
||||
static uint32_t s_next_id;
|
||||
static esp_timer_handle_t s_secret_timer;
|
||||
static bool s_secret_timer_started;
|
||||
|
||||
static bool credential_action(account_action_t action)
|
||||
{
|
||||
return action == ACTION_CREATE || action == ACTION_PASSWORD;
|
||||
}
|
||||
|
||||
static void wipe_input(account_operation_t *operation)
|
||||
{
|
||||
secure_wipe(&operation->principal, sizeof(operation->principal));
|
||||
secure_wipe(&operation->target, sizeof(operation->target));
|
||||
secure_wipe(operation->password, sizeof(operation->password));
|
||||
operation->password_length = 0;
|
||||
secure_wipe(operation->key_type, sizeof(operation->key_type));
|
||||
secure_wipe(operation->key_blob, sizeof(operation->key_blob));
|
||||
operation->key_blob_length = 0;
|
||||
operation->key_index = 0;
|
||||
}
|
||||
|
||||
static void expire_secret(void *unused)
|
||||
{
|
||||
(void)unused;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
/* Inspect only the current ID/deadline, never a captured/rearmed job. A late
|
||||
* tick cannot cancel a replacement before its own deadline or executing work. */
|
||||
if (s_operation.id && s_operation.state == PENDING && !s_operation.executing &&
|
||||
credential_action(s_operation.action) && esp_timer_get_time() >= s_operation.deadline) {
|
||||
s_operation.state = CANCELLED;
|
||||
wipe_input(&s_operation);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static bool ensure_secret_timer(void)
|
||||
{
|
||||
/* HTTPD is the sole admission owner. Once started, this one firmware-lifetime
|
||||
* timer is never stopped/rearmed/deleted. Expiry is best-effort scheduling,
|
||||
* not hard realtime; no network/database work runs in its callback. */
|
||||
if (!s_secret_timer) {
|
||||
const esp_timer_create_args_t args = {.callback = expire_secret, .name = "account-secret"};
|
||||
if (esp_timer_create(&args, &s_secret_timer) != ESP_OK) return false;
|
||||
}
|
||||
if (!s_secret_timer_started) {
|
||||
if (esp_timer_start_periodic(s_secret_timer, 1000000ULL) != ESP_OK) return false;
|
||||
s_secret_timer_started = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* OpenSSH text envelope only. The canonical database parser validates the SSH
|
||||
* blob (including the P256 point) on the dispatcher, not the HTTPD stack. */
|
||||
static bool parse_public_key(const char *text, size_t length, account_operation_t *operation)
|
||||
{
|
||||
size_t type_length = 0;
|
||||
while (type_length < length && text[type_length] != ' ' && text[type_length] != '\t') ++type_length;
|
||||
if (!((type_length == 11 && !memcmp(text, "ssh-ed25519", 11)) ||
|
||||
(type_length == 19 && !memcmp(text, "ecdsa-sha2-nistp256", 19)))) return false;
|
||||
size_t start = type_length;
|
||||
while (start < length && (text[start] == ' ' || text[start] == '\t')) ++start;
|
||||
size_t end = start;
|
||||
while (end < length && text[end] != ' ' && text[end] != '\t') ++end;
|
||||
size_t encoded_length = end - start;
|
||||
if (!encoded_length || encoded_length > 172 || encoded_length % 4) return false;
|
||||
for (size_t i = start; i < end; ++i) {
|
||||
unsigned char c = (unsigned char)text[i];
|
||||
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '+' || c == '/' ||
|
||||
(c == '=' && i >= end - 2))) return false;
|
||||
}
|
||||
for (size_t i = end; i < length; ++i)
|
||||
if ((text[i] < ' ' || text[i] > '~') && text[i] != '\t') return false;
|
||||
if (mbedtls_base64_decode(operation->key_blob, sizeof(operation->key_blob),
|
||||
&operation->key_blob_length, (const uint8_t *)text + start, encoded_length) != 0) return false;
|
||||
/* Round-trip rejects noncanonical padding and unused base64 bits. */
|
||||
unsigned char encoded[173];
|
||||
size_t written = 0;
|
||||
if (mbedtls_base64_encode(encoded, sizeof(encoded), &written, operation->key_blob,
|
||||
operation->key_blob_length) != 0 || written != encoded_length ||
|
||||
memcmp(encoded, text + start, written)) return false;
|
||||
memcpy(operation->key_type, text, type_length);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Exact flat schemas. Password/public_key accept JSON escapes; canonical database
|
||||
* policy validates the decoded bytes. No coercion/unknown/duplicate fields. */
|
||||
static bool parse_request(const char *body, size_t length, account_operation_t *operation, bool keys_only)
|
||||
{
|
||||
const char *keys[] = {"action", "username", "user_id", "auth_generation", "role", "password", "public_key", "key_index"};
|
||||
unsigned seen = 0;
|
||||
size_t pos = 0;
|
||||
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
|
||||
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
|
||||
TAKE('{');
|
||||
for (unsigned field = 0; field < 8; ++field) {
|
||||
if (field) { TAKE(','); }
|
||||
TAKE('"');
|
||||
size_t start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
unsigned key = 0;
|
||||
for (; key < 8; ++key)
|
||||
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
||||
if (key == 8 || (seen & (1U << key))) return false;
|
||||
++pos; TAKE(':'); SPACE();
|
||||
uint32_t number = 0;
|
||||
char value[USER_DATABASE_USERNAME_CAPACITY + 1] = {0};
|
||||
if (key == 2 || key == 3 || key == 7) {
|
||||
start = pos;
|
||||
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
|
||||
unsigned digit = (unsigned)(body[pos++] - '0');
|
||||
if (number > (UINT32_MAX - digit) / 10U) return false;
|
||||
number = number * 10U + digit;
|
||||
}
|
||||
if ((!number && key != 7) || pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
||||
if (key == 7 && number >= USER_DATABASE_MAX_SSH_KEYS_PER_USER) return false;
|
||||
} else if (key == 6) {
|
||||
char text[385] = {0};
|
||||
size_t text_length = 0;
|
||||
if (!web_auth_parse_json_string(body, length, &pos, (uint8_t *)text,
|
||||
sizeof(text), &text_length) || !parse_public_key(text, text_length, operation)) return false;
|
||||
} else if (key == 5) {
|
||||
if (!web_auth_parse_json_string(body, length, &pos, operation->password,
|
||||
sizeof(operation->password), &operation->password_length) ||
|
||||
!user_database_password_valid(operation->password, operation->password_length)) return false;
|
||||
} else {
|
||||
TAKE('"'); start = pos;
|
||||
while (pos < length && body[pos] != '"') {
|
||||
if (body[pos] < ' ' || body[pos] > '~' || body[pos] == '\\' || pos - start >= sizeof(value) - 1) return false;
|
||||
++pos;
|
||||
}
|
||||
if (pos == length) return false;
|
||||
memcpy(value, body + start, pos - start); ++pos;
|
||||
}
|
||||
switch (key) {
|
||||
case 0:
|
||||
{
|
||||
unsigned action = 0;
|
||||
for (; action < sizeof(s_actions) / sizeof(*s_actions); ++action)
|
||||
if (!strcmp(value, s_actions[action])) break;
|
||||
if (action == sizeof(s_actions) / sizeof(*s_actions)) return false;
|
||||
operation->action = (account_action_t)action;
|
||||
}
|
||||
break;
|
||||
case 1:
|
||||
if (!user_database_username_valid((const uint8_t *)value, strlen(value))) return false;
|
||||
memcpy(operation->target.username, value, sizeof(value)); break;
|
||||
case 2: operation->target.user_id = number; break;
|
||||
case 3: operation->target.auth_generation = number; break;
|
||||
case 4: if (!user_role_parse(value, &operation->role)) return false; break;
|
||||
case 7: operation->key_index = (uint8_t)number; break;
|
||||
}
|
||||
seen |= 1U << key;
|
||||
SPACE();
|
||||
if (pos < length && body[pos] == '}') break;
|
||||
}
|
||||
TAKE('}'); SPACE();
|
||||
#undef TAKE
|
||||
#undef SPACE
|
||||
const unsigned schemas[] = {31U, 15U, 51U, 47U, 79U, 143U, 15U};
|
||||
return pos == length && seen == (keys_only ? 14U : schemas[operation->action]);
|
||||
}
|
||||
|
||||
static bool parse(const char *body, size_t length, account_operation_t *operation)
|
||||
{
|
||||
return parse_request(body, length, operation, false);
|
||||
}
|
||||
|
||||
void web_account_settings_execute(uint32_t id)
|
||||
{
|
||||
account_operation_t operation = {0};
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool admitted = id && s_operation.id == id && s_operation.state == PENDING && !s_operation.executing;
|
||||
if (admitted) {
|
||||
s_operation.executing = true;
|
||||
operation = s_operation;
|
||||
wipe_input(&s_operation);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!admitted) return;
|
||||
bool current = false;
|
||||
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t);
|
||||
unsigned state = CANCELLED;
|
||||
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
||||
esp_timer_get_time() < operation.deadline) {
|
||||
/* CLI and typed mutations share this dispatcher. Target identity is also
|
||||
* compared under the database mutation lock, not just at HTTP admission. */
|
||||
switch (operation.action) {
|
||||
case ACTION_ROLE: error = user_database_set_role_current(&operation.target, operation.role); break;
|
||||
case ACTION_DELETE: error = user_database_delete_current(&operation.target); break;
|
||||
case ACTION_CREATE:
|
||||
error = user_database_create((const uint8_t *)operation.target.username,
|
||||
strlen(operation.target.username), operation.role, operation.password, operation.password_length);
|
||||
break;
|
||||
case ACTION_KEY_ADD:
|
||||
error = user_database_add_ssh_key_current(&operation.target,
|
||||
(const uint8_t *)operation.key_type, strlen(operation.key_type),
|
||||
operation.key_blob, operation.key_blob_length, &operation.key_index);
|
||||
break;
|
||||
case ACTION_KEY_DELETE:
|
||||
error = user_database_remove_ssh_key_current(&operation.target, operation.key_index);
|
||||
break;
|
||||
case ACTION_KEY_CLEAR:
|
||||
error = user_database_clear_ssh_keys_current(&operation.target);
|
||||
break;
|
||||
case ACTION_PASSWORD:
|
||||
error = user_database_set_password_current(&operation.target, operation.password, operation.password_length);
|
||||
break;
|
||||
}
|
||||
secure_wipe(operation.password, sizeof(operation.password));
|
||||
operation.password_length = 0;
|
||||
state = error == ESP_OK ? OK : error == ESP_ERR_NOT_FOUND ? STALE :
|
||||
error == ESP_ERR_INVALID_STATE ? (operation.action == ACTION_CREATE ? DUPLICATE :
|
||||
operation.action <= ACTION_PASSWORD ? PROTECTED : FAILED) :
|
||||
error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY && operation.action == ACTION_KEY_ADD ? DUPLICATE :
|
||||
error == ESP_ERR_NO_MEM && (operation.action == ACTION_CREATE || operation.action == ACTION_KEY_ADD) ? FULL : FAILED;
|
||||
if (error == ESP_OK && operation.action != ACTION_CREATE) {
|
||||
size_t length = strlen(operation.target.username);
|
||||
(void)web_serial_transport_revoke_user((const uint8_t *)operation.target.username, length);
|
||||
(void)ssh_transport_revoke_user((const uint8_t *)operation.target.username, length);
|
||||
}
|
||||
}
|
||||
secure_wipe(operation.password, sizeof(operation.password));
|
||||
operation.password_length = 0;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.id == id && s_operation.state == PENDING && s_operation.executing) {
|
||||
s_operation.state = state;
|
||||
s_operation.executing = false;
|
||||
wipe_input(&s_operation);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
}
|
||||
|
||||
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(request, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
static esp_err_t list_accounts(httpd_req_t *request)
|
||||
{
|
||||
user_database_accounts_t accounts;
|
||||
if (user_database_get_accounts(&accounts) != ESP_OK)
|
||||
return respond(request, "503 Service Unavailable", "{\"error\":\"accounts_unavailable\"}");
|
||||
char body[1024];
|
||||
size_t used = (size_t)snprintf(body, sizeof(body), "{\"users\":[");
|
||||
for (size_t i = 0; i < accounts.count; ++i) {
|
||||
const user_database_account_t *user = &accounts.users[i];
|
||||
/* Database username policy makes these ASCII strings JSON-safe. */
|
||||
int written = snprintf(body + used, sizeof(body) - used,
|
||||
"%s{\"username\":\"%s\",\"user_id\":%" PRIu32 ",\"auth_generation\":%" PRIu32 ",\"role\":\"%s\"}",
|
||||
i ? "," : "", user->username, user->user_id, user->auth_generation, user_role_to_string(user->role));
|
||||
if (written < 0 || (size_t)written >= sizeof(body) - used) return ESP_FAIL;
|
||||
used += (size_t)written;
|
||||
}
|
||||
if (used + 3 > sizeof(body)) return ESP_FAIL;
|
||||
memcpy(body + used, "]}", 3);
|
||||
return respond(request, "200 OK", body);
|
||||
}
|
||||
|
||||
static bool read_request(httpd_req_t *request, account_operation_t *operation, bool keys_only)
|
||||
{
|
||||
char type[40] = {0}, body[768];
|
||||
size_t received = 0;
|
||||
bool valid = request->content_len && request->content_len <= sizeof(body) &&
|
||||
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
|
||||
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
|
||||
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
|
||||
int count = httpd_req_recv(request, body + received, request->content_len - received);
|
||||
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
|
||||
else received += (size_t)count;
|
||||
}
|
||||
valid = valid && received == request->content_len &&
|
||||
(keys_only ? parse_request(body, received, operation, true) : parse(body, received, operation));
|
||||
secure_wipe(body, sizeof(body));
|
||||
return valid;
|
||||
}
|
||||
|
||||
esp_err_t web_account_keys_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
account_operation_t operation = {0};
|
||||
user_database_user_snapshot_t snapshot = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require_json(request, 768, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
if (strcmp(request->uri, "/api/settings/accounts/keys") || !read_request(request, &operation, true)) {
|
||||
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}");
|
||||
goto done;
|
||||
}
|
||||
error = user_database_get_account_keys(&operation.target, &snapshot);
|
||||
if (error != ESP_OK) {
|
||||
error = error == ESP_ERR_NOT_FOUND ? respond(request, "409 Conflict", "{\"error\":\"stale\"}") :
|
||||
respond(request, "503 Service Unavailable", "{\"error\":\"accounts_unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
char body[512];
|
||||
int written = snprintf(body, sizeof(body),
|
||||
"{\"username\":\"%s\",\"user_id\":%" PRIu32 ",\"auth_generation\":%" PRIu32 ",\"keys\":[",
|
||||
snapshot.username, snapshot.user_id, snapshot.auth_generation);
|
||||
if (written < 0 || (size_t)written >= sizeof(body)) { error = ESP_FAIL; goto done; }
|
||||
size_t used = (size_t)written;
|
||||
bool comma = false;
|
||||
for (size_t i = 0; i < USER_DATABASE_MAX_SSH_KEYS_PER_USER; ++i) {
|
||||
const user_database_key_snapshot_t *key = &snapshot.public_keys[i];
|
||||
if (!key->active) continue;
|
||||
unsigned char fingerprint[45];
|
||||
size_t length = 0;
|
||||
if (mbedtls_base64_encode(fingerprint, sizeof(fingerprint), &length,
|
||||
key->sha256_fingerprint, sizeof(key->sha256_fingerprint)) != 0 || length != 44) {
|
||||
error = ESP_FAIL; goto done;
|
||||
}
|
||||
fingerprint[43] = 0; /* OpenSSH SHA256 fingerprints omit base64 padding. */
|
||||
written = snprintf(body + used, sizeof(body) - used,
|
||||
"%s{\"index\":%u,\"type\":\"%s\",\"fingerprint\":\"SHA256:%s\"}",
|
||||
comma ? "," : "", key->index, key->key_type, (const char *)fingerprint);
|
||||
if (written < 0 || (size_t)written >= sizeof(body) - used) { error = ESP_FAIL; goto done; }
|
||||
used += (size_t)written;
|
||||
comma = true;
|
||||
}
|
||||
if (used + 3 > sizeof(body)) { error = ESP_FAIL; goto done; }
|
||||
memcpy(body + used, "]}", 3);
|
||||
error = respond(request, "200 OK", body);
|
||||
done:
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_account_generate_password_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
user_database_generated_password_t generated = {0};
|
||||
char response[96] = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
error = user_database_generate_password_value(&generated);
|
||||
if (error != ESP_OK) {
|
||||
secure_wipe(&generated, sizeof(generated));
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
bool current = false;
|
||||
error = web_session_store_check_principal(view.id, &view.principal, ¤t);
|
||||
if (error != ESP_OK || !current) {
|
||||
secure_wipe(&generated, sizeof(generated));
|
||||
error = respond(request, "401 Unauthorized", "{\"error\":\"authentication_required\"}");
|
||||
goto done;
|
||||
}
|
||||
int written = snprintf(response, sizeof(response), "{\"password\":\"%s\"}", (const char *)generated.password);
|
||||
secure_wipe(&generated, sizeof(generated));
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&generated, sizeof(generated));
|
||||
secure_wipe(response, sizeof(response));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_account_settings_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
account_operation_t operation = {0};
|
||||
bool allowed = false, mutation = request->method == HTTP_POST;
|
||||
esp_err_t error = mutation ? web_cookie_auth_require_json(request, 768, &view, &allowed) :
|
||||
web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
if (!strcmp(request->uri, "/api/settings/accounts")) {
|
||||
error = mutation ? respond(request, "400 Bad Request", "{\"error\":\"invalid_request\"}") : list_accounts(request);
|
||||
goto done;
|
||||
}
|
||||
if (mutation) {
|
||||
if (!read_request(request, &operation, false)) {
|
||||
wipe_input(&operation);
|
||||
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}");
|
||||
goto done;
|
||||
}
|
||||
if (credential_action(operation.action) && !ensure_secret_timer()) {
|
||||
wipe_input(&operation);
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
operation.session = view.id;
|
||||
operation.principal = view.principal;
|
||||
operation.deadline = esp_timer_get_time() + 30000000LL;
|
||||
operation.state = PENDING;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
|
||||
if (!busy) { operation.id = ++s_next_id; s_operation = operation; }
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (busy || admin_ssh_console_submit_account_settings(operation.id) != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!busy && s_operation.id == operation.id && !s_operation.executing)
|
||||
secure_wipe(&s_operation, sizeof(s_operation));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
wipe_input(&operation);
|
||||
error = httpd_resp_set_hdr(request, "Retry-After", "1");
|
||||
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
|
||||
goto done;
|
||||
}
|
||||
} else {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.session == view.id) {
|
||||
operation.id = s_operation.id;
|
||||
operation.action = s_operation.action;
|
||||
operation.state = s_operation.state;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
wipe_input(&operation);
|
||||
char response[96];
|
||||
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
|
||||
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
||||
respond(request, mutation ? "202 Accepted" : "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/* One session-bound pending/result slot. Dispatcher execution only; completed
|
||||
* results are replaceable, not durable history or an idempotent retry API. */
|
||||
esp_err_t web_account_settings_handler(httpd_req_t *request);
|
||||
void web_account_settings_execute(uint32_t id);
|
||||
/* POST /api/settings/accounts/keys: admin cookie + Origin/CSRF, JSON exactly
|
||||
* {username,user_id,auth_generation}. Read-only zero-wait snapshot, 512-byte
|
||||
* response bound: {username,user_id,auth_generation,keys:[{index,type,fingerprint}]}.
|
||||
* Fingerprints are OpenSSH SHA256: base64 without padding, never key blobs.
|
||||
* Stale/absent target: 409 {error:"stale"}; busy DB: 503 accounts_unavailable.
|
||||
* Register independently as an optional POST route.
|
||||
*
|
||||
* Existing account-operation POST adds key-add (+public_key, OpenSSH text <=384
|
||||
* decoded bytes), key-delete (+key_index integer 0..2), key-clear. All require
|
||||
* username/user_id/auth_generation. Exact schemas, <=768 body bytes/4 receives.
|
||||
* Text/base64 errors: 400; canonical SSH blob/curve validation runs on dispatcher
|
||||
* (failed result). Duplicate/full/stale use existing named result states.
|
||||
* Success target-revokes immediately, including self; lost response/401 remains
|
||||
* uncertain, never proof of cancellation. No automatic mutation retries. */
|
||||
esp_err_t web_account_keys_handler(httpd_req_t *request);
|
||||
/* POST /api/settings/accounts/generate-password; bodyless admin cookie +
|
||||
* Origin/CSRF. RNG only, no queued/account/persistent state or retrieval. */
|
||||
esp_err_t web_account_generate_password_handler(httpd_req_t *request);
|
||||
@@ -1,300 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_admin_tickets.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <string.h>
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
typedef struct {
|
||||
uint64_t generation;
|
||||
web_session_id_t id;
|
||||
int64_t expires_at_us;
|
||||
user_principal_t principal;
|
||||
uint8_t digest[32];
|
||||
} ticket_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static struct {
|
||||
ticket_t tickets[WEB_ADMIN_TICKET_CAPACITY];
|
||||
uint64_t epoch;
|
||||
uint64_t generation;
|
||||
uint32_t issued, consumed, rejected, capacity_rejections;
|
||||
bool ready;
|
||||
} s_state;
|
||||
|
||||
static void increment(uint32_t *counter)
|
||||
{
|
||||
if (*counter != UINT32_MAX) ++*counter;
|
||||
}
|
||||
|
||||
static bool equal_digest(const uint8_t *a, const uint8_t *b)
|
||||
{
|
||||
volatile uint8_t difference = 0;
|
||||
for (size_t i = 0; i < 32; ++i) difference |= a[i] ^ b[i];
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
static bool admin(const user_principal_t *p)
|
||||
{
|
||||
return p != NULL && p->role == USER_ROLE_ADMIN &&
|
||||
p->method == USER_AUTH_METHOD_PASSWORD && p->user_id != 0 &&
|
||||
p->auth_generation != 0 && p->username_length != 0 &&
|
||||
p->username_length <= USER_DATABASE_USERNAME_CAPACITY;
|
||||
}
|
||||
|
||||
static bool same_principal(const user_principal_t *a, const user_principal_t *b)
|
||||
{
|
||||
return admin(b) && a->user_id == b->user_id &&
|
||||
a->auth_generation == b->auth_generation && a->role == b->role &&
|
||||
a->method == b->method && a->username_length == b->username_length &&
|
||||
memcmp(a->username, b->username, a->username_length) == 0;
|
||||
}
|
||||
|
||||
static bool current(web_session_id_t id, const user_principal_t *p)
|
||||
{
|
||||
bool valid = false;
|
||||
return id != 0 && admin(p) &&
|
||||
web_session_store_check_principal(id, p, &valid) == ESP_OK && valid;
|
||||
}
|
||||
|
||||
static void expire_locked(int64_t now)
|
||||
{
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t *t = &s_state.tickets[i];
|
||||
if (t->generation && t->expires_at_us <= now) secure_wipe(t, sizeof(*t));
|
||||
}
|
||||
}
|
||||
|
||||
/* Fixed two-slot walk. Generation prevents an external check from deleting a
|
||||
* replacement, including when RNG returns the same bytes on a later issue. */
|
||||
static void prune(void)
|
||||
{
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t copy = {0};
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
copy = s_state.tickets[i];
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (copy.generation && !current(copy.id, ©.principal)) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_state.tickets[i].generation == copy.generation)
|
||||
secure_wipe(&s_state.tickets[i], sizeof(ticket_t));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
secure_wipe(©, sizeof(copy));
|
||||
}
|
||||
}
|
||||
|
||||
static void advance_epoch_locked(void)
|
||||
{
|
||||
if (s_state.epoch != UINT64_MAX) ++s_state.epoch;
|
||||
if (s_state.epoch == UINT64_MAX) {
|
||||
s_state.ready = false;
|
||||
secure_wipe(s_state.tickets, sizeof(s_state.tickets));
|
||||
}
|
||||
}
|
||||
|
||||
void web_admin_tickets_start(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!s_state.ready && s_state.epoch != UINT64_MAX &&
|
||||
s_state.generation != UINT64_MAX) {
|
||||
advance_epoch_locked();
|
||||
s_state.ready = s_state.epoch != UINT64_MAX;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_admin_tickets_stop(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
s_state.ready = false;
|
||||
secure_wipe(s_state.tickets, sizeof(s_state.tickets));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static bool capture_epoch(uint64_t *epoch)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
*epoch = s_state.epoch;
|
||||
bool ready = s_state.ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ready;
|
||||
}
|
||||
|
||||
static esp_err_t result(esp_err_t error)
|
||||
{
|
||||
if (error != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
increment(&s_state.rejected);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_admin_tickets_issue(web_session_id_t id,
|
||||
const user_principal_t *principal, char token[WEB_ADMIN_TICKET_LENGTH + 1U])
|
||||
{
|
||||
ticket_t candidate = {0};
|
||||
uint8_t random[32] = {0};
|
||||
uint64_t epoch = 0;
|
||||
esp_err_t error = ESP_ERR_INVALID_ARG;
|
||||
if (token == NULL) return result(error);
|
||||
secure_wipe(token, WEB_ADMIN_TICKET_LENGTH + 1U);
|
||||
if (id == 0 || principal == NULL) goto done;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
if (!capture_epoch(&epoch) || !current(id, principal)) goto done;
|
||||
candidate.id = id;
|
||||
candidate.principal = *principal;
|
||||
prune();
|
||||
if (!current(id, &candidate.principal)) goto done;
|
||||
error = secure_random_fill(random, sizeof(random));
|
||||
/* Recheck even when crypto fails; never use an old authorization result. */
|
||||
bool valid = current(id, &candidate.principal);
|
||||
if (error != ESP_OK) goto done;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
if (!valid) goto done;
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < sizeof(random); ++i) {
|
||||
token[2 * i] = hex[random[i] >> 4];
|
||||
token[2 * i + 1] = hex[random[i] & 15];
|
||||
}
|
||||
int crypto = mbedtls_sha256(random, sizeof(random), candidate.digest, 0);
|
||||
valid = current(id, &candidate.principal);
|
||||
error = crypto == 0 ? ESP_ERR_INVALID_STATE : ESP_FAIL;
|
||||
if (crypto != 0 || !valid) goto done;
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
if (s_state.ready && epoch == s_state.epoch && now >= 0 &&
|
||||
now <= INT64_MAX - WEB_ADMIN_TICKET_LIFETIME_US &&
|
||||
s_state.generation != UINT64_MAX) {
|
||||
ticket_t *free_slot = NULL;
|
||||
bool duplicate = false;
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t *t = &s_state.tickets[i];
|
||||
if (!t->generation) free_slot = t;
|
||||
else if (equal_digest(t->digest, candidate.digest)) duplicate = true;
|
||||
}
|
||||
if (duplicate) error = ESP_FAIL;
|
||||
else if (free_slot == NULL) {
|
||||
increment(&s_state.capacity_rejections);
|
||||
error = ESP_ERR_NO_MEM;
|
||||
} else {
|
||||
candidate.generation = ++s_state.generation;
|
||||
candidate.expires_at_us = now + WEB_ADMIN_TICKET_LIFETIME_US;
|
||||
*free_slot = candidate;
|
||||
increment(&s_state.issued);
|
||||
error = ESP_OK;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
done:
|
||||
secure_wipe(random, sizeof(random));
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
if (error != ESP_OK) secure_wipe(token, WEB_ADMIN_TICKET_LENGTH + 1U);
|
||||
return result(error);
|
||||
}
|
||||
|
||||
static int unhex(char c)
|
||||
{
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
esp_err_t web_admin_tickets_consume(const char *token, web_session_id_t id,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
uint8_t bytes[32] = {0}, digest[32] = {0};
|
||||
ticket_t found = {0};
|
||||
uint64_t epoch = 0;
|
||||
esp_err_t error = ESP_ERR_INVALID_ARG;
|
||||
if (token == NULL) goto done;
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_LENGTH; ++i) {
|
||||
int n = unhex(token[i]);
|
||||
if (n < 0) goto done;
|
||||
bytes[i / 2] |= (uint8_t)(n << ((i % 2 == 0) ? 4 : 0));
|
||||
}
|
||||
if (token[WEB_ADMIN_TICKET_LENGTH] != '\0') goto done;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
if (!capture_epoch(&epoch)) goto done;
|
||||
bool before = current(id, principal);
|
||||
if (mbedtls_sha256(bytes, sizeof(bytes), digest, 0) != 0) {
|
||||
(void)current(id, principal);
|
||||
error = ESP_FAIL;
|
||||
goto done;
|
||||
}
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
if (s_state.ready && epoch == s_state.epoch) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t *t = &s_state.tickets[i];
|
||||
if (t->generation && equal_digest(t->digest, digest)) {
|
||||
found = *t;
|
||||
secure_wipe(t, sizeof(*t));
|
||||
increment(&s_state.consumed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
/* Burn precedes acting on either currentness result or identity binding. */
|
||||
bool after = current(id, principal);
|
||||
if (found.generation) {
|
||||
now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
error = before && after && found.id == id &&
|
||||
same_principal(&found.principal, principal) && s_state.ready &&
|
||||
epoch == s_state.epoch && now < found.expires_at_us ?
|
||||
ESP_OK : ESP_ERR_INVALID_STATE;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
done:
|
||||
secure_wipe(bytes, sizeof(bytes));
|
||||
secure_wipe(digest, sizeof(digest));
|
||||
secure_wipe(&found, sizeof(found));
|
||||
return result(error);
|
||||
}
|
||||
|
||||
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *username,
|
||||
size_t length)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t *t = &s_state.tickets[i];
|
||||
bool match = id != 0 ? t->id == id : username == NULL ||
|
||||
(length == t->principal.username_length &&
|
||||
length <= USER_DATABASE_USERNAME_CAPACITY &&
|
||||
memcmp(username, t->principal.username, length) == 0);
|
||||
if (match) secure_wipe(t, sizeof(*t));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_admin_tickets_get_snapshot(web_admin_tickets_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) return;
|
||||
prune();
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
*snapshot = (web_admin_tickets_snapshot_t) {
|
||||
.issued = s_state.issued, .consumed = s_state.consumed,
|
||||
.rejected = s_state.rejected,
|
||||
.capacity_rejections = s_state.capacity_rejections,
|
||||
.storage_bytes = sizeof(s_state) + sizeof(s_lock), .ready = s_state.ready,
|
||||
};
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i)
|
||||
if (s_state.tickets[i].generation) ++snapshot->active;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
|
||||
#include "web_session_store.h"
|
||||
|
||||
#define WEB_ADMIN_TICKET_LENGTH 64U
|
||||
#define WEB_ADMIN_TICKET_CAPACITY 2U
|
||||
#define WEB_ADMIN_TICKET_LIFETIME_US 30000000LL
|
||||
|
||||
typedef struct {
|
||||
uint32_t issued;
|
||||
uint32_t consumed;
|
||||
uint32_t rejected;
|
||||
uint32_t capacity_rejections;
|
||||
uint32_t active;
|
||||
size_t storage_bytes;
|
||||
bool ready;
|
||||
} web_admin_tickets_snapshot_t;
|
||||
|
||||
/* Trusted internal API, not HTTP authorization. Start is idempotent while ready;
|
||||
* stop wipes records. Neither lifecycle operation resets epochs or counters.
|
||||
* RNG must already be initialized. Exhausted generations fail closed. */
|
||||
void web_admin_tickets_start(void);
|
||||
void web_admin_tickets_stop(void);
|
||||
/* Only current password-authenticated administrators. No live eviction.
|
||||
* Output must not alias inputs; all 65 bytes are wiped on failure.
|
||||
* NO_MEM: capacity; INVALID_ARG: malformed input; INVALID_STATE: stopped,
|
||||
* stale, unauthorized or raced; FAIL: SHA failure; RNG errors propagate. */
|
||||
esp_err_t web_admin_tickets_issue(web_session_id_t id,
|
||||
const user_principal_t *principal, char token[WEB_ADMIN_TICKET_LENGTH + 1U]);
|
||||
/* Exact hex string (either case). Matching tickets are burned even for wrong
|
||||
* session/principal or failed currentness. NOT_FOUND means no live match.
|
||||
* Crypto failure cannot identify/burn a ticket. Success is not a session lease. */
|
||||
esp_err_t web_admin_tickets_consume(const char *token, web_session_id_t id,
|
||||
const user_principal_t *principal);
|
||||
/* Caller invalidates sessions FIRST. Nonzero ID takes precedence; otherwise
|
||||
* non-NULL username matches exact bytes/length; otherwise revoke all.
|
||||
* Every call cancels in-flight work, even when no record matches. */
|
||||
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *username,
|
||||
size_t length);
|
||||
/* Saturating lifetime counters; consumed counts burned matches, not admissions.
|
||||
* rejected counts failed issue/consume (including capacity). Snapshot prunes
|
||||
* expired/stale records; storage_bytes includes state and lock, no secrets. */
|
||||
void web_admin_tickets_get_snapshot(web_admin_tickets_snapshot_t *snapshot);
|
||||
+1940
-485
File diff suppressed because it is too large
Load Diff
+126
-32
@@ -1,42 +1,136 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Authenticated, bounded WebSocket frontend for the canonical admin console. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_http_server.h"
|
||||
#include "web_session_store.h"
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define WEB_ADMIN_TICKET_URI "/api/admin/ws-ticket"
|
||||
#define WEB_ADMIN_WS_URI "/ws/admin"
|
||||
#define WEB_ADMIN_MAX_SESSIONS 1U
|
||||
#define WEB_ADMIN_RX_CAPACITY 512U
|
||||
#define WEB_ADMIN_TX_CAPACITY 1024U
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "user_database.h"
|
||||
#include "web_session.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define WEB_ADMIN_TRANSPORT_MAX_SESSIONS 1U
|
||||
#define WEB_ADMIN_TRANSPORT_MAX_TICKETS 2U
|
||||
#define WEB_ADMIN_TRANSPORT_TICKET_LENGTH 32U
|
||||
#define WEB_ADMIN_TRANSPORT_TICKET_CAPACITY \
|
||||
(WEB_ADMIN_TRANSPORT_TICKET_LENGTH + 1U)
|
||||
#define WEB_ADMIN_TRANSPORT_TICKET_LIFETIME_SECONDS 30U
|
||||
#define WEB_ADMIN_TRANSPORT_MAX_RX_PAYLOAD 1024U
|
||||
#define WEB_ADMIN_TRANSPORT_PENDING_INPUT_CAPACITY 1024U
|
||||
#define WEB_ADMIN_TRANSPORT_TX_PAYLOAD_SIZE 512U
|
||||
|
||||
#define WEB_ADMIN_TRANSPORT_TICKET_URI "/api/admin/ws-ticket"
|
||||
#define WEB_ADMIN_TRANSPORT_WS_URI "/ws/admin"
|
||||
#define WEB_ADMIN_TRANSPORT_TICKET_QUERY_KEY "ticket"
|
||||
|
||||
typedef struct {
|
||||
bool initialized, attached, active, closing;
|
||||
uint32_t connections, disconnections, capacity_rejections, authorization_rejections;
|
||||
uint32_t protocol_errors, input_backpressure, send_failures, queue_failures;
|
||||
uint32_t rx_bytes, tx_bytes;
|
||||
size_t static_bytes, payload_bytes;
|
||||
esp_err_t last_error;
|
||||
uint64_t tickets_issued;
|
||||
uint64_t tickets_consumed;
|
||||
uint64_t tickets_rejected;
|
||||
uint64_t tickets_expired;
|
||||
|
||||
uint64_t connections;
|
||||
uint64_t connection_failures;
|
||||
uint64_t console_admission_failures;
|
||||
uint64_t disconnections;
|
||||
uint64_t session_revocations;
|
||||
uint64_t currentness_failures;
|
||||
|
||||
uint64_t rx_ws_frames_accepted;
|
||||
uint64_t rx_ws_frames_rejected;
|
||||
uint64_t rx_ws_bytes_accepted;
|
||||
uint64_t rx_ws_bytes_rejected;
|
||||
uint64_t input_bytes_fed;
|
||||
uint64_t input_feed_retries;
|
||||
uint64_t input_overflow_closes;
|
||||
|
||||
uint64_t tx_binary_frames;
|
||||
uint64_t tx_binary_bytes;
|
||||
uint64_t send_failures;
|
||||
uint64_t queue_failures;
|
||||
uint64_t protocol_errors;
|
||||
uint64_t close_requests;
|
||||
} web_admin_transport_counters_t;
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
bool principal_valid;
|
||||
bool input_pending;
|
||||
bool tx_pending;
|
||||
bool close_requested;
|
||||
int socket_fd;
|
||||
uint32_t session_id;
|
||||
uint32_t generation;
|
||||
size_t pending_input_bytes;
|
||||
user_role_t user_role;
|
||||
user_auth_method_t auth_method;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
||||
} web_admin_transport_session_snapshot_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
bool server_attached;
|
||||
bool accepting_connections;
|
||||
uint32_t active_sessions;
|
||||
uint32_t active_tickets;
|
||||
web_admin_transport_session_snapshot_t
|
||||
sessions[WEB_ADMIN_TRANSPORT_MAX_SESSIONS];
|
||||
web_admin_transport_counters_t counters;
|
||||
} web_admin_transport_snapshot_t;
|
||||
|
||||
/* Lifecycle caller serializes init/attach/detach/stopped. Optional PSRAM-only
|
||||
* payload allocation; no internal fallback, new task, broker client or dispatcher.
|
||||
* Timer only queues at most one poll; HTTPD owns all payload/IO/session cleanup. */
|
||||
/*
|
||||
* Allocate no heap objects and start the permanent static transport task.
|
||||
* CONFIG_HTTPD_WS_SUPPORT must be enabled. CONFIG_HTTPD_QUEUE_WORK_BLOCKING must
|
||||
* be disabled because that IDF mode can wait forever inside httpd_queue_work().
|
||||
*/
|
||||
esp_err_t web_admin_transport_init(void);
|
||||
esp_err_t web_admin_transport_attach(httpd_handle_t server);
|
||||
/* Disable admission and console access, then wait a bounded time for timer
|
||||
* submissions to finish. On timeout do NOT stop/free HTTPD; retry detach first. */
|
||||
esp_err_t web_admin_transport_detach(httpd_handle_t server);
|
||||
/* Call ONLY after successful httpd_ssl_stop, including partial startup cleanup.
|
||||
* Retires any unexecuted queued poll before allowing reuse of its static storage. */
|
||||
void web_admin_transport_stopped(httpd_handle_t server);
|
||||
|
||||
/* Ordinary HTTP routes, never register is_websocket=true: admission before 101.
|
||||
* These handlers enforce cookie/Origin/CSRF/role themselves. Binary frames carry
|
||||
* console bytes, final/unfragmented, at most RX_CAPACITY; no serial controls. */
|
||||
esp_err_t web_admin_transport_ticket_handler(httpd_req_t *request);
|
||||
esp_err_t web_admin_transport_upgrade_handler(httpd_req_t *request);
|
||||
/* Notification after authoritative store invalidation. id wins; else exact
|
||||
* username; else all. Safe before init. No socket calls from notifier context. */
|
||||
void web_admin_transport_revoke(web_session_id_t id, const uint8_t *username, size_t length);
|
||||
void web_admin_transport_get_snapshot(web_admin_transport_snapshot_t *snapshot);
|
||||
/* Attach after HTTPD start; detach before stopping that exact server. */
|
||||
esp_err_t web_admin_transport_attach_server(httpd_handle_t server);
|
||||
esp_err_t web_admin_transport_detach_server(httpd_handle_t server);
|
||||
/*
|
||||
* Complete a timed-out detach only after httpd_ssl_stop() has successfully
|
||||
* destroyed that exact server, so discarded queued work can be retired safely.
|
||||
*/
|
||||
esp_err_t web_admin_transport_finalize_stopped_server(httpd_handle_t server);
|
||||
|
||||
|
||||
/*
|
||||
* Convenience POST response helper for /api/admin/ws-ticket. Authentication and
|
||||
* CSRF validation remain outside this module: pass the principal and exact session
|
||||
* reference produced by the authenticated request. Register it as HTTP_POST.
|
||||
*/
|
||||
esp_err_t web_admin_transport_handle_authenticated_ticket_request(
|
||||
httpd_req_t *request, const user_principal_t *principal,
|
||||
const web_session_ref_t *session_reference);
|
||||
|
||||
/*
|
||||
* Handler for /ws/admin. Register as HTTP_GET with is_websocket=true and
|
||||
* handle_ws_control_frames=false. Only complete binary terminal frames are valid.
|
||||
*/
|
||||
esp_err_t web_admin_transport_ws_handler(httpd_req_t *request);
|
||||
|
||||
/* Snapshot and counters contain no ticket, digest, browser-session reference, or data. */
|
||||
esp_err_t web_admin_transport_get_snapshot(
|
||||
web_admin_transport_snapshot_t *snapshot);
|
||||
esp_err_t web_admin_transport_clear_counters(void);
|
||||
|
||||
/* Invalidate tickets and request closure for one exact browser login session. */
|
||||
esp_err_t web_admin_transport_revoke_session(
|
||||
const web_session_ref_t *session_reference);
|
||||
|
||||
/* Invalidate tickets/sessions for one account, or all admin web sessions. */
|
||||
esp_err_t web_admin_transport_revoke_user(const uint8_t *username,
|
||||
size_t username_length);
|
||||
esp_err_t web_admin_transport_revoke_sessions(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_auth_parse.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static void wipe(void *buffer, size_t length)
|
||||
{
|
||||
volatile uint8_t *p = buffer;
|
||||
while (length--) *p++ = 0;
|
||||
}
|
||||
|
||||
static bool alnum_ascii(unsigned char c)
|
||||
{
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9');
|
||||
}
|
||||
|
||||
static bool authority(const char *text, size_t length, char *out)
|
||||
{
|
||||
if (!text || !length || length > WEB_AUTH_ORIGIN_CAPACITY - 5U) return false;
|
||||
if (length >= 4U && memcmp(text + length - 4U, ":443", 4U) == 0) length -= 4U;
|
||||
if (!length || length > WEB_AUTH_ORIGIN_CAPACITY - 9U) return false;
|
||||
size_t label = 0;
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
unsigned char c = (unsigned char)text[i];
|
||||
if (c == '.') {
|
||||
if (!label || text[i - 1U] == '-') return false;
|
||||
label = 0;
|
||||
} else {
|
||||
if (!alnum_ascii(c) && c != '-') return false;
|
||||
if ((!label && c == '-') || ++label > 63U) return false;
|
||||
}
|
||||
out[i] = c >= 'A' && c <= 'Z' ? (char)(c + ('a' - 'A')) : (char)c;
|
||||
}
|
||||
if (!label || text[length - 1U] == '-') return false;
|
||||
out[length] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_origin(const char *host, size_t host_length,
|
||||
const char *origin, size_t origin_length,
|
||||
char canonical[WEB_AUTH_ORIGIN_CAPACITY])
|
||||
{
|
||||
if (!canonical) return false;
|
||||
memset(canonical, 0, WEB_AUTH_ORIGIN_CAPACITY);
|
||||
char other[WEB_AUTH_ORIGIN_CAPACITY] = {0};
|
||||
if (!origin || origin_length < 9U || origin_length > WEB_AUTH_ORIGIN_CAPACITY + 3U ||
|
||||
memcmp(origin, "https://", 8U) != 0 ||
|
||||
!authority(host, host_length, canonical + 8U) ||
|
||||
!authority(origin + 8U, origin_length - 8U, other) ||
|
||||
strcmp(canonical + 8U, other) != 0) {
|
||||
memset(canonical, 0, WEB_AUTH_ORIGIN_CAPACITY);
|
||||
return false;
|
||||
}
|
||||
memcpy(canonical, "https://", 8U);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool cookie_name_char(unsigned char c)
|
||||
{
|
||||
return alnum_ascii(c) || (c && strchr("!#$%&'*+-.^_`|~", c));
|
||||
}
|
||||
|
||||
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present)
|
||||
{
|
||||
if (!present) return false;
|
||||
*present = false;
|
||||
if (!token) return false;
|
||||
memset(token, 0, WEB_AUTH_TOKEN_LENGTH + 1U);
|
||||
if (!header || !name || !*name || !length || length > WEB_AUTH_COOKIE_HEADER_MAX)
|
||||
return false;
|
||||
size_t pos = 0, selected = 0, name_length = strlen(name);
|
||||
bool found = false;
|
||||
while (pos < length) {
|
||||
while (pos < length && header[pos] == ' ') ++pos;
|
||||
size_t start = pos;
|
||||
while (pos < length && cookie_name_char((unsigned char)header[pos])) ++pos;
|
||||
size_t key_length = pos - start;
|
||||
if (!key_length || pos == length || header[pos++] != '=') return false;
|
||||
size_t value = pos;
|
||||
while (pos < length && header[pos] != ';') {
|
||||
unsigned char c = (unsigned char)header[pos++];
|
||||
if (c < 0x21 || c > 0x7e || c == '"' || c == ',' || c == '\\') return false;
|
||||
}
|
||||
if (key_length == name_length && memcmp(header + start, name, key_length) == 0) {
|
||||
if (found || pos - value != WEB_AUTH_TOKEN_LENGTH) return false;
|
||||
for (size_t i = value; i < pos; ++i)
|
||||
if (!((header[i] >= '0' && header[i] <= '9') ||
|
||||
(header[i] >= 'a' && header[i] <= 'f'))) return false;
|
||||
found = true;
|
||||
selected = value;
|
||||
}
|
||||
if (pos < length && ++pos == length) return false;
|
||||
}
|
||||
if (found) memcpy(token, header + selected, WEB_AUTH_TOKEN_LENGTH);
|
||||
*present = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U])
|
||||
{
|
||||
bool present = false;
|
||||
return web_auth_parse_optional_cookie(header, length, name, token, &present) && present;
|
||||
}
|
||||
|
||||
typedef struct { const uint8_t *data; size_t length; size_t pos; } json_cursor_t;
|
||||
|
||||
static void whitespace(json_cursor_t *c)
|
||||
{
|
||||
while (c->pos < c->length) {
|
||||
uint8_t b = c->data[c->pos];
|
||||
if (b != ' ' && b != '\t' && b != '\r' && b != '\n') break;
|
||||
++c->pos;
|
||||
}
|
||||
}
|
||||
|
||||
static bool take(json_cursor_t *c, uint8_t byte)
|
||||
{
|
||||
whitespace(c);
|
||||
if (c->pos == c->length || c->data[c->pos] != byte) return false;
|
||||
++c->pos;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool hex4(json_cursor_t *c, uint32_t *value)
|
||||
{
|
||||
*value = 0;
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
if (c->pos == c->length) return false;
|
||||
uint8_t b = c->data[c->pos++];
|
||||
unsigned digit;
|
||||
if (b >= '0' && b <= '9') digit = b - '0';
|
||||
else if (b >= 'a' && b <= 'f') digit = b - 'a' + 10U;
|
||||
else if (b >= 'A' && b <= 'F') digit = b - 'A' + 10U;
|
||||
else return false;
|
||||
*value = (*value << 4) | digit;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool codepoint(json_cursor_t *c, uint32_t *value)
|
||||
{
|
||||
if (c->pos == c->length) return false;
|
||||
uint8_t b = c->data[c->pos++];
|
||||
if (b == '\\') {
|
||||
if (c->pos == c->length) return false;
|
||||
b = c->data[c->pos++];
|
||||
switch (b) {
|
||||
case '"': case '\\': case '/': *value = b; return true;
|
||||
case 'b': *value = 8; return true;
|
||||
case 'f': *value = 12; return true;
|
||||
case 'n': *value = 10; return true;
|
||||
case 'r': *value = 13; return true;
|
||||
case 't': *value = 9; return true;
|
||||
case 'u': break;
|
||||
default: return false;
|
||||
}
|
||||
if (!hex4(c, value)) return false;
|
||||
if (*value >= 0xd800 && *value <= 0xdbff) {
|
||||
uint32_t low;
|
||||
if (c->length - c->pos < 2U || c->data[c->pos++] != '\\' ||
|
||||
c->data[c->pos++] != 'u' || !hex4(c, &low) ||
|
||||
low < 0xdc00 || low > 0xdfff) return false;
|
||||
*value = 0x10000 + ((*value - 0xd800) << 10) + low - 0xdc00;
|
||||
}
|
||||
return *value && !(*value >= 0xd800 && *value <= 0xdfff);
|
||||
}
|
||||
if (b < 0x20) return false;
|
||||
if (b < 0x80) { *value = b; return true; }
|
||||
unsigned extra;
|
||||
uint32_t minimum;
|
||||
if (b >= 0xc2 && b <= 0xdf) { extra = 1; minimum = 0x80; *value = b & 0x1f; }
|
||||
else if (b >= 0xe0 && b <= 0xef) { extra = 2; minimum = 0x800; *value = b & 0x0f; }
|
||||
else if (b >= 0xf0 && b <= 0xf4) { extra = 3; minimum = 0x10000; *value = b & 7; }
|
||||
else return false;
|
||||
while (extra--) {
|
||||
if (c->pos == c->length) return false;
|
||||
b = c->data[c->pos++];
|
||||
if ((b & 0xc0) != 0x80) return false;
|
||||
*value = (*value << 6) | (b & 0x3f);
|
||||
}
|
||||
return *value >= minimum && *value <= 0x10ffff &&
|
||||
!(*value >= 0xd800 && *value <= 0xdfff);
|
||||
}
|
||||
|
||||
static bool string(json_cursor_t *c, uint8_t *out, size_t capacity, size_t *length)
|
||||
{
|
||||
*length = 0;
|
||||
if (!take(c, '"')) return false;
|
||||
while (c->pos < c->length && c->data[c->pos] != '"') {
|
||||
uint32_t cp;
|
||||
if (!codepoint(c, &cp)) return false;
|
||||
size_t bytes = cp < 0x80 ? 1U : cp < 0x800 ? 2U : cp < 0x10000 ? 3U : 4U;
|
||||
if (bytes > capacity - *length) return false;
|
||||
if (bytes == 1U) out[(*length)++] = (uint8_t)cp;
|
||||
else {
|
||||
out[(*length)++] = (uint8_t)((bytes == 2U ? 0xc0 : bytes == 3U ? 0xe0 : 0xf0) |
|
||||
(cp >> (6U * (bytes - 1U))));
|
||||
for (size_t i = bytes - 1U; i > 0; --i)
|
||||
out[(*length)++] = (uint8_t)(0x80 | ((cp >> (6U * (i - 1U))) & 0x3f));
|
||||
}
|
||||
}
|
||||
if (c->pos == c->length) return false;
|
||||
++c->pos;
|
||||
out[*length] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_json_string(const char *body, size_t length, size_t *position,
|
||||
uint8_t *output, size_t capacity, size_t *decoded_length)
|
||||
{
|
||||
if (output && capacity) wipe(output, capacity);
|
||||
if (decoded_length) *decoded_length = 0;
|
||||
if (!body || !position || *position > length || !output || !capacity || !decoded_length)
|
||||
return false;
|
||||
json_cursor_t c = { (const uint8_t *)body, length, *position };
|
||||
if (!string(&c, output, capacity - 1U, decoded_length)) {
|
||||
wipe(output, capacity);
|
||||
*decoded_length = 0;
|
||||
return false;
|
||||
}
|
||||
*position = c.pos;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_login(const char *body, size_t length,
|
||||
web_auth_credentials_t *credentials)
|
||||
{
|
||||
if (!credentials) return false;
|
||||
wipe(credentials, sizeof(*credentials));
|
||||
if (!body || !length || length > WEB_AUTH_LOGIN_BODY_MAX) return false;
|
||||
json_cursor_t c = { (const uint8_t *)body, length, 0 };
|
||||
unsigned seen = 0;
|
||||
if (!take(&c, '{')) return false;
|
||||
for (unsigned field = 0; field < 2; ++field) {
|
||||
uint8_t key[9] = {0};
|
||||
size_t key_length;
|
||||
if ((field && !take(&c, ',')) || !string(&c, key, 8U, &key_length) ||
|
||||
!take(&c, ':')) goto invalid;
|
||||
unsigned bit;
|
||||
uint8_t *output;
|
||||
size_t *output_length, capacity;
|
||||
if (key_length == 8U && memcmp(key, "username", 8U) == 0) {
|
||||
bit = 1; output = credentials->username;
|
||||
output_length = &credentials->username_length; capacity = WEB_AUTH_USERNAME_MAX;
|
||||
} else if (key_length == 8U && memcmp(key, "password", 8U) == 0) {
|
||||
bit = 2; output = credentials->password;
|
||||
output_length = &credentials->password_length; capacity = WEB_AUTH_PASSWORD_MAX;
|
||||
} else goto invalid;
|
||||
if ((seen & bit) || !string(&c, output, capacity, output_length)) goto invalid;
|
||||
seen |= bit;
|
||||
}
|
||||
if (!take(&c, '}')) goto invalid;
|
||||
whitespace(&c);
|
||||
if (c.pos == c.length && seen == 3U) return true;
|
||||
invalid:
|
||||
wipe(credentials, sizeof(*credentials));
|
||||
return false;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Private, allocation-free parsing only: these helpers do not authorize requests. */
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define WEB_AUTH_ORIGIN_CAPACITY 129U
|
||||
#define WEB_AUTH_COOKIE_HEADER_MAX 1024U
|
||||
#define WEB_AUTH_TOKEN_LENGTH 64U
|
||||
#define WEB_AUTH_LOGIN_BODY_MAX 512U
|
||||
#define WEB_AUTH_USERNAME_MAX 16U
|
||||
#define WEB_AUTH_PASSWORD_MAX 64U
|
||||
|
||||
typedef struct {
|
||||
size_t username_length;
|
||||
size_t password_length;
|
||||
uint8_t username[WEB_AUTH_USERNAME_MAX + 1U];
|
||||
uint8_t password[WEB_AUTH_PASSWORD_MAX + 1U];
|
||||
} web_auth_credentials_t;
|
||||
|
||||
/* Exact byte spans, not necessarily NUL-terminated. Inputs and output must not
|
||||
* alias. Failures clear output. Host supports ASCII DNS/IPv4 authorities only;
|
||||
* IPv6 literals are deliberately rejected until the device supports that route.
|
||||
* Only optional :443 is accepted. Origin is mandatory and must match Host.
|
||||
* HTTP callers must separately reject duplicate header lines, enforce methods,
|
||||
* body/content-type limits, Fetch Metadata and CSRF/session policy. */
|
||||
bool web_auth_parse_origin(const char *host, size_t host_length,
|
||||
const char *origin, size_t origin_length,
|
||||
char canonical[WEB_AUTH_ORIGIN_CAPACITY]);
|
||||
/* Extract exactly one named lowercase-hex token; malformed/duplicate or missing
|
||||
* selected cookie fails. Other cookies are syntax-checked but not retained.
|
||||
* This deliberately accepts only unquoted cookie values, including unrelated
|
||||
* cookies; quoted values fail closed. No whitespace inside a cookie pair.
|
||||
* name is a trusted, nonempty C string. Output is sensitive: wipe after use. */
|
||||
bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U]);
|
||||
/* As above, but a missing selected cookie is valid with present=false. This
|
||||
* lets HTTP policy distinguish absence from malformed/ambiguous cookies. */
|
||||
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present);
|
||||
/* Decode one string at *position (including optional JSON whitespace). Capacity
|
||||
* includes the terminator. Failure wipes output and leaves position unchanged.
|
||||
* Success output is sensitive; caller must wipe it. Same strict decoder as login. */
|
||||
bool web_auth_parse_json_string(const char *body, size_t length, size_t *position,
|
||||
uint8_t *output, size_t capacity, size_t *decoded_length);
|
||||
/* Exactly username/password string fields, either order. JSON escapes and valid
|
||||
* UTF-8 accepted; unknown/duplicate fields, NUL and malformed Unicode rejected.
|
||||
* Database credential policy remains authoritative. Caller must wipe BOTH the
|
||||
* original request body and successful credentials using secure_wipe(). */
|
||||
bool web_auth_parse_login(const char *body, size_t length,
|
||||
web_auth_credentials_t *credentials);
|
||||
+252
-92
@@ -1,32 +1,33 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* HTTPS lifecycle and TLS certificate commands. */
|
||||
/* Shared HTTPS lifecycle, legacy recovery credential, and certificate commands. */
|
||||
|
||||
#include "web_console.h"
|
||||
#include "admin_ssh_console.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_console.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "user_database.h"
|
||||
#include "web_admin_transport.h"
|
||||
#include "web_security.h"
|
||||
#include "web_serial_transport.h"
|
||||
#include "web_session.h"
|
||||
#include "web_server.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_admin_transport.h"
|
||||
#include "web_admin_tickets.h"
|
||||
#include "web_diagnostics.h"
|
||||
|
||||
static void print_usage(void)
|
||||
{
|
||||
printf("Usage:\n");
|
||||
printf(" web status|start|stop\n");
|
||||
printf(" web counters|clear-counters\n");
|
||||
printf(" web diagnostics enable|disable|show|clear\n");
|
||||
printf(" web credentials show\n");
|
||||
printf(" web credentials rotate --force\n");
|
||||
printf(" web certificate info\n");
|
||||
printf(" web certificate rotate --force\n");
|
||||
printf(" web reset --force (TLS certificate and private key only)\n");
|
||||
printf(" web reset --force\n");
|
||||
}
|
||||
|
||||
static void print_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH])
|
||||
@@ -36,30 +37,6 @@ static void print_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LENG
|
||||
}
|
||||
}
|
||||
|
||||
static void show_admin_transport(void)
|
||||
{
|
||||
web_admin_transport_snapshot_t admin;
|
||||
web_admin_tickets_snapshot_t tickets;
|
||||
web_admin_transport_get_snapshot(&admin);
|
||||
web_admin_tickets_get_snapshot(&tickets);
|
||||
printf("WebSocket admin: initialized=%s attached=%s active=%s/1 closing=%s init-error=%s\n",
|
||||
admin.initialized ? "yes" : "no", admin.attached ? "yes" : "no",
|
||||
admin.active ? "yes" : "no", admin.closing ? "yes" : "no", esp_err_to_name(admin.last_error));
|
||||
printf(" tickets=%" PRIu32 "/%u issued=%" PRIu32 " consumed=%" PRIu32
|
||||
" rejected=%" PRIu32 " capacity=%" PRIu32 "\n",
|
||||
tickets.active, WEB_ADMIN_TICKET_CAPACITY, tickets.issued, tickets.consumed,
|
||||
tickets.rejected, tickets.capacity_rejections);
|
||||
printf(" connected=%" PRIu32 " disconnected=%" PRIu32 " capacity=%" PRIu32
|
||||
" authorization=%" PRIu32 " protocol=%" PRIu32 " input-backpressure=%" PRIu32 "\n",
|
||||
admin.connections, admin.disconnections, admin.capacity_rejections,
|
||||
admin.authorization_rejections, admin.protocol_errors, admin.input_backpressure);
|
||||
printf(" rx-bytes=%" PRIu32 " tx-bytes=%" PRIu32 " send-failures=%" PRIu32
|
||||
" queue-failures=%" PRIu32 " static=%u ticket-storage=%u PSRAM-payload=%u bytes\n",
|
||||
admin.rx_bytes, admin.tx_bytes, admin.send_failures, admin.queue_failures,
|
||||
(unsigned)admin.static_bytes, (unsigned)tickets.storage_bytes, (unsigned)admin.payload_bytes);
|
||||
printf(" Admin counters are saturating lifetime counts (not reset by web clear-counters).\n");
|
||||
}
|
||||
|
||||
static int show_status(void)
|
||||
{
|
||||
web_server_snapshot_t snapshot;
|
||||
@@ -78,26 +55,45 @@ static int show_status(void)
|
||||
(unsigned int)snapshot.port,
|
||||
esp_err_to_name(snapshot.last_error));
|
||||
if (users_error == ESP_OK) {
|
||||
printf("Authentication: HTTPS cookie sessions via user database, users=%u admins=%u\n",
|
||||
printf("Authentication: HTTPS login sessions via user database, active=%" PRIu32 "/%u users=%u admins=%u\n",
|
||||
snapshot.active_sessions, WEB_SESSION_MAX_SESSIONS,
|
||||
(unsigned int)users.user_count, (unsigned int)users.admin_count);
|
||||
} else {
|
||||
printf("Authentication database unavailable: %s; use 'user recover --force'.\n",
|
||||
esp_err_to_name(users_error));
|
||||
}
|
||||
printf("Endpoints: GET /, GET /api/status, POST /api/ws-ticket, WSS /ws/serial\n");
|
||||
printf("Authentication routes: GET /login, GET /api/login-challenge, POST /api/login, GET /api/session, POST /api/logout\n");
|
||||
printf("Admin-only backend: POST /api/admin/ws-ticket, WSS /ws/admin (no normal UI entry)\n");
|
||||
show_admin_transport();
|
||||
web_cookie_auth_snapshot_t auth;
|
||||
web_cookie_auth_get_snapshot(&auth);
|
||||
web_session_store_snapshot_t sessions;
|
||||
if (web_session_store_get_snapshot(&sessions) == ESP_OK)
|
||||
printf("Cookie authentication: ready=%s sessions=%" PRIu32 "/4 challenges=%" PRIu32 "/4\n",
|
||||
auth.ready ? "yes" : "no", sessions.active, auth.active_challenges);
|
||||
printf("Login attempts=%" PRIu32 " invalid-credentials=%" PRIu32 " throttled=%" PRIu32
|
||||
" auth-capacity-rejections=%" PRIu32 " CSRF/origin-rejections=%" PRIu32 " logouts=%" PRIu32 "\n",
|
||||
auth.login_attempts, auth.login_failures, auth.throttled, auth.capacity_rejections,
|
||||
auth.security_rejections, auth.logouts);
|
||||
printf("Endpoints: login/logout/session/status, serial WSS, admin WSS, and typed admin Serial/Wi-Fi/broker APIs\n");
|
||||
|
||||
web_admin_transport_snapshot_t admin_transport;
|
||||
esp_err_t admin_transport_error =
|
||||
web_admin_transport_get_snapshot(&admin_transport);
|
||||
if (admin_transport_error == ESP_OK) {
|
||||
printf("WebSocket admin: attached=%s accepting=%s sessions=%" PRIu32
|
||||
"/%u tickets=%" PRIu32 "\n",
|
||||
admin_transport.server_attached ? "yes" : "no",
|
||||
admin_transport.accepting_connections ? "yes" : "no",
|
||||
admin_transport.active_sessions,
|
||||
WEB_ADMIN_TRANSPORT_MAX_SESSIONS,
|
||||
admin_transport.active_tickets);
|
||||
for (size_t index = 0U;
|
||||
index < WEB_ADMIN_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
const web_admin_transport_session_snapshot_t *session =
|
||||
&admin_transport.sessions[index];
|
||||
if (!session->active) {
|
||||
continue;
|
||||
}
|
||||
printf(" admin-web session=%" PRIu32 " generation=%" PRIu32
|
||||
" account=%s input-pending=%u tx-pending=%s closing=%s\n",
|
||||
session->session_id, session->generation,
|
||||
session->principal_valid ? session->username : "-",
|
||||
(unsigned int)session->pending_input_bytes,
|
||||
session->tx_pending ? "yes" : "no",
|
||||
session->close_requested ? "yes" : "no");
|
||||
}
|
||||
} else {
|
||||
printf("WebSocket admin transport unavailable: %s\n",
|
||||
esp_err_to_name(snapshot.admin_transport_error));
|
||||
}
|
||||
|
||||
web_serial_transport_snapshot_t transport;
|
||||
esp_err_t transport_error = web_serial_transport_get_snapshot(&transport);
|
||||
@@ -149,7 +145,6 @@ static int show_counters(void)
|
||||
return 1;
|
||||
}
|
||||
|
||||
show_admin_transport();
|
||||
const web_server_counters_t *counter = &snapshot.counters;
|
||||
printf("Lifecycle: starts=%" PRIu64 " start-failures=%" PRIu64
|
||||
" stops=%" PRIu64 "\n",
|
||||
@@ -162,6 +157,82 @@ static int show_counters(void)
|
||||
counter->authentication_failures, counter->root_requests,
|
||||
counter->status_requests, counter->ticket_requests,
|
||||
counter->asset_requests, counter->response_errors);
|
||||
printf("Login sessions: active=%" PRIu32 "/%u login=%" PRIu64
|
||||
" success=%" PRIu64 " failure=%" PRIu64 " throttled=%" PRIu64
|
||||
" logout=%" PRIu64 " session-info=%" PRIu64 "\n",
|
||||
snapshot.active_sessions, WEB_SESSION_MAX_SESSIONS,
|
||||
counter->login_requests, counter->login_successes,
|
||||
counter->login_failures, counter->login_throttled,
|
||||
counter->logout_requests, counter->session_requests);
|
||||
const web_session_counters_t *sessions = &snapshot.session_counters;
|
||||
printf("Session table: created=%" PRIu64 " create-failures=%" PRIu64
|
||||
" capacity=%" PRIu64 " authenticated=%" PRIu64
|
||||
" rejected=%" PRIu64 " expired=%" PRIu64 "\n",
|
||||
sessions->created, sessions->create_failures,
|
||||
sessions->capacity_failures, sessions->authenticated,
|
||||
sessions->rejected, sessions->expired);
|
||||
printf("Session lifecycle: stale-principal=%" PRIu64
|
||||
" destroyed=%" PRIu64 " revocations=%" PRIu64
|
||||
" csrf-accepted=%" PRIu64 " csrf-rejected=%" PRIu64 "\n",
|
||||
sessions->stale_principal, sessions->destroyed,
|
||||
sessions->revocations, sessions->csrf_accepted,
|
||||
sessions->csrf_rejected);
|
||||
printf("Request rejection: cookie=%" PRIu64 " origin=%" PRIu64
|
||||
" csrf=%" PRIu64 "\n",
|
||||
counter->cookie_rejections, counter->origin_rejections,
|
||||
counter->csrf_rejections);
|
||||
printf("Admin API: tickets=%" PRIu64 " auth-denied=%" PRIu64
|
||||
" requests=%" PRIu64 " rejected=%" PRIu64
|
||||
" operation-failures=%" PRIu64 "\n",
|
||||
counter->admin_ticket_requests,
|
||||
counter->admin_authorization_failures,
|
||||
counter->admin_api_requests,
|
||||
counter->admin_request_rejections,
|
||||
counter->admin_operation_failures);
|
||||
printf("Writer transfer: attempts=%" PRIu64 " success=%" PRIu64
|
||||
" conflicts=%" PRIu64 "\n",
|
||||
counter->writer_transfer_attempts,
|
||||
counter->writer_transfer_successes,
|
||||
counter->writer_transfer_conflicts);
|
||||
|
||||
web_admin_transport_snapshot_t admin_transport;
|
||||
esp_err_t admin_error =
|
||||
web_admin_transport_get_snapshot(&admin_transport);
|
||||
if (admin_error == ESP_OK) {
|
||||
const web_admin_transport_counters_t *admin =
|
||||
&admin_transport.counters;
|
||||
printf("Admin WebSocket tickets: issued=%" PRIu64
|
||||
" consumed=%" PRIu64 " rejected=%" PRIu64
|
||||
" expired=%" PRIu64 "\n",
|
||||
admin->tickets_issued, admin->tickets_consumed,
|
||||
admin->tickets_rejected, admin->tickets_expired);
|
||||
printf("Admin WebSocket sessions: connect=%" PRIu64
|
||||
" failures=%" PRIu64 " admission-failures=%" PRIu64
|
||||
" disconnect=%" PRIu64 " revocations=%" PRIu64
|
||||
" stale=%" PRIu64 "\n",
|
||||
admin->connections, admin->connection_failures,
|
||||
admin->console_admission_failures, admin->disconnections,
|
||||
admin->session_revocations, admin->currentness_failures);
|
||||
printf("Admin WebSocket I/O: rx-frames=%" PRIu64
|
||||
" rx-rejected=%" PRIu64 " rx-bytes=%" PRIu64
|
||||
" rx-bytes-rejected=%" PRIu64 " fed=%" PRIu64
|
||||
" retries=%" PRIu64 " overflow-close=%" PRIu64
|
||||
" tx-frames=%" PRIu64 " tx-bytes=%" PRIu64 "\n",
|
||||
admin->rx_ws_frames_accepted,
|
||||
admin->rx_ws_frames_rejected,
|
||||
admin->rx_ws_bytes_accepted,
|
||||
admin->rx_ws_bytes_rejected, admin->input_bytes_fed,
|
||||
admin->input_feed_retries, admin->input_overflow_closes,
|
||||
admin->tx_binary_frames, admin->tx_binary_bytes);
|
||||
printf("Admin WebSocket failures: send=%" PRIu64
|
||||
" queue=%" PRIu64 " protocol=%" PRIu64
|
||||
" closes=%" PRIu64 "\n",
|
||||
admin->send_failures, admin->queue_failures,
|
||||
admin->protocol_errors, admin->close_requests);
|
||||
} else {
|
||||
printf("Admin WebSocket counters unavailable: %s\n",
|
||||
esp_err_to_name(admin_error));
|
||||
}
|
||||
|
||||
web_serial_transport_snapshot_t transport;
|
||||
error = web_serial_transport_get_snapshot(&transport);
|
||||
@@ -203,6 +274,25 @@ static int show_counters(void)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int show_credentials(void)
|
||||
{
|
||||
web_security_credentials_t credentials;
|
||||
esp_err_t error = web_security_show_credentials(&credentials);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read web credentials: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("Username: %.*s\n", (int)credentials.username_length,
|
||||
credentials.username);
|
||||
printf("Password: %.*s\n", (int)credentials.password_length,
|
||||
credentials.password);
|
||||
printf("Phase 8B uses the user database for HTTPS and SSH authentication.\n");
|
||||
printf("This legacy credential is retained only for migration and physical recovery.\n");
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int show_certificate(void)
|
||||
{
|
||||
web_security_certificate_metadata_t metadata;
|
||||
@@ -233,23 +323,86 @@ static bool force_is_present(int argc, char **argv, int expected_argc)
|
||||
return argc == expected_argc && strcmp(argv[expected_argc - 1], "--force") == 0;
|
||||
}
|
||||
|
||||
static int restart_if_running(bool was_running)
|
||||
static int refresh_tls_after_material_change(
|
||||
const web_server_snapshot_t *before, bool ensure_running)
|
||||
{
|
||||
if (!was_running) {
|
||||
return 0;
|
||||
bool start_if_unchanged = ensure_running || before->desired_running;
|
||||
if (admin_ssh_console_dispatch_frontend() ==
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_WEB) {
|
||||
admin_ssh_deferred_action_type_t action = start_if_unchanged
|
||||
? ADMIN_SSH_DEFER_WEB_TLS_REFRESH_RUNNING
|
||||
: ADMIN_SSH_DEFER_WEB_TLS_REFRESH_STOPPED;
|
||||
esp_err_t error = admin_ssh_console_dispatch_defer(
|
||||
action, before->lifecycle_generation);
|
||||
if (error == ESP_OK) {
|
||||
printf("HTTPS TLS refresh scheduled after administrative output drains.\n");
|
||||
return 0;
|
||||
}
|
||||
printf("Could not schedule deferred HTTPS TLS refresh: %s; applying it now.\n",
|
||||
esp_err_to_name(error));
|
||||
}
|
||||
esp_err_t error = web_server_stop();
|
||||
|
||||
esp_err_t error = web_server_refresh_tls(
|
||||
before->lifecycle_generation, start_if_unchanged);
|
||||
if (error != ESP_OK) {
|
||||
printf("Material changed, but the old TLS server could not stop: %s\n",
|
||||
printf("Security material changed, but HTTPS could not apply it: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
error = web_server_start();
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void synchronize_migrated_user(
|
||||
const web_security_credentials_t *credentials)
|
||||
{
|
||||
const user_database_legacy_credentials_t legacy = {
|
||||
.username = (const uint8_t *)credentials->username,
|
||||
.username_length = credentials->username_length,
|
||||
.password = (const uint8_t *)credentials->password,
|
||||
.password_length = credentials->password_length,
|
||||
};
|
||||
bool synchronized = false;
|
||||
esp_err_t error = user_database_sync_legacy_credentials(&legacy, &synchronized);
|
||||
if (error != ESP_OK) {
|
||||
printf("Material changed, but HTTPS could not restart: %s\n",
|
||||
printf("Warning: migrated user synchronization failed: %s. Boot will retry a valid stored database; otherwise use 'user recover --force'.\n",
|
||||
esp_err_to_name(error));
|
||||
return;
|
||||
}
|
||||
if (synchronized) {
|
||||
(void)web_server_revoke_user(
|
||||
(const uint8_t *)credentials->username,
|
||||
credentials->username_length);
|
||||
(void)ssh_transport_revoke_user(
|
||||
(const uint8_t *)credentials->username,
|
||||
credentials->username_length);
|
||||
printf("The pre-bootstrap migrated user credential was synchronized.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
user_database_snapshot_t snapshot;
|
||||
if (user_database_get_snapshot(&snapshot) == ESP_OK &&
|
||||
snapshot.admin_bootstrapped) {
|
||||
printf("This legacy recovery credential is separate from role-based user passwords.\n");
|
||||
} else {
|
||||
printf("Warning: no matching pre-bootstrap migrated user was synchronized; establish an administrator with 'user bootstrap'.\n");
|
||||
}
|
||||
}
|
||||
|
||||
static int rotate_credentials(void)
|
||||
{
|
||||
web_security_credentials_t credentials;
|
||||
esp_err_t error = web_security_rotate_credentials(&credentials);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not rotate web credentials: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
synchronize_migrated_user(&credentials);
|
||||
printf("Legacy migration/recovery credential rotated and persisted.\n");
|
||||
printf("Username: %.*s\nPassword: %.*s\n",
|
||||
(int)credentials.username_length, credentials.username,
|
||||
(int)credentials.password_length, credentials.password);
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -267,32 +420,31 @@ static int rotate_certificate(void)
|
||||
return 1;
|
||||
}
|
||||
printf("Web certificate and private key rotated and persisted.\n");
|
||||
return restart_if_running(snapshot.running);
|
||||
return refresh_tls_after_material_change(&snapshot, false);
|
||||
}
|
||||
|
||||
static int reset_material(void)
|
||||
{
|
||||
web_server_snapshot_t snapshot;
|
||||
bool was_running = web_server_get_snapshot(&snapshot) == ESP_OK && snapshot.running;
|
||||
esp_err_t error = web_security_reset_all();
|
||||
esp_err_t error = web_server_get_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not inspect HTTPS runtime: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
web_security_credentials_t credentials;
|
||||
error = web_security_reset_all(&credentials);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not reset web security material: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("HTTPS certificate and private key replaced and persisted; user accounts unchanged.\n");
|
||||
if (was_running) {
|
||||
return restart_if_running(true);
|
||||
}
|
||||
|
||||
error = web_server_start();
|
||||
if (error != ESP_OK) {
|
||||
printf("Security material recovered, but HTTPS could not start: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS started with the recovered security material.\n");
|
||||
return 0;
|
||||
synchronize_migrated_user(&credentials);
|
||||
printf("Legacy recovery credential, HTTPS certificate, and HTTPS private key replaced and persisted.\n");
|
||||
printf("Username: %.*s\nPassword: %.*s\n",
|
||||
(int)credentials.username_length, credentials.username,
|
||||
(int)credentials.password_length, credentials.password);
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
return refresh_tls_after_material_change(&snapshot, true);
|
||||
}
|
||||
|
||||
static int command_web(int argc, char **argv)
|
||||
@@ -301,11 +453,6 @@ static int command_web(int argc, char **argv)
|
||||
print_usage();
|
||||
return 0;
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "diagnostics") == 0) {
|
||||
if (web_diagnostics_command(argv[2]) == 0) return 0;
|
||||
print_usage();
|
||||
return 1;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "status") == 0) {
|
||||
return show_status();
|
||||
}
|
||||
@@ -319,13 +466,21 @@ static int command_web(int argc, char **argv)
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "stop") == 0) {
|
||||
if (admin_ssh_console_dispatch_is_web()) {
|
||||
esp_err_t error = admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_STOP, 0U);
|
||||
if (admin_ssh_console_dispatch_frontend() ==
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_WEB) {
|
||||
web_server_snapshot_t snapshot;
|
||||
esp_err_t error = web_server_get_snapshot(&snapshot);
|
||||
if (error == ESP_OK) {
|
||||
error = admin_ssh_console_dispatch_defer(
|
||||
ADMIN_SSH_DEFER_WEB_STOP,
|
||||
snapshot.lifecycle_generation);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not schedule HTTPS stop: %s\n", esp_err_to_name(error));
|
||||
printf("Could not schedule HTTPS stop: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS stop scheduled after console output drains; both browser connections will close.\n");
|
||||
printf("HTTPS stop scheduled after administrative output drains.\n");
|
||||
return 0;
|
||||
}
|
||||
esp_err_t error = web_server_stop();
|
||||
@@ -344,6 +499,9 @@ static int command_web(int argc, char **argv)
|
||||
if (error == ESP_OK) {
|
||||
error = web_serial_transport_clear_counters();
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = web_admin_transport_clear_counters();
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not clear web counters: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
@@ -351,6 +509,18 @@ static int command_web(int argc, char **argv)
|
||||
printf("HTTPS and WebSocket counters cleared.\n");
|
||||
return 0;
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "credentials") == 0 &&
|
||||
strcmp(argv[2], "show") == 0) {
|
||||
return show_credentials();
|
||||
}
|
||||
if (strcmp(argv[1], "credentials") == 0 && argc >= 3 &&
|
||||
strcmp(argv[2], "rotate") == 0) {
|
||||
if (!force_is_present(argc, argv, 4)) {
|
||||
printf("Credential rotation requires: web credentials rotate --force\n");
|
||||
return 1;
|
||||
}
|
||||
return rotate_credentials();
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "certificate") == 0 &&
|
||||
strcmp(argv[2], "info") == 0) {
|
||||
return show_certificate();
|
||||
@@ -361,21 +531,11 @@ static int command_web(int argc, char **argv)
|
||||
printf("Certificate rotation requires: web certificate rotate --force\n");
|
||||
return 1;
|
||||
}
|
||||
if (admin_ssh_console_dispatch_is_web()) {
|
||||
esp_err_t error = admin_ssh_console_dispatch_defer(
|
||||
ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE, 0U);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not schedule HTTPS certificate rotation: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS certificate rotation scheduled after console output drains; both browser connections will close. Reconnect and verify the new certificate. If restart fails, use UART0 or SSH recovery.\n");
|
||||
return 0;
|
||||
}
|
||||
return rotate_certificate();
|
||||
}
|
||||
if (strcmp(argv[1], "reset") == 0) {
|
||||
if (!force_is_present(argc, argv, 3)) {
|
||||
printf("TLS-only certificate/private-key replacement requires: web reset --force\n");
|
||||
printf("Full material replacement requires: web reset --force\n");
|
||||
return 1;
|
||||
}
|
||||
return reset_material();
|
||||
@@ -389,7 +549,7 @@ esp_err_t web_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
.command = "web",
|
||||
.help = "Manage authenticated HTTPS and recover TLS certificate/private key",
|
||||
.help = "Manage authenticated HTTPS and recover web credentials/certificate",
|
||||
.hint = NULL,
|
||||
.func = &command_web,
|
||||
.argtable = NULL,
|
||||
|
||||
@@ -1,429 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_cookie_auth.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "secure_random.h"
|
||||
#include "web_auth_parse.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "web_login_ui.h"
|
||||
#include "web_serial_transport.h"
|
||||
|
||||
#define SESSION_COOKIE "__Host-sak-session"
|
||||
#define PRELOGIN_COOKIE "__Host-sak-prelogin"
|
||||
#define COOKIE_FLAGS "; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age="
|
||||
#define CHALLENGE_US 120000000LL
|
||||
#define WINDOW_US 60000000LL
|
||||
|
||||
typedef struct {
|
||||
int64_t expiry;
|
||||
uint8_t token_digest[32];
|
||||
uint8_t origin_digest[32];
|
||||
char csrf[65];
|
||||
} challenge_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static challenge_t s_challenges[4];
|
||||
static bool s_ready;
|
||||
static uint64_t s_epoch;
|
||||
static int64_t s_window;
|
||||
static unsigned s_attempts;
|
||||
static web_cookie_auth_snapshot_t s_counts;
|
||||
|
||||
static bool equal(const void *a, const void *b, size_t size)
|
||||
{
|
||||
const uint8_t *x = a, *y = b;
|
||||
unsigned difference = 0;
|
||||
for (size_t i = 0; i < size; ++i) difference |= x[i] ^ y[i];
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
static bool header(httpd_req_t *r, const char *name, char *out, size_t size)
|
||||
{
|
||||
size_t length = httpd_req_get_hdr_value_len(r, name);
|
||||
out[0] = 0;
|
||||
return length < size && httpd_req_get_hdr_value_str(r, name, out, size) == ESP_OK;
|
||||
}
|
||||
|
||||
static bool origin(httpd_req_t *r, bool required, char canonical[129])
|
||||
{
|
||||
char host[129] = {0}, supplied[137] = {0}, site[16] = {0};
|
||||
if (!header(r, "Host", host, sizeof(host))) return false;
|
||||
if (header(r, "Sec-Fetch-Site", site, sizeof(site))) {
|
||||
if (strcmp(site, "same-origin") && strcmp(site, "none")) return false;
|
||||
} else if (httpd_req_get_hdr_value_len(r, "Sec-Fetch-Site")) return false;
|
||||
if (!header(r, "Origin", supplied, sizeof(supplied))) {
|
||||
if (required || httpd_req_get_hdr_value_len(r, "Origin")) return false;
|
||||
int length = snprintf(supplied, sizeof(supplied), "https://%s", host);
|
||||
if (length < 0 || (size_t)length >= sizeof(supplied)) return false;
|
||||
}
|
||||
return web_auth_parse_origin(host, strlen(host), supplied, strlen(supplied), canonical);
|
||||
}
|
||||
|
||||
static bool cookie(httpd_req_t *r, const char *name, char token[65])
|
||||
{
|
||||
char cookies[1025] = {0};
|
||||
bool valid = header(r, "Cookie", cookies, sizeof(cookies)) &&
|
||||
web_auth_parse_cookie(cookies, strlen(cookies), name, token);
|
||||
secure_wipe(cookies, sizeof(cookies));
|
||||
return valid;
|
||||
}
|
||||
|
||||
static bool cookies_valid(httpd_req_t *r)
|
||||
{
|
||||
char cookies[1025] = {0}, token[65] = {0};
|
||||
bool present;
|
||||
esp_err_t error = httpd_req_get_hdr_value_str(r, "Cookie", cookies, sizeof(cookies));
|
||||
bool valid = error == ESP_ERR_NOT_FOUND ||
|
||||
(error == ESP_OK && httpd_req_get_hdr_value_len(r, "Cookie") < sizeof(cookies) &&
|
||||
web_auth_parse_optional_cookie(cookies, strlen(cookies), SESSION_COOKIE, token, &present) &&
|
||||
web_auth_parse_optional_cookie(cookies, strlen(cookies), PRELOGIN_COOKIE, token, &present));
|
||||
secure_wipe(cookies, sizeof(cookies));
|
||||
secure_wipe(token, sizeof(token));
|
||||
return valid;
|
||||
}
|
||||
|
||||
static esp_err_t response(httpd_req_t *r, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(r, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(r, "application/json; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Frame-Options", "DENY");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(r, body);
|
||||
/* Never let HTTPD drain an attacker-controlled rejected request body. */
|
||||
return web_httpd_unread_body(r) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
static esp_err_t failure(httpd_req_t *r, const char *status, const char *code)
|
||||
{
|
||||
bool security = !strcmp(status, "403 Forbidden");
|
||||
bool credentials = !strcmp(code, "invalid_credentials");
|
||||
bool throttled = !strcmp(code, "throttled");
|
||||
bool full = !strcmp(code, "capacity");
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_counts.security_rejections += security;
|
||||
s_counts.login_failures += credentials;
|
||||
s_counts.throttled += throttled;
|
||||
s_counts.capacity_rejections += full;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
char body[80];
|
||||
snprintf(body, sizeof(body), "{\"error\":\"%s\"}", code);
|
||||
return response(r, status, body);
|
||||
}
|
||||
|
||||
static esp_err_t capacity(httpd_req_t *r)
|
||||
{
|
||||
if (httpd_resp_set_hdr(r, "Retry-After", "5") != ESP_OK) return ESP_FAIL;
|
||||
return failure(r, "503 Service Unavailable", "capacity");
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_start(void)
|
||||
{
|
||||
esp_err_t error = web_session_store_init();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (error == ESP_OK && s_epoch != UINT64_MAX) {
|
||||
++s_epoch;
|
||||
secure_wipe(s_challenges, sizeof(s_challenges));
|
||||
s_window = 0;
|
||||
s_attempts = 0;
|
||||
s_ready = true;
|
||||
} else {
|
||||
s_ready = false;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_cookie_auth_stop(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_ready = false;
|
||||
if (s_epoch != UINT64_MAX) ++s_epoch;
|
||||
secure_wipe(s_challenges, sizeof(s_challenges));
|
||||
s_window = 0;
|
||||
s_attempts = 0;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
web_session_store_stop();
|
||||
}
|
||||
|
||||
void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot) return;
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
*snapshot = s_counts;
|
||||
snapshot->ready = s_ready;
|
||||
snapshot->active_challenges = 0;
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
if (s_challenges[i].expiry <= now) secure_wipe(&s_challenges[i], sizeof(s_challenges[i]));
|
||||
else ++snapshot->active_challenges;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_cookie_auth_clear_counters(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
memset(&s_counts, 0, sizeof(s_counts));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static esp_err_t require(httpd_req_t *r, bool mutation, bool upgrade, size_t body_limit,
|
||||
web_session_view_t *view, bool *allowed)
|
||||
{
|
||||
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
|
||||
*allowed = false;
|
||||
memset(view, 0, sizeof(*view));
|
||||
if (!web_httpd_headers_valid(r) || !cookies_valid(r) || (!upgrade && strchr(r->uri, '?')) ||
|
||||
r->content_len > body_limit || r->method != (mutation ? HTTP_POST : HTTP_GET))
|
||||
return failure(r, "400 Bad Request", "invalid_request");
|
||||
if (!origin(r, mutation || upgrade, canonical))
|
||||
return failure(r, "403 Forbidden", "origin");
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) return failure(r, "503 Service Unavailable", "unavailable");
|
||||
esp_err_t error = ESP_ERR_NOT_FOUND;
|
||||
if (cookie(r, SESSION_COOKIE, token))
|
||||
error = web_session_store_lookup(token, strlen(token), canonical, strlen(canonical), view);
|
||||
secure_wipe(token, sizeof(token));
|
||||
if (error != ESP_OK) {
|
||||
if (error != ESP_ERR_NOT_FOUND)
|
||||
return failure(r, "503 Service Unavailable", "unavailable");
|
||||
if (!strcmp(r->uri, "/")) {
|
||||
if (httpd_resp_set_hdr(r, "Location", "/login") != ESP_OK) return ESP_FAIL;
|
||||
return response(r, "303 See Other", "");
|
||||
}
|
||||
return failure(r, "401 Unauthorized", "authentication_required");
|
||||
}
|
||||
if (mutation && (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) ||
|
||||
strlen(csrf) != 64U || !equal(csrf, view->csrf, 64U))) {
|
||||
secure_wipe(csrf, sizeof(csrf));
|
||||
secure_wipe(view, sizeof(*view));
|
||||
return failure(r, "403 Forbidden", "csrf");
|
||||
}
|
||||
secure_wipe(csrf, sizeof(csrf));
|
||||
*allowed = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
|
||||
web_session_view_t *view, bool *allowed)
|
||||
{
|
||||
return require(r, mutation, upgrade, 0, 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 require(r, true, false, body_limit, view, allowed);
|
||||
}
|
||||
|
||||
static bool secret(char out[65])
|
||||
{
|
||||
uint8_t bytes[32];
|
||||
bool ok = secure_random_fill(bytes, sizeof(bytes)) == ESP_OK;
|
||||
if (ok) {
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < sizeof(bytes); ++i) {
|
||||
out[2*i] = hex[bytes[i] >> 4];
|
||||
out[2*i+1] = hex[bytes[i] & 15];
|
||||
}
|
||||
out[64] = 0;
|
||||
}
|
||||
secure_wipe(bytes, sizeof(bytes));
|
||||
return ok;
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_handler(httpd_req_t *r)
|
||||
{
|
||||
bool login = !strcmp(r->uri, "/api/login");
|
||||
bool bootstrap = !strcmp(r->uri, "/api/login-challenge");
|
||||
bool logout = !strcmp(r->uri, "/api/logout");
|
||||
bool document = !strcmp(r->uri, "/login");
|
||||
web_session_view_t view = {0};
|
||||
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
|
||||
char set_cookie[180] = {0}, body[513] = {0};
|
||||
web_auth_credentials_t credentials = {0};
|
||||
challenge_t candidate = {0};
|
||||
uint8_t digest[32] = {0}, origin_digest[32] = {0};
|
||||
esp_err_t result = ESP_FAIL;
|
||||
const char *status = "400 Bad Request", *code = "invalid_request";
|
||||
bool consumed = false, allowed = false;
|
||||
bool challenge_published = false;
|
||||
uint64_t epoch;
|
||||
int selected = -1;
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_ready;
|
||||
epoch = s_epoch;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
|
||||
if (!web_httpd_headers_valid(r) || !cookies_valid(r) || strchr(r->uri, '?') ||
|
||||
r->method != ((login || logout) ? HTTP_POST : HTTP_GET) ||
|
||||
(!login && r->content_len)) goto deny;
|
||||
if (document) { result = web_login_ui_send_response(r); goto cleanup; }
|
||||
if (!login && !bootstrap) {
|
||||
result = web_cookie_auth_require(r, logout, false, &view, &allowed);
|
||||
if (!allowed) goto cleanup;
|
||||
if (logout) {
|
||||
web_serial_transport_revoke_web_session(view.id);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
++s_counts.logouts;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
result = httpd_resp_set_hdr(r, "Set-Cookie", SESSION_COOKIE "=" COOKIE_FLAGS "0");
|
||||
if (result != ESP_OK) goto cleanup;
|
||||
result = response(r, "204 No Content", "");
|
||||
} else {
|
||||
/* Database usernames are restricted ASCII; encode nevertheless. */
|
||||
char username[97] = {0};
|
||||
size_t used = 0;
|
||||
for (size_t i = 0; i < view.principal.username_length && i < 16; ++i)
|
||||
used += (size_t)snprintf(username + used, sizeof(username) - used,
|
||||
"\\u%04x", (unsigned char)view.principal.username[i]);
|
||||
int64_t remaining = (view.expires_at_us - esp_timer_get_time()) / 1000000LL;
|
||||
snprintf(body, sizeof(body), "{\"username\":\"%s\",\"role\":\"%s\",\"csrf\":\"%s\",\"expires_in\":%lld}",
|
||||
username, view.principal.role == USER_ROLE_ADMIN ? "admin" : "user", view.csrf,
|
||||
(long long)(remaining > 0 ? remaining : 0));
|
||||
result = response(r, "200 OK", body);
|
||||
}
|
||||
goto cleanup;
|
||||
}
|
||||
if (!origin(r, login, canonical)) { status = "403 Forbidden"; code = "origin"; goto deny; }
|
||||
if (mbedtls_sha256((const uint8_t *)canonical, strlen(canonical), origin_digest, 0)) goto deny;
|
||||
if (bootstrap) {
|
||||
char flag[2];
|
||||
if (!header(r, "X-Login-Bootstrap", flag, sizeof(flag)) || strcmp(flag, "1")) {
|
||||
status = "403 Forbidden"; code = "csrf"; goto deny;
|
||||
}
|
||||
} else {
|
||||
char type[40];
|
||||
if (!header(r, "Content-Type", type, sizeof(type)) ||
|
||||
(strcmp(type, "application/json") && strcmp(type, "application/json; charset=utf-8"))) {
|
||||
status = "415 Unsupported Media Type"; code = "content_type"; goto deny;
|
||||
}
|
||||
if (!r->content_len || r->content_len > 512U) {
|
||||
status = "413 Payload Too Large"; code = "body_size"; goto deny;
|
||||
}
|
||||
if (cookie(r, SESSION_COOKIE, token) &&
|
||||
web_session_store_lookup(token, 64, canonical, strlen(canonical), &view) == ESP_OK) {
|
||||
status = "409 Conflict"; code = "already_authenticated"; goto deny;
|
||||
}
|
||||
if (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) || strlen(csrf) != 64) {
|
||||
status = "403 Forbidden"; code = "csrf"; goto deny;
|
||||
}
|
||||
}
|
||||
bool has_cookie = cookie(r, PRELOGIN_COOKIE, token);
|
||||
if (has_cookie && mbedtls_sha256((const uint8_t *)token, 64, digest, 0)) goto deny;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
challenge_t *entry = &s_challenges[i];
|
||||
if (entry->expiry <= now) secure_wipe(entry, sizeof(*entry));
|
||||
if (s_ready && epoch == s_epoch && entry->expiry && has_cookie &&
|
||||
equal(entry->token_digest, digest, 32) && equal(entry->origin_digest, origin_digest, 32)) {
|
||||
if (bootstrap || equal(entry->csrf, csrf, 64)) {
|
||||
candidate = *entry;
|
||||
selected = i;
|
||||
if (login) { secure_wipe(entry, sizeof(*entry)); consumed = true; }
|
||||
}
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (bootstrap) {
|
||||
bool fresh = selected < 0;
|
||||
if (fresh) {
|
||||
if (!secret(token) || !secret(candidate.csrf) ||
|
||||
mbedtls_sha256((const uint8_t *)token, 64, candidate.token_digest, 0)) {
|
||||
status = "503 Service Unavailable"; code = "unavailable"; goto deny;
|
||||
}
|
||||
memcpy(candidate.origin_digest, origin_digest, 32);
|
||||
candidate.expiry = now + CHALLENGE_US;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_ready && epoch == s_epoch) for (int i = 0; i < 4; ++i) {
|
||||
if (!s_challenges[i].expiry) {
|
||||
s_challenges[i] = candidate; selected = i; challenge_published = true; break;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (selected < 0) { result = capacity(r); goto cleanup; }
|
||||
snprintf(set_cookie, sizeof(set_cookie), PRELOGIN_COOKIE "=%s" COOKIE_FLAGS "120", token);
|
||||
if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) goto cleanup;
|
||||
}
|
||||
snprintf(body, sizeof(body), "{\"csrf\":\"%s\",\"expires_in\":%lld}", candidate.csrf,
|
||||
(long long)((candidate.expiry - now) / 1000000LL));
|
||||
result = response(r, "200 OK", body);
|
||||
goto cleanup;
|
||||
}
|
||||
if (!consumed) { status = "403 Forbidden"; code = "challenge_expired"; goto deny; }
|
||||
if (httpd_resp_set_hdr(r, "Set-Cookie", PRELOGIN_COOKIE "=" COOKIE_FLAGS "0") != ESP_OK) goto cleanup;
|
||||
size_t received = 0;
|
||||
int64_t deadline = now + 3000000LL;
|
||||
while (received < r->content_len && esp_timer_get_time() < deadline) {
|
||||
int count = httpd_req_recv(r, body + received, r->content_len - received);
|
||||
if (count <= 0) goto deny;
|
||||
received += (size_t)count;
|
||||
}
|
||||
if (received != r->content_len || !web_auth_parse_login(body, received, &credentials)) goto deny;
|
||||
now = esp_timer_get_time();
|
||||
unsigned attempts;
|
||||
int64_t retry;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
ready = s_ready && epoch == s_epoch;
|
||||
if (now - s_window >= WINDOW_US) { s_window = now; s_attempts = 0; }
|
||||
attempts = s_attempts;
|
||||
if (ready && attempts < 5) { ++s_attempts; ++s_counts.login_attempts; }
|
||||
retry = (s_window + WINDOW_US - now + 999999LL) / 1000000LL;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
|
||||
if (attempts >= 5) {
|
||||
char seconds[16];
|
||||
snprintf(seconds, sizeof(seconds), "%lld", (long long)retry);
|
||||
if (httpd_resp_set_hdr(r, "Retry-After", seconds) != ESP_OK) goto cleanup;
|
||||
result = failure(r, "429 Too Many Requests", "throttled");
|
||||
goto cleanup;
|
||||
}
|
||||
bool authenticated = false;
|
||||
user_principal_t principal = {0};
|
||||
esp_err_t error = user_database_authenticate_password(credentials.username, credentials.username_length,
|
||||
credentials.password, credentials.password_length, &principal, &authenticated);
|
||||
secure_wipe(body, sizeof(body));
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
if (error == ESP_OK && authenticated)
|
||||
error = web_session_store_issue(&principal, canonical, strlen(canonical), token, &view);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
if (error == ESP_ERR_NO_MEM) { result = capacity(r); goto cleanup; }
|
||||
if (error != ESP_OK) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
|
||||
if (!authenticated) { status = "401 Unauthorized"; code = "invalid_credentials"; goto deny; }
|
||||
snprintf(set_cookie, sizeof(set_cookie), SESSION_COOKIE "=%s" COOKIE_FLAGS "3600", token);
|
||||
if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) {
|
||||
web_session_store_invalidate(view.id); goto cleanup;
|
||||
}
|
||||
result = response(r, "200 OK", "{\"authenticated\":true}");
|
||||
if (result != ESP_OK) web_session_store_invalidate(view.id);
|
||||
goto cleanup;
|
||||
deny:
|
||||
result = failure(r, status, code);
|
||||
cleanup:
|
||||
if (challenge_published && result != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (epoch == s_epoch && selected >= 0 &&
|
||||
equal(s_challenges[selected].token_digest, candidate.token_digest, 32))
|
||||
secure_wipe(&s_challenges[selected], sizeof(s_challenges[selected]));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
web_httpd_wipe_request(r, web_httpd_unread_body(r));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
secure_wipe(token, sizeof(token));
|
||||
secure_wipe(csrf, sizeof(csrf));
|
||||
secure_wipe(set_cookie, sizeof(set_cookie));
|
||||
secure_wipe(body, sizeof(body));
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
secure_wipe(digest, sizeof(digest));
|
||||
return result;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include "esp_http_server.h"
|
||||
#include "web_session_store.h"
|
||||
|
||||
esp_err_t web_cookie_auth_start(void);
|
||||
void web_cookie_auth_stop(void);
|
||||
typedef struct {
|
||||
uint32_t login_attempts, login_failures, throttled, capacity_rejections;
|
||||
uint32_t security_rejections, logouts, active_challenges;
|
||||
bool ready;
|
||||
} web_cookie_auth_snapshot_t;
|
||||
void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot);
|
||||
void web_cookie_auth_clear_counters(void);
|
||||
/* Sends an error on denial, with allowed=false. View is caller-wiped. */
|
||||
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, 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);
|
||||
@@ -1,233 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Post-TLS observation only. Never retain request data or replace TLS cleanup. */
|
||||
#include "web_diagnostics.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define DIAG_SOCKETS 6U
|
||||
#define DIAG_EVENTS 32U
|
||||
|
||||
typedef struct {
|
||||
const esp_tls_t *tls; /* Identity only, never dereferenced or printed. */
|
||||
uint64_t seq;
|
||||
int64_t opened_us;
|
||||
int fd;
|
||||
unsigned kind; /* 0 ordinary, 1 serial WS, 2 admin WS */
|
||||
} connection_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t id, seq;
|
||||
int64_t at_us, elapsed_us;
|
||||
uint32_t free_bytes[3], largest[3], stack_bytes;
|
||||
int fd, result;
|
||||
unsigned event, route, ordinary, serial, admin;
|
||||
} trace_t;
|
||||
|
||||
enum { TLS_OPEN, TLS_CLOSE, ENTER, RESULT };
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static connection_t s_connections[DIAG_SOCKETS];
|
||||
static trace_t s_events[DIAG_EVENTS];
|
||||
static bool s_enabled;
|
||||
static uint64_t s_connection_seq, s_event_seq, s_epoch;
|
||||
static uint32_t s_overwritten, s_unmatched, s_lost;
|
||||
static unsigned s_count, s_next;
|
||||
|
||||
static void increment(uint32_t *value)
|
||||
{
|
||||
if (*value != UINT32_MAX) ++*value;
|
||||
}
|
||||
|
||||
static void occupancy(trace_t *event)
|
||||
{
|
||||
for (unsigned i = 0; i < DIAG_SOCKETS; ++i) {
|
||||
if (!s_connections[i].seq) continue;
|
||||
if (s_connections[i].kind == 1) ++event->serial;
|
||||
else if (s_connections[i].kind == 2) ++event->admin;
|
||||
else ++event->ordinary;
|
||||
}
|
||||
}
|
||||
|
||||
/* Called by HTTPD only. Expensive capability scans stay outside the portMUX.
|
||||
* Epoch rejects a sample crossing clear/disable/enable. Sequence fences fd reuse. */
|
||||
static void record(connection_t connection, unsigned event, unsigned route,
|
||||
int result, int64_t elapsed_us, uint64_t epoch)
|
||||
{
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
bool enabled = s_enabled && s_epoch == epoch;
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
if (!enabled) return;
|
||||
trace_t row = {.seq = connection.seq, .fd = connection.fd,
|
||||
.at_us = esp_timer_get_time(), .elapsed_us = elapsed_us,
|
||||
.event = event, .route = route, .result = result};
|
||||
const uint32_t caps[] = {MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT,
|
||||
MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA,
|
||||
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT};
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
row.free_bytes[i] = heap_caps_get_free_size(caps[i]);
|
||||
row.largest[i] = heap_caps_get_largest_free_block(caps[i]);
|
||||
}
|
||||
/* ESP-IDF FreeRTOS reports minimum-free stack in bytes, not vanilla words. */
|
||||
row.stack_bytes = uxTaskGetStackHighWaterMark(NULL);
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
if (s_enabled && s_epoch == epoch && s_event_seq != UINT64_MAX) {
|
||||
row.id = ++s_event_seq;
|
||||
occupancy(&row);
|
||||
s_events[s_next] = row;
|
||||
s_next = (s_next + 1U) % DIAG_EVENTS;
|
||||
if (s_count < DIAG_EVENTS) ++s_count;
|
||||
else increment(&s_overwritten);
|
||||
}
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_diagnostics_tls(esp_https_server_user_cb_arg_t *arg)
|
||||
{
|
||||
if (!arg || !arg->tls) return;
|
||||
connection_t connection = {0};
|
||||
int fd = -1;
|
||||
if (arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE &&
|
||||
(esp_tls_get_conn_sockfd(arg->tls, &fd) != ESP_OK || fd < 0)) {
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
increment(&s_lost);
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
return;
|
||||
}
|
||||
int64_t now = esp_timer_get_time();
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
uint64_t epoch = s_epoch;
|
||||
if (arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE) {
|
||||
unsigned i;
|
||||
for (i = 0; i < DIAG_SOCKETS; ++i)
|
||||
if (s_connections[i].seq && (s_connections[i].tls == arg->tls ||
|
||||
s_connections[i].fd == fd)) break;
|
||||
/* Duplicate notifications are not new connections. */
|
||||
if (i != DIAG_SOCKETS) {
|
||||
increment(&s_unmatched);
|
||||
} else {
|
||||
for (i = 0; i < DIAG_SOCKETS; ++i) if (!s_connections[i].seq) break;
|
||||
if (i < DIAG_SOCKETS && s_connection_seq != UINT64_MAX) {
|
||||
connection = (connection_t){.tls = arg->tls, .fd = fd,
|
||||
.seq = ++s_connection_seq, .opened_us = now};
|
||||
s_connections[i] = connection;
|
||||
} else increment(&s_lost);
|
||||
}
|
||||
} else if (arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CLOSE) {
|
||||
unsigned i;
|
||||
for (i = 0; i < DIAG_SOCKETS; ++i)
|
||||
if (s_connections[i].seq && s_connections[i].tls == arg->tls) break;
|
||||
if (i < DIAG_SOCKETS) {
|
||||
connection = s_connections[i];
|
||||
memset(&s_connections[i], 0, sizeof(s_connections[i]));
|
||||
} else increment(&s_unmatched);
|
||||
}
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
if (connection.seq)
|
||||
record(connection, arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE ? TLS_OPEN : TLS_CLOSE,
|
||||
0, 0, now - connection.opened_us, epoch);
|
||||
}
|
||||
|
||||
esp_err_t web_diagnostics_handler(httpd_req_t *request, web_diag_route_t route,
|
||||
esp_err_t (*handler)(httpd_req_t *))
|
||||
{
|
||||
int fd = httpd_req_to_sockfd(request);
|
||||
connection_t connection = {.fd = fd};
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
uint64_t epoch = s_epoch;
|
||||
bool enabled = s_enabled;
|
||||
for (unsigned i = 0; i < DIAG_SOCKETS; ++i)
|
||||
if (s_connections[i].seq && s_connections[i].fd == fd) connection = s_connections[i];
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
record(connection, ENTER, route, 0, 0, epoch);
|
||||
int64_t start = enabled ? esp_timer_get_time() : 0;
|
||||
esp_err_t result = handler(request);
|
||||
int64_t elapsed = enabled ? esp_timer_get_time() - start : 0;
|
||||
if (route == WEB_DIAG_SERIAL_UPGRADE || route == WEB_DIAG_ADMIN_UPGRADE) {
|
||||
bool upgraded = httpd_ws_get_fd_info(request->handle, fd) == HTTPD_WS_CLIENT_WEBSOCKET;
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
for (unsigned i = 0; i < DIAG_SOCKETS; ++i)
|
||||
if (connection.seq && s_connections[i].seq == connection.seq && upgraded)
|
||||
s_connections[i].kind = route == WEB_DIAG_SERIAL_UPGRADE ? 1U : 2U;
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
record(connection, RESULT, route, result, elapsed, epoch);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void show(void)
|
||||
{
|
||||
connection_t connections[DIAG_SOCKETS];
|
||||
trace_t counts = {0};
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
memcpy(connections, s_connections, sizeof(connections));
|
||||
occupancy(&counts);
|
||||
uint64_t last = s_event_seq;
|
||||
unsigned count = s_count;
|
||||
bool enabled = s_enabled;
|
||||
uint32_t overwritten = s_overwritten, unmatched = s_unmatched, lost = s_lost;
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
int64_t now = esp_timer_get_time();
|
||||
printf("Web diagnostics %s; post-TLS occupancy=%u/6 ordinary=%u serial=%u admin=%u\n",
|
||||
enabled ? "enabled" : "disabled", counts.ordinary + counts.serial + counts.admin,
|
||||
counts.ordinary, counts.serial, counts.admin);
|
||||
printf("snapshot_us=%" PRId64 " retained=%u/32 overwritten=%" PRIu32 " unmatched=%" PRIu32 " lost=%" PRIu32 "\n",
|
||||
now, count, overwritten, unmatched, lost);
|
||||
printf("No preaccept/TLS-failure timing; occupancy excludes in-progress TLS. rc is handler return, NOT HTTP status.\n");
|
||||
for (unsigned i = 0; i < DIAG_SOCKETS; ++i) {
|
||||
connection_t c = connections[i];
|
||||
if (c.seq) printf("live fd=%d conn=%" PRIu64 " kind=%u opened_us=%" PRId64 " age_us=%" PRId64 "\n",
|
||||
c.fd, c.seq, c.kind, c.opened_us, now - c.opened_us);
|
||||
}
|
||||
printf("events: open/close/enter/result; routes: serial-ticket/admin-ticket/serial-upgrade/admin-upgrade; heap pairs free/largest internal,DMA,PSRAM bytes; stack=HTTPD minimum-free bytes\n");
|
||||
const char *const events[] = {"open", "close", "enter", "result"};
|
||||
const char *const routes[] = {"serial-ticket", "admin-ticket", "serial-upgrade", "admin-upgrade"};
|
||||
/* Copy one immutable-ID-qualified row at a time; never hold a lock while printing.
|
||||
* Concurrent overwrite/clear can omit rows, but cannot turn show into an endless stream. */
|
||||
for (unsigned n = 0; n < count; ++n) {
|
||||
uint64_t id = last - count + 1U + n;
|
||||
trace_t row = {0};
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
for (unsigned i = 0; i < DIAG_EVENTS; ++i)
|
||||
if (s_events[i].id == id) { row = s_events[i]; break; }
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
if (!row.id) { printf("event=%" PRIu64 " no longer retained\n", id); continue; }
|
||||
printf("event=%" PRIu64 " t_us=%" PRId64 " fd=%d conn=%" PRIu64 " %s %s rc=%d dt_us=%" PRId64
|
||||
" occ=%u/%u/%u heap=%" PRIu32 "/%" PRIu32 ",%" PRIu32 "/%" PRIu32 ",%" PRIu32 "/%" PRIu32 " stack=%" PRIu32 "\n",
|
||||
row.id, row.at_us, row.fd, row.seq, events[row.event],
|
||||
row.event < ENTER ? "-" : routes[row.route], row.result, row.elapsed_us,
|
||||
row.ordinary, row.serial, row.admin,
|
||||
row.free_bytes[0], row.largest[0], row.free_bytes[1], row.largest[1],
|
||||
row.free_bytes[2], row.largest[2], row.stack_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
int web_diagnostics_command(const char *action)
|
||||
{
|
||||
if (strcmp(action, "show") == 0) { show(); return 0; }
|
||||
bool enable = strcmp(action, "enable") == 0;
|
||||
bool disable = strcmp(action, "disable") == 0;
|
||||
bool clear = strcmp(action, "clear") == 0;
|
||||
if (!enable && !disable && !clear) return 1;
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
/* Never wrap identity or capture epochs; exhausting diagnostics cannot affect HTTPD. */
|
||||
if (s_epoch != UINT64_MAX) {
|
||||
++s_epoch;
|
||||
if (enable || disable) s_enabled = enable;
|
||||
} else s_enabled = false;
|
||||
if (clear) {
|
||||
memset(s_events, 0, sizeof(s_events));
|
||||
s_count = s_next = 0;
|
||||
s_overwritten = s_unmatched = s_lost = 0;
|
||||
}
|
||||
bool enabled = s_enabled;
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
printf("Web diagnostics %s%s; live identities retained.\n", enabled ? "enabled" : "disabled",
|
||||
clear ? ", trace cleared" : "");
|
||||
return 0;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
|
||||
#include "esp_https_server.h"
|
||||
|
||||
typedef enum {
|
||||
WEB_DIAG_SERIAL_TICKET,
|
||||
WEB_DIAG_ADMIN_TICKET,
|
||||
WEB_DIAG_SERIAL_UPGRADE,
|
||||
WEB_DIAG_ADMIN_UPGRADE,
|
||||
} web_diag_route_t;
|
||||
|
||||
/* Synchronous HTTPD-owner callbacks only; no socket/context ownership transfer. */
|
||||
void web_diagnostics_tls(esp_https_server_user_cb_arg_t *arg);
|
||||
esp_err_t web_diagnostics_handler(httpd_req_t *request, web_diag_route_t route,
|
||||
esp_err_t (*handler)(httpd_req_t *));
|
||||
/* Canonical console dispatcher only. No HTTPD calls or network waits. */
|
||||
int web_diagnostics_command(const char *action);
|
||||
@@ -1,191 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Deliberately isolated dependency on the installed IDF HTTPD layout. */
|
||||
#include "web_httpd_adapter.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include "esp_idf_version.h"
|
||||
#include "esp_httpd_priv.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
#if ESP_IDF_VERSION != ESP_IDF_VERSION_VAL(5, 5, 0)
|
||||
#error "Reaudit HTTPD headers, upgrade, request completion and idle cleanup for this IDF"
|
||||
#endif
|
||||
|
||||
/* IDF 5.5.0 httpd_sess_process increments lru_counter only AFTER successful
|
||||
* req_new + req_delete (handler, response, leftover-body purge and cleanup).
|
||||
* Work callbacks run between sessions, never inside synchronous parse/TLS/send.
|
||||
* This counter is an observation marker, NOT permission to enable LRU purge. */
|
||||
void web_httpd_idle_sweep(httpd_handle_t server,
|
||||
web_httpd_idle_row_t rows[WEB_HTTPD_IDLE_SOCKETS], int64_t now)
|
||||
{
|
||||
struct httpd_data *hd = server;
|
||||
if (!hd || !rows || hd->config.max_open_sockets > WEB_HTTPD_IDLE_SOCKETS ||
|
||||
httpd_os_thread_handle() != hd->hd_td.handle || hd->hd_req_aux.sd) return;
|
||||
for (unsigned i = 0; i < hd->config.max_open_sockets; ++i) {
|
||||
struct sock_db *sd = &hd->hd_sd[i];
|
||||
web_httpd_idle_row_t *row = &rows[i];
|
||||
/* Actual SDK classification, not delayed diagnostic route metadata. */
|
||||
if (sd->fd < 0 || sd->for_async_req || sd->ws_handshake_done || sd->ws_close) {
|
||||
memset(row, 0, sizeof(*row));
|
||||
continue;
|
||||
}
|
||||
if (!row->observed || row->fd != sd->fd || row->completed != sd->lru_counter) {
|
||||
*row = (web_httpd_idle_row_t){.fd = sd->fd, .completed = sd->lru_counter,
|
||||
.idle_since_us = now, .observed = true};
|
||||
continue;
|
||||
}
|
||||
if (row->shutdown_sent) continue;
|
||||
/* Control work precedes data processing in httpd_main. Do not expire a
|
||||
* connection whose next request is buffered in HTTPD, TLS or TCP. Zero
|
||||
* timeout select does not consume bytes or change TLS receive ownership.
|
||||
* Errors are conservative too; normal HTTPD owns error cleanup. */
|
||||
fd_set ready;
|
||||
FD_ZERO(&ready);
|
||||
if (sd->fd >= FD_SETSIZE) { row->idle_since_us = now; continue; }
|
||||
FD_SET(sd->fd, &ready);
|
||||
struct timeval timeout = {0};
|
||||
if (sd->pending_len || (sd->pending_fn && sd->pending_fn(hd, sd->fd) != 0) ||
|
||||
select(sd->fd + 1, &ready, NULL, NULL, &timeout) != 0) {
|
||||
row->idle_since_us = now;
|
||||
continue;
|
||||
}
|
||||
if (now - row->idle_since_us < WEB_HTTPD_IDLE_TIMEOUT_US) continue;
|
||||
/* Still the current fd on its owner; no queued sock_db pointer can later
|
||||
* target a replacement. HTTPD performs normal TLS/session destruction
|
||||
* on the next read. A failed shutdown retries on the next probe. */
|
||||
if (shutdown(sd->fd, SHUT_RDWR) == 0) row->shutdown_sent = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool web_httpd_headers_valid(httpd_req_t *request)
|
||||
{
|
||||
if (!request || !request->aux) return false;
|
||||
const struct httpd_req_aux *aux = request->aux;
|
||||
const char *start = aux->scratch;
|
||||
if (!start || aux->scratch_cur_size > 1024U) return false;
|
||||
const char *end = start + aux->scratch_cur_size;
|
||||
const char *line = start;
|
||||
for (unsigned i = 0; i < aux->req_hdrs_count; ++i) {
|
||||
if (line >= end) return false;
|
||||
while (line < end && !*line) ++line;
|
||||
const char *stop = memchr(line, 0, (size_t)(end - line));
|
||||
if (!stop) return false;
|
||||
const char *colon = memchr(line, ':', (size_t)(stop - line));
|
||||
if (!colon || colon == line) return false;
|
||||
size_t length = (size_t)(colon - line);
|
||||
for (const char *p = line; p < colon; ++p) {
|
||||
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') ||
|
||||
(*p >= '0' && *p <= '9') || strchr("!#$%&'*+-.^_`|~", *p))) return false;
|
||||
}
|
||||
for (const char *p = colon + 1; p < stop; ++p) {
|
||||
if ((unsigned char)*p < 32U || (unsigned char)*p == 127U) return false;
|
||||
}
|
||||
/* Reject transfer coding and Expect rather than draining an unbounded
|
||||
* body after an authentication failure. No application route uses them. */
|
||||
if ((length == 17U && !strncasecmp(line, "Transfer-Encoding", length)) ||
|
||||
(length == 6U && !strncasecmp(line, "Expect", length))) return false;
|
||||
const char *previous = start;
|
||||
for (unsigned j = 0; j < i; ++j) {
|
||||
while (previous < line && !*previous) ++previous;
|
||||
const char *previous_end = memchr(previous, 0, (size_t)(line - previous));
|
||||
if (!previous_end) return false;
|
||||
const char *previous_colon = memchr(previous, ':', (size_t)(previous_end - previous));
|
||||
if (!previous_colon) return false;
|
||||
if ((size_t)(previous_colon - previous) == length &&
|
||||
!strncasecmp(previous, line, length)) return false;
|
||||
previous = previous_end + 1;
|
||||
}
|
||||
line = stop + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_httpd_upgrade_requested(httpd_req_t *request)
|
||||
{
|
||||
const struct httpd_req_aux *aux = request->aux;
|
||||
if (!aux || !aux->sd || !aux->ws_handshake_detect || aux->sd->ws_handshake_done)
|
||||
return false;
|
||||
char version[3], key[25];
|
||||
if (httpd_req_get_hdr_value_len(request, "Sec-WebSocket-Version") != 2U ||
|
||||
httpd_req_get_hdr_value_str(request, "Sec-WebSocket-Version", version, sizeof(version)) != ESP_OK ||
|
||||
strcmp(version, "13") || httpd_req_get_hdr_value_len(request, "Sec-WebSocket-Key") != 24U ||
|
||||
httpd_req_get_hdr_value_str(request, "Sec-WebSocket-Key", key, sizeof(key)) != ESP_OK ||
|
||||
key[22] != '=' || key[23] != '=' || !strchr("AQgw", key[21])) return false;
|
||||
for (unsigned i = 0; i < 21; ++i)
|
||||
if (!strchr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", key[i])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_upgrade(httpd_req_t *request,
|
||||
esp_err_t (*handler)(httpd_req_t *))
|
||||
{
|
||||
if (!web_httpd_upgrade_requested(request)) return ESP_ERR_INVALID_STATE;
|
||||
esp_err_t error = httpd_ws_respond_server_handshake(request, NULL);
|
||||
if (error == ESP_OK) {
|
||||
struct httpd_req_aux *aux = request->aux;
|
||||
aux->sd->ws_handshake_done = true;
|
||||
aux->sd->ws_handler = handler;
|
||||
aux->sd->ws_control_frames = false;
|
||||
aux->sd->ws_user_ctx = NULL;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_httpd_wipe_request(httpd_req_t *request, bool closing)
|
||||
{
|
||||
struct httpd_req_aux *aux = request->aux;
|
||||
if (!aux) return;
|
||||
if (aux->scratch) secure_wipe(aux->scratch, aux->scratch_cur_size);
|
||||
aux->req_hdrs_count = 0;
|
||||
if (aux->sd) {
|
||||
size_t keep = closing ? 0 : aux->sd->pending_len;
|
||||
/* httpd_unrecv()/httpd_recv_pending() right-align unread bytes. */
|
||||
if (keep <= sizeof(aux->sd->pending_data))
|
||||
secure_wipe(aux->sd->pending_data, sizeof(aux->sd->pending_data) - keep);
|
||||
}
|
||||
}
|
||||
|
||||
bool web_httpd_unread_body(httpd_req_t *request)
|
||||
{
|
||||
const struct httpd_req_aux *aux = request->aux;
|
||||
return aux && aux->remaining_len != 0;
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_register_optional(httpd_handle_t server, const httpd_uri_t *uri)
|
||||
{
|
||||
struct httpd_data *hd = server;
|
||||
if (!hd || !uri || !uri->uri || !uri->handler ||
|
||||
(uri->method != HTTP_GET && uri->method != HTTP_POST) ||
|
||||
uri->is_websocket || uri->supported_subprotocol || hd->config.uri_match_fn)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
size_t length = 0;
|
||||
while (length < 128 && uri->uri[length]) ++length;
|
||||
if (!length || length == 128) return ESP_ERR_INVALID_ARG;
|
||||
int slot = -1;
|
||||
for (unsigned i = 0; i < hd->config.max_uri_handlers; ++i) {
|
||||
if (!hd->hd_calls[i]) { if (slot < 0) slot = (int)i; }
|
||||
else if (!strcmp(hd->hd_calls[i]->uri, uri->uri) && hd->hd_calls[i]->method == uri->method)
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (slot < 0) return ESP_ERR_NO_MEM;
|
||||
/* IDF 5.5.0 publishes its descriptor before strdup; strdup failure leaves a
|
||||
* freed hd_calls entry. Optional registration must leave the table intact. */
|
||||
httpd_uri_t *copy = malloc(sizeof(*copy));
|
||||
if (!copy) return ESP_ERR_NO_MEM;
|
||||
char *name = malloc(length + 1);
|
||||
if (!name) { free(copy); return ESP_ERR_NO_MEM; }
|
||||
memcpy(name, uri->uri, length + 1);
|
||||
*copy = *uri;
|
||||
copy->uri = name;
|
||||
hd->hd_calls[slot] = copy;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri)
|
||||
{
|
||||
if (!uri || uri->method != HTTP_GET) return ESP_ERR_INVALID_ARG;
|
||||
return web_httpd_register_optional(server, uri);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include "esp_http_server.h"
|
||||
#include <stdint.h>
|
||||
|
||||
#define WEB_HTTPD_IDLE_SOCKETS 6U
|
||||
#define WEB_HTTPD_IDLE_TIMEOUT_US INT64_C(15000000)
|
||||
typedef struct {
|
||||
uint64_t completed;
|
||||
int64_t idle_since_us;
|
||||
int fd;
|
||||
bool observed, shutdown_sent;
|
||||
} web_httpd_idle_row_t;
|
||||
/* HTTPD-owner work boundary only. TLS create must invalidate reused fd rows. */
|
||||
void web_httpd_idle_sweep(httpd_handle_t server,
|
||||
web_httpd_idle_row_t rows[WEB_HTTPD_IDLE_SOCKETS], int64_t now);
|
||||
|
||||
/* HTTPD-owner only, before body reads or any response. Reject duplicate lines,
|
||||
* including Cookie, rather than trusting first-match public getters. */
|
||||
bool web_httpd_headers_valid(httpd_req_t *request);
|
||||
bool web_httpd_upgrade_requested(httpd_req_t *request);
|
||||
bool web_httpd_unread_body(httpd_req_t *request);
|
||||
/* After the final response/lookup: preserve only unread pipelined data on a
|
||||
* keepalive connection. Closing requests may discard pending data entirely. */
|
||||
void web_httpd_wipe_request(httpd_req_t *request, bool closing);
|
||||
esp_err_t web_httpd_upgrade(httpd_req_t *request,
|
||||
esp_err_t (*handler)(httpd_req_t *));
|
||||
|
||||
/* Serialized server startup only, exact-match ordinary GET, URI <= 127 bytes.
|
||||
* Stage both allocations before publication; HTTPD owns/frees them on success. */
|
||||
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri);
|
||||
/* Same staged startup ownership for ordinary exact GET or POST. */
|
||||
esp_err_t web_httpd_register_optional(httpd_handle_t server, const httpd_uri_t *uri);
|
||||
@@ -1,137 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_httpd_idle.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define IDLE_POLL_US INT64_C(1000000)
|
||||
#define IDLE_FENCE_US INT64_C(1000000)
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static httpd_handle_t s_server;
|
||||
static esp_timer_handle_t s_timer;
|
||||
static uintptr_t s_generation;
|
||||
static bool s_accepting, s_queued, s_submitting;
|
||||
/* Only HTTPD touches rows while alive; prepare runs before SSL startup. */
|
||||
static web_httpd_idle_row_t s_rows[WEB_HTTPD_IDLE_SOCKETS];
|
||||
|
||||
static void idle_work(void *argument)
|
||||
{
|
||||
uintptr_t generation = (uintptr_t)argument;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool current = s_queued && generation == s_generation;
|
||||
httpd_handle_t server = current && s_accepting ? s_server : NULL;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (server) web_httpd_idle_sweep(server, s_rows, esp_timer_get_time());
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (current && generation == s_generation) s_queued = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static void idle_timer(void *argument)
|
||||
{
|
||||
(void)argument;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
httpd_handle_t server = NULL;
|
||||
uintptr_t generation = s_generation;
|
||||
if (s_accepting && !s_queued && !s_submitting) {
|
||||
server = s_server;
|
||||
s_queued = s_submitting = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!server) return;
|
||||
esp_err_t error = httpd_queue_work(server, idle_work, (void *)generation);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
/* A callback may finish before queue_work returns. Keep the submission
|
||||
* reservation until here so it cannot clear a newer probe's queued flag. */
|
||||
if (error != ESP_OK) s_queued = false;
|
||||
s_submitting = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_idle_prepare(void)
|
||||
{
|
||||
#if defined(CONFIG_HTTPD_QUEUE_WORK_BLOCKING) && CONFIG_HTTPD_QUEUE_WORK_BLOCKING
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
#else
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool allowed = !s_server && !s_queued && !s_submitting && s_generation != UINTPTR_MAX;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!allowed) return ESP_ERR_INVALID_STATE;
|
||||
if (!s_timer) {
|
||||
esp_timer_handle_t timer = NULL;
|
||||
const esp_timer_create_args_t args = {
|
||||
.callback = idle_timer, .name = "web_idle", .skip_unhandled_events = true,
|
||||
};
|
||||
esp_err_t error = esp_timer_create(&args, &timer);
|
||||
if (error == ESP_OK) error = esp_timer_start_periodic(timer, IDLE_POLL_US);
|
||||
if (error != ESP_OK) {
|
||||
if (timer) (void)esp_timer_delete(timer);
|
||||
return error;
|
||||
}
|
||||
s_timer = timer;
|
||||
}
|
||||
memset(s_rows, 0, sizeof(s_rows));
|
||||
return ESP_OK;
|
||||
#endif
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_idle_attach(httpd_handle_t server)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool allowed = server && s_timer && !s_server && !s_queued && !s_submitting &&
|
||||
s_generation != UINTPTR_MAX;
|
||||
if (allowed) {
|
||||
++s_generation; /* Never reused, including when HTTPD's handle is reused. */
|
||||
s_server = server;
|
||||
s_accepting = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return allowed ? ESP_OK : ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_idle_detach(httpd_handle_t server)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool owned = server && s_server == server;
|
||||
bool absent = !s_server;
|
||||
if (owned) s_accepting = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
/* Partial startup may never have attached. */
|
||||
if (!owned) return absent ? ESP_OK : ESP_ERR_INVALID_STATE;
|
||||
int64_t deadline = esp_timer_get_time() + IDLE_FENCE_US;
|
||||
for (;;) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool submitting = s_submitting;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!submitting) return ESP_OK;
|
||||
if (esp_timer_get_time() >= deadline) return ESP_ERR_TIMEOUT;
|
||||
vTaskDelay(1);
|
||||
}
|
||||
}
|
||||
|
||||
void web_httpd_idle_stopped(httpd_handle_t server)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (server && s_server == server && !s_accepting && !s_submitting) {
|
||||
s_server = NULL;
|
||||
s_queued = false; /* Successful HTTPD stop joined owner and destroyed queue. */
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_httpd_idle_tls(esp_https_server_user_cb_arg_t *arg)
|
||||
{
|
||||
if (!arg || !arg->tls || arg->user_cb_state != HTTPD_SSL_USER_CB_SESS_CREATE) return;
|
||||
int fd = -1;
|
||||
if (esp_tls_get_conn_sockfd(arg->tls, &fd) != ESP_OK || fd < 0) {
|
||||
/* Identity unavailable: conservatively restart every idle observation. */
|
||||
memset(s_rows, 0, sizeof(s_rows));
|
||||
return;
|
||||
}
|
||||
for (unsigned i = 0; i < WEB_HTTPD_IDLE_SOCKETS; ++i)
|
||||
if (s_rows[i].fd == fd) memset(&s_rows[i], 0, sizeof(s_rows[i]));
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include "esp_https_server.h"
|
||||
|
||||
/* Serialized web_server lifecycle. Prepare before SSL start; stop must fence
|
||||
* submissions before destroying HTTPD, and retire only after successful stop. */
|
||||
esp_err_t web_httpd_idle_prepare(void);
|
||||
esp_err_t web_httpd_idle_attach(httpd_handle_t server);
|
||||
esp_err_t web_httpd_idle_detach(httpd_handle_t server);
|
||||
void web_httpd_idle_stopped(httpd_handle_t server);
|
||||
/* Synchronous HTTPD-owner TLS callback, composed with diagnostics by server. */
|
||||
void web_httpd_idle_tls(esp_https_server_user_cb_arg_t *arg);
|
||||
@@ -1,161 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_login_ui.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
/* Authored standalone page; no dependency on protected or generated assets. */
|
||||
static const char s_login_html[] =
|
||||
"<!doctype html>\n<html lang=\"en\">\n<head>\n"
|
||||
"<meta charset=\"utf-8\">\n"
|
||||
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
|
||||
"<title>Sign in - ESP32 Serial Console</title>\n"
|
||||
"<style>\n"
|
||||
":root{color-scheme:dark;font:16px/1.5 system-ui,sans-serif;background:#090d14;color:#e8eef8}\n"
|
||||
"*{box-sizing:border-box}body{margin:0;padding:2rem 1rem}main{max-width:28rem;margin:3vh auto;"
|
||||
"padding:1.5rem;background:#111824;border:1px solid #29364a;border-radius:14px}\n"
|
||||
"h1{font-size:1.5rem}label{display:block;margin-top:1rem}input,button{font:inherit;"
|
||||
"width:100%;padding:.65rem;border:1px solid #91a0b5;border-radius:6px}"
|
||||
"input{background:#090d14;color:inherit}button{margin-top:1.25rem;background:#55c2ff;"
|
||||
"color:#090d14;cursor:pointer}button:disabled{opacity:.6;cursor:wait}"
|
||||
"a{color:#55c2ff}:focus-visible{outline:3px solid #ffc857;outline-offset:3px}"
|
||||
"#message{min-height:3em}small{display:block;color:#b6c2d4}\n"
|
||||
"</style>\n</head>\n<body>\n<main>\n"
|
||||
"<h1>ESP32 Serial Console</h1>\n"
|
||||
"<p>Sign in to access the serial terminal.</p>\n"
|
||||
"<form id=\"login\" method=\"post\" action=\"/api/login\">\n"
|
||||
"<label for=\"username\">Username</label>\n"
|
||||
"<input id=\"username\" name=\"username\" autocomplete=\"username\" "
|
||||
"autocapitalize=\"none\" spellcheck=\"false\" maxlength=\"16\" required>\n"
|
||||
"<label for=\"password\">Password</label>\n"
|
||||
"<input id=\"password\" name=\"password\" type=\"password\" "
|
||||
"autocomplete=\"current-password\" maxlength=\"64\" required>\n"
|
||||
"<button id=\"submit\" type=\"submit\" disabled>Sign in</button>\n"
|
||||
"</form>\n"
|
||||
"<p id=\"message\" role=\"status\" aria-live=\"polite\">Ready to sign in.</p>\n"
|
||||
"<noscript><p>JavaScript is required to sign in securely.</p></noscript>\n"
|
||||
"<p><a href=\"/\">Return to console</a></p>\n"
|
||||
"<small>Sessions expire after one hour, including active serial connections. "
|
||||
"To switch accounts, return to the console and sign out first.</small>\n"
|
||||
"</main>\n<script>\n"
|
||||
"(() => {\n"
|
||||
" 'use strict';\n"
|
||||
" const form = document.getElementById('login');\n"
|
||||
" const username = document.getElementById('username');\n"
|
||||
" const password = document.getElementById('password');\n"
|
||||
" const submit = document.getElementById('submit');\n"
|
||||
" const message = document.getElementById('message');\n"
|
||||
" const encoder = new TextEncoder();\n"
|
||||
" let busy = false, generation = 0, controller = null, retryAt = 0;\n"
|
||||
" function reset() {\n"
|
||||
" ++generation;\n"
|
||||
" if (controller) controller.abort();\n"
|
||||
" controller = null; busy = false; password.value = '';\n"
|
||||
" submit.disabled = false; username.disabled = false; password.disabled = false; form.setAttribute('aria-busy', 'false');\n"
|
||||
" }\n"
|
||||
" window.addEventListener('pagehide', reset);\n"
|
||||
" window.addEventListener('pageshow', event => {\n"
|
||||
" if (event.persisted) { reset(); message.textContent = 'Ready to sign in.'; }\n"
|
||||
" });\n"
|
||||
" async function readJSON(response) {\n"
|
||||
" if (!response.body) throw new Error('response');\n"
|
||||
" const reader = response.body.getReader();\n"
|
||||
" const bytes = new Uint8Array(512);\n"
|
||||
" let length = 0;\n"
|
||||
" try {\n"
|
||||
" for (;;) {\n"
|
||||
" const part = await reader.read();\n"
|
||||
" if (part.done) break;\n"
|
||||
" if (part.value.length > bytes.length - length) throw new Error('response');\n"
|
||||
" bytes.set(part.value, length); length += part.value.length;\n"
|
||||
" }\n"
|
||||
" return JSON.parse(new TextDecoder('utf-8', {fatal:true}).decode(bytes.subarray(0, length)));\n"
|
||||
" } finally { await reader.cancel(); reader.releaseLock(); }\n"
|
||||
" }\n"
|
||||
" function report(response, stage) {\n"
|
||||
" if (response.status === 429 || response.status === 503) {\n"
|
||||
" const raw = response.headers.get('Retry-After') || '';\n"
|
||||
" const seconds = /^[0-9]{1,3}$/.test(raw) ? Math.max(1, Math.min(120, Number(raw))) : 5;\n"
|
||||
" retryAt = Date.now() + seconds * 1000;\n"
|
||||
" message.textContent = (response.status === 429 ? 'Too many sign-in attempts.' : 'Sign-in capacity is busy.') +\n"
|
||||
" ' Wait ' + seconds + ' seconds, then try again.';\n"
|
||||
" } else if (response.status === 401 && stage === 'login') {\n"
|
||||
" message.textContent = 'Username or password is incorrect. Please try again.';\n"
|
||||
" } else if (response.status === 403) {\n"
|
||||
" message.textContent = 'The sign-in challenge expired or the request was rejected. Please try again.';\n"
|
||||
" } else if (response.status === 409) {\n"
|
||||
" message.textContent = 'Already signed in. Return to the console; sign out there to switch accounts.';\n"
|
||||
" } else if ([400, 413, 415].includes(response.status)) {\n"
|
||||
" message.textContent = 'The sign-in request was not accepted. Check your input and try again.';\n"
|
||||
" } else {\n"
|
||||
" message.textContent = 'The device could not complete sign-in. Please try again.';\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" form.addEventListener('submit', async event => {\n"
|
||||
" event.preventDefault();\n"
|
||||
" if (busy) { password.value = ''; return; }\n"
|
||||
" if (Date.now() < retryAt) {\n"
|
||||
" password.value = '';\n"
|
||||
" message.textContent = 'Please wait ' + Math.ceil((retryAt - Date.now()) / 1000) + ' seconds before retrying.';\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" let body = JSON.stringify({username:username.value, password:password.value});\n"
|
||||
" const valid = username.value.length && password.value.length &&\n"
|
||||
" encoder.encode(username.value).length <= 16 && encoder.encode(password.value).length <= 64 &&\n"
|
||||
" !username.value.includes('\\0') && !password.value.includes('\\0') && encoder.encode(body).length <= 512;\n"
|
||||
" password.value = '';\n"
|
||||
" if (!valid) { body = ''; message.textContent = 'Enter a username (up to 16 UTF-8 bytes) and password (up to 64 UTF-8 bytes).'; return; }\n"
|
||||
" busy = true; submit.disabled = true; username.disabled = true; password.disabled = true; form.setAttribute('aria-busy', 'true');\n"
|
||||
" message.textContent = 'Signing in...';\n"
|
||||
" const current = ++generation;\n"
|
||||
" const abort = new AbortController(); controller = abort;\n"
|
||||
" const timeout = setTimeout(() => abort.abort(), 15000);\n"
|
||||
" let csrf = '';\n"
|
||||
/* CORS mode preserves Origin under no-referrer; CSP still limits connections to self. */
|
||||
" const options = {credentials:'same-origin', mode:'cors', cache:'no-store', redirect:'error', signal:abort.signal};\n"
|
||||
" try {\n"
|
||||
" const challenge = await fetch('/api/login-challenge', {...options, headers:{'X-Login-Bootstrap':'1'}});\n"
|
||||
" if (current !== generation) return;\n"
|
||||
" if (challenge.status !== 200) { report(challenge, 'challenge'); return; }\n"
|
||||
" const data = await readJSON(challenge);\n"
|
||||
" if (current !== generation) return;\n"
|
||||
" if (!data || typeof data.csrf !== 'string' || !/^[0-9a-f]{64}$/.test(data.csrf) ||\n"
|
||||
" !Number.isInteger(data.expires_in) || data.expires_in < 1 || data.expires_in > 120) throw new Error('challenge');\n"
|
||||
" csrf = data.csrf;\n"
|
||||
" const response = await fetch('/api/login', {...options, method:'POST',\n"
|
||||
" headers:{'Content-Type':'application/json', 'X-CSRF-Token':csrf}, body});\n"
|
||||
" body = ''; csrf = '';\n"
|
||||
" if (current !== generation) return;\n"
|
||||
" if (response.status !== 200) { report(response, 'login'); return; }\n"
|
||||
" const result = await readJSON(response);\n"
|
||||
" if (current !== generation) return;\n"
|
||||
" if (!result || result.authenticated !== true) throw new Error('login');\n"
|
||||
" window.location.replace('/');\n"
|
||||
" } catch (_) {\n"
|
||||
" if (current === generation) message.textContent = 'Could not confirm sign-in. Check the connection, then return to the console or try again.';\n"
|
||||
" } finally {\n"
|
||||
" abort.abort(); clearTimeout(timeout); body = ''; csrf = '';\n"
|
||||
" if (current === generation) {\n"
|
||||
" controller = null; busy = false; password.value = '';\n"
|
||||
" submit.disabled = false; username.disabled = false; password.disabled = false; form.setAttribute('aria-busy', 'false');\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" });\n"
|
||||
" submit.disabled = false;\n"
|
||||
"})();\n"
|
||||
"</script>\n</body>\n</html>\n";
|
||||
|
||||
esp_err_t web_login_ui_send_response(httpd_req_t *request)
|
||||
{
|
||||
if (request == NULL) return ESP_ERR_INVALID_ARG;
|
||||
esp_err_t error = httpd_resp_set_type(request, "text/html; 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_set_hdr(request, "X-Frame-Options", "DENY");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Content-Security-Policy",
|
||||
"default-src 'none'; script-src 'sha256-eZO4pMDQx6SIaa5AFlMnuf0CD5JdGSWyi8lNVmCNPBQ='; "
|
||||
"style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; "
|
||||
"form-action 'none'; frame-ancestors 'none'");
|
||||
if (error == ESP_OK) error = httpd_resp_send(request, s_login_html, sizeof(s_login_html) - 1U);
|
||||
return error;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/* Standalone public login document. Rendering only: authentication, route
|
||||
* registration and bounded challenge allocation belong to web_cookie_auth. */
|
||||
esp_err_t web_login_ui_send_response(httpd_req_t *request);
|
||||
@@ -1,446 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_network_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdarg.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "mdns_service.h"
|
||||
#include "secure_random.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "wifi_manager.h"
|
||||
|
||||
enum { WIFI_PATCH, PROFILE_PATCH, WIFI_SAVE, WIFI_LOAD, START, STOP, RECONNECT,
|
||||
NEXT_PROFILE, MDNS_SET, MDNS_SAVE, MDNS_LOAD, MDNS_DEFAULTS, ACTION_COUNT };
|
||||
static const char *const s_actions[] = {"wifi-patch", "profile-patch", "wifi-save", "wifi-load",
|
||||
"start", "stop", "reconnect", "next-profile", "mdns-set", "mdns-save", "mdns-load", "mdns-defaults"};
|
||||
enum { IDLE, PENDING, ACCEPTED, OK, FAILED, CANCELLED, STALE, INVALID,
|
||||
LOADED_DEFAULTS, APPLIED_NOT_QUEUED };
|
||||
static const char *const s_states[] = {"idle", "pending", "accepted", "ok", "failed", "cancelled",
|
||||
"stale", "invalid", "loaded_defaults", "applied_not_queued"};
|
||||
typedef struct {
|
||||
uint32_t id, generation;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
int64_t deadline;
|
||||
unsigned action, state;
|
||||
esp_err_t error;
|
||||
bool executing;
|
||||
wifi_manager_patch_t patch;
|
||||
mdns_config_t mdns;
|
||||
} network_operation_t;
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static network_operation_t s_operation;
|
||||
static uint32_t s_next_id;
|
||||
static esp_timer_handle_t s_secret_timer;
|
||||
static bool s_secret_timer_started;
|
||||
|
||||
static void wipe_input(network_operation_t *operation)
|
||||
{
|
||||
secure_wipe(&operation->principal, sizeof(operation->principal));
|
||||
secure_wipe(&operation->patch, sizeof(operation->patch));
|
||||
secure_wipe(&operation->mdns, sizeof(operation->mdns));
|
||||
operation->generation = 0;
|
||||
}
|
||||
|
||||
static void expire_input(void *unused)
|
||||
{
|
||||
(void)unused;
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.state == PENDING && !s_operation.executing && now >= s_operation.deadline) {
|
||||
s_operation.state = CANCELLED;
|
||||
wipe_input(&s_operation);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static bool ensure_secret_timer(void)
|
||||
{
|
||||
/* Sole HTTPD admission owner; one firmware-lifetime timer, no extra task.
|
||||
* Periodic inspection avoids an old captured expiry cancelling a newer ID. */
|
||||
if (!s_secret_timer) {
|
||||
const esp_timer_create_args_t args = {.callback = expire_input, .name = "network-input"};
|
||||
if (esp_timer_create(&args, &s_secret_timer) != ESP_OK) return false;
|
||||
}
|
||||
if (!s_secret_timer_started) {
|
||||
if (esp_timer_start_periodic(s_secret_timer, 1000000ULL) != ESP_OK) return false;
|
||||
s_secret_timer_started = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
typedef struct { const char *body; size_t size, pos; } parser_t;
|
||||
static void space(parser_t *p)
|
||||
{
|
||||
while (p->pos < p->size && (p->body[p->pos] == ' ' || p->body[p->pos] == '\r' ||
|
||||
p->body[p->pos] == '\n' || p->body[p->pos] == '\t')) ++p->pos;
|
||||
}
|
||||
static bool take(parser_t *p, char c)
|
||||
{
|
||||
space(p);
|
||||
return p->pos < p->size && p->body[p->pos++] == c;
|
||||
}
|
||||
static int hex_digit(unsigned char c)
|
||||
{
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
/* Bounded byte-string decoder, deliberately not Unicode-to-UTF8 conversion.
|
||||
* See the public contract: \\u00ff is exactly one SSID byte, not two. */
|
||||
static bool byte_string(parser_t *p, uint8_t *out, size_t capacity, size_t *length)
|
||||
{
|
||||
*length = 0;
|
||||
if (!take(p, '"')) return false;
|
||||
while (p->pos < p->size) {
|
||||
unsigned char c = (unsigned char)p->body[p->pos++];
|
||||
if (c == '"') return true;
|
||||
if (c < 0x20 || c > 0x7e || *length == capacity) return false;
|
||||
if (c == '\\') {
|
||||
if (p->pos == p->size) return false;
|
||||
c = (unsigned char)p->body[p->pos++];
|
||||
switch (c) {
|
||||
case '"': case '\\': case '/': break;
|
||||
case 'b': c = '\b'; break;
|
||||
case 'f': c = '\f'; break;
|
||||
case 'n': c = '\n'; break;
|
||||
case 'r': c = '\r'; break;
|
||||
case 't': c = '\t'; break;
|
||||
case 'u': {
|
||||
if (p->size - p->pos < 4 || p->body[p->pos] != '0' || p->body[p->pos + 1] != '0') return false;
|
||||
int high = hex_digit(p->body[p->pos + 2]), low = hex_digit(p->body[p->pos + 3]);
|
||||
if (high < 0 || low < 0) return false;
|
||||
c = (unsigned char)(high * 16 + low); p->pos += 4; break;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
out[(*length)++] = c;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
static bool number(parser_t *p, uint32_t *out)
|
||||
{
|
||||
space(p); size_t start = p->pos; *out = 0;
|
||||
while (p->pos < p->size && p->body[p->pos] >= '0' && p->body[p->pos] <= '9') {
|
||||
unsigned digit = (unsigned)(p->body[p->pos++] - '0');
|
||||
if (*out > (UINT32_MAX - digit) / 10) return false;
|
||||
*out = *out * 10 + digit;
|
||||
}
|
||||
return p->pos > start && (p->pos - start == 1 || p->body[start] != '0');
|
||||
}
|
||||
static bool boolean(parser_t *p, uint32_t *out)
|
||||
{
|
||||
space(p);
|
||||
if (p->size - p->pos >= 4 && !memcmp(p->body + p->pos, "true", 4)) { p->pos += 4; *out = 1; return true; }
|
||||
if (p->size - p->pos >= 5 && !memcmp(p->body + p->pos, "false", 5)) { p->pos += 5; *out = 0; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool parse_request(const char *body, size_t length, network_operation_t *operation)
|
||||
{
|
||||
enum { ACTION, GENERATION, PROFILE, ENABLED, PRIORITY, SECURITY, SSID, PASSWORD,
|
||||
CLEAR_PASSWORD, BOOT, POLICY, CHANNEL, SUFFIX, KEY_COUNT };
|
||||
static const char *const keys[] = {"action", "generation", "profile", "enabled", "priority", "security",
|
||||
"ssid", "password", "clear_password", "enabled_at_boot", "ap_policy", "channel", "suffix"};
|
||||
parser_t p = {.body = body, .size = length};
|
||||
uint32_t seen = 0;
|
||||
operation->action = ACTION_COUNT;
|
||||
operation->patch.profile = -1;
|
||||
if (!take(&p, '{')) return false;
|
||||
for (unsigned field = 0; field < KEY_COUNT; ++field) {
|
||||
uint8_t key_text[20] = {0}; size_t n;
|
||||
if ((field && !take(&p, ',')) || !byte_string(&p, key_text, sizeof(key_text), &n)) return false;
|
||||
unsigned key = 0;
|
||||
for (; key < KEY_COUNT; ++key) if (strlen(keys[key]) == n && !memcmp(keys[key], key_text, n)) break;
|
||||
if (key == KEY_COUNT || (seen & (1U << key)) || !take(&p, ':')) return false;
|
||||
seen |= 1U << key;
|
||||
uint32_t value = 0;
|
||||
uint8_t text[64] = {0};
|
||||
bool valid;
|
||||
if (key == GENERATION || key == PROFILE || key == PRIORITY || key == CHANNEL) valid = number(&p, &value);
|
||||
else if (key == ENABLED || key == CLEAR_PASSWORD || key == BOOT) valid = boolean(&p, &value);
|
||||
else valid = byte_string(&p, text, sizeof(text) - 1, &n);
|
||||
if (!valid) { secure_wipe(text, sizeof(text)); return false; }
|
||||
switch (key) {
|
||||
case ACTION:
|
||||
for (unsigned i = 0; i < ACTION_COUNT; ++i)
|
||||
if (strlen(s_actions[i]) == n && !memcmp(s_actions[i], text, n)) operation->action = i;
|
||||
valid = operation->action != ACTION_COUNT; break;
|
||||
case GENERATION: operation->generation = value; valid = value != 0; break;
|
||||
case PROFILE: valid = value < WIFI_CONFIG_STA_PROFILE_COUNT; operation->patch.profile = (int8_t)value; break;
|
||||
case ENABLED: operation->patch.enabled = value; operation->patch.fields |= WIFI_PATCH_ENABLED; break;
|
||||
case PRIORITY: valid = value <= UINT8_MAX; operation->patch.priority = value; operation->patch.fields |= WIFI_PATCH_PRIORITY; break;
|
||||
case SECURITY:
|
||||
valid = !memchr(text, 0, n) && wifi_config_parse_security((char *)text, &operation->patch.security);
|
||||
operation->patch.fields |= WIFI_PATCH_SECURITY; break;
|
||||
case SSID:
|
||||
valid = n <= WIFI_CONFIG_SSID_MAX_LEN;
|
||||
if (valid) { memcpy(operation->patch.ssid, text, n); operation->patch.ssid_len = n; }
|
||||
operation->patch.fields |= WIFI_PATCH_SSID; break;
|
||||
case PASSWORD:
|
||||
valid = n >= WIFI_CONFIG_PSK_MIN_LEN && n <= WIFI_CONFIG_PSK_MAX_LEN;
|
||||
for (size_t i = 0; valid && i < n; ++i) valid = text[i] >= 0x20 && text[i] <= 0x7e;
|
||||
if (valid) { memcpy(operation->patch.password, text, n); operation->patch.password_len = n; }
|
||||
operation->patch.fields |= WIFI_PATCH_PASSWORD; break;
|
||||
case CLEAR_PASSWORD: valid = value == 1; operation->patch.fields |= WIFI_PATCH_PASSWORD; break;
|
||||
case BOOT: operation->patch.enabled_at_boot = value; operation->patch.fields |= WIFI_PATCH_BOOT; break;
|
||||
case POLICY:
|
||||
valid = !memchr(text, 0, n) && wifi_config_parse_ap_policy((char *)text, &operation->patch.ap_policy);
|
||||
operation->patch.fields |= WIFI_PATCH_POLICY; break;
|
||||
case CHANNEL: valid = value >= WIFI_CONFIG_AP_CHANNEL_MIN && value <= WIFI_CONFIG_AP_CHANNEL_MAX;
|
||||
operation->patch.ap_channel = value; operation->patch.fields |= WIFI_PATCH_CHANNEL; break;
|
||||
case SUFFIX:
|
||||
valid = n <= MDNS_CONFIG_SUFFIX_MAX_LEN;
|
||||
if (valid) {
|
||||
operation->mdns.schema_version = MDNS_CONFIG_SCHEMA_VERSION;
|
||||
operation->mdns.blob_size = MDNS_CONFIG_BLOB_SIZE;
|
||||
operation->mdns.suffix_len = n; memcpy(operation->mdns.suffix, text, n);
|
||||
valid = mdns_config_validate(&operation->mdns) == ESP_OK;
|
||||
}
|
||||
break;
|
||||
}
|
||||
secure_wipe(text, sizeof(text));
|
||||
if (!valid) return false;
|
||||
space(&p);
|
||||
if (p.pos < p.size && p.body[p.pos] == '}') break;
|
||||
}
|
||||
if (!take(&p, '}')) return false;
|
||||
space(&p);
|
||||
if (p.pos != p.size || !(seen & 1U) ||
|
||||
((seen & (1U << PASSWORD)) && (seen & (1U << CLEAR_PASSWORD)))) return false;
|
||||
uint32_t required = 1U, allowed = 1U;
|
||||
if (operation->action == WIFI_PATCH || operation->action == PROFILE_PATCH) {
|
||||
required |= 1U << GENERATION;
|
||||
allowed = required | (1U << SSID) | (1U << PASSWORD) | (1U << CLEAR_PASSWORD);
|
||||
if (operation->action == PROFILE_PATCH) {
|
||||
required |= 1U << PROFILE;
|
||||
allowed |= (1U << PROFILE) | (1U << ENABLED) | (1U << PRIORITY) | (1U << SECURITY);
|
||||
} else allowed |= (1U << BOOT) | (1U << POLICY) | (1U << CHANNEL);
|
||||
if (!operation->patch.fields) return false;
|
||||
} else if (operation->action == WIFI_SAVE || operation->action == WIFI_LOAD || operation->action >= MDNS_SET) {
|
||||
required |= 1U << GENERATION;
|
||||
if (operation->action == MDNS_SET) required |= 1U << SUFFIX;
|
||||
allowed = required;
|
||||
}
|
||||
return operation->action < ACTION_COUNT && (seen & required) == required && !(seen & ~allowed);
|
||||
}
|
||||
|
||||
void web_network_settings_execute(uint32_t id)
|
||||
{
|
||||
network_operation_t operation = {0};
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool claimed = id && s_operation.id == id && s_operation.state == PENDING && !s_operation.executing;
|
||||
if (claimed) {
|
||||
s_operation.executing = true;
|
||||
operation = s_operation;
|
||||
wipe_input(&s_operation);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!claimed) return;
|
||||
bool current = false;
|
||||
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t);
|
||||
unsigned state = CANCELLED;
|
||||
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
||||
esp_timer_get_time() < operation.deadline) {
|
||||
state = ACCEPTED;
|
||||
switch (operation.action) {
|
||||
case WIFI_PATCH: case PROFILE_PATCH: error = wifi_manager_patch_current(operation.generation, &operation.patch); break;
|
||||
case WIFI_SAVE: error = wifi_manager_save_current(operation.generation); state = OK; break;
|
||||
case WIFI_LOAD: error = wifi_manager_load_current(operation.generation); break;
|
||||
case START: error = wifi_manager_start(); break;
|
||||
case STOP: error = wifi_manager_stop(); break;
|
||||
case RECONNECT: error = wifi_manager_reconnect(); break;
|
||||
case NEXT_PROFILE: error = wifi_manager_next_profile(); break;
|
||||
default: {
|
||||
bool stored = true;
|
||||
mdns_settings_action_t action = operation.action == MDNS_SET ? MDNS_SETTINGS_SET :
|
||||
operation.action == MDNS_SAVE ? MDNS_SETTINGS_SAVE :
|
||||
operation.action == MDNS_LOAD ? MDNS_SETTINGS_LOAD : MDNS_SETTINGS_DEFAULTS;
|
||||
error = mdns_service_update_current(operation.generation, action, &operation.mdns, &stored);
|
||||
if (error == ESP_OK) {
|
||||
if (action == MDNS_SETTINGS_SAVE) state = OK;
|
||||
else {
|
||||
error = wifi_manager_mdns_reannounce();
|
||||
state = error != ESP_OK ? APPLIED_NOT_QUEUED : stored ? ACCEPTED : LOADED_DEFAULTS;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (error != ESP_OK && state != APPLIED_NOT_QUEUED)
|
||||
state = error == ESP_ERR_NOT_FOUND ? STALE : error == ESP_ERR_INVALID_ARG ? INVALID : FAILED;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.id == id && s_operation.state == PENDING) {
|
||||
s_operation.state = state;
|
||||
s_operation.error = error;
|
||||
s_operation.executing = false;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
}
|
||||
|
||||
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(request, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||
}
|
||||
static esp_err_t authorize(httpd_req_t *request, bool mutation, web_session_view_t *view, bool *allowed)
|
||||
{
|
||||
esp_err_t error = mutation ? web_cookie_auth_require_json(request, WEB_NETWORK_REQUEST_MAX, view, allowed) :
|
||||
web_cookie_auth_require(request, false, false, view, allowed);
|
||||
if (error == ESP_OK && *allowed && view->principal.role != USER_ROLE_ADMIN) {
|
||||
*allowed = false;
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
static bool append(char *out, size_t capacity, size_t *used, const char *format, ...)
|
||||
{
|
||||
va_list args; va_start(args, format);
|
||||
int count = vsnprintf(out + *used, capacity - *used, format, args);
|
||||
va_end(args);
|
||||
if (count < 0 || (size_t)count >= capacity - *used) return false;
|
||||
*used += (size_t)count; return true;
|
||||
}
|
||||
static bool append_ssid(char *out, size_t capacity, size_t *used, const uint8_t *ssid, size_t length)
|
||||
{
|
||||
if (length > WIFI_CONFIG_SSID_MAX_LEN || !append(out, capacity, used, "\"")) return false;
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
unsigned c = ssid[i];
|
||||
if (c >= 0x20 && c <= 0x7e && c != '"' && c != '\\') {
|
||||
if (!append(out, capacity, used, "%c", c)) return false;
|
||||
} else if (!append(out, capacity, used, "\\u%04x", c)) return false;
|
||||
}
|
||||
return append(out, capacity, used, "\"");
|
||||
}
|
||||
static const char *json_bool(bool value) { return value ? "true" : "false"; }
|
||||
|
||||
static esp_err_t snapshot_response(httpd_req_t *request)
|
||||
{
|
||||
wifi_manager_settings_t wifi;
|
||||
mdns_service_snapshot_t mdns;
|
||||
/* No blocking config getters, driver/NVS calls or secret-bearing copies on HTTPD. */
|
||||
if (wifi_manager_get_settings(&wifi) != ESP_OK || mdns_service_get_settings(&mdns) != ESP_OK)
|
||||
return respond(request, "503 Service Unavailable", "{\"error\":\"snapshot_unavailable\"}");
|
||||
char response[WEB_NETWORK_SNAPSHOT_MAX]; size_t used = 0;
|
||||
#define ADD(...) do { if (!append(response, sizeof(response), &used, __VA_ARGS__)) return ESP_FAIL; } while (0)
|
||||
#define SSID(data, length) do { if (!append_ssid(response, sizeof(response), &used, data, length)) return ESP_FAIL; } while (0)
|
||||
ADD("{\"wifi\":{\"generation\":%" PRIu32 ",\"enabled_at_boot\":%s,\"ap\":{\"policy\":\"%s\",\"channel\":%u,\"ssid\":",
|
||||
wifi.runtime.config_generation, json_bool(wifi.enabled_at_boot),
|
||||
wifi_config_ap_policy_to_string(wifi.ap_policy), (unsigned)wifi.ap_channel);
|
||||
SSID(wifi.ap_ssid, wifi.ap_ssid_len);
|
||||
ADD(",\"password_configured\":%s},\"profiles\":[", json_bool(wifi.ap_password_configured));
|
||||
for (unsigned i = 0; i < WIFI_CONFIG_STA_PROFILE_COUNT; ++i) {
|
||||
const wifi_manager_profile_settings_t *p = &wifi.profiles[i];
|
||||
ADD("%s{\"index\":%u,\"enabled\":%s,\"priority\":%u,\"security\":\"%s\",\"ssid\":",
|
||||
i ? "," : "", i, json_bool(p->enabled), (unsigned)p->priority, wifi_config_security_to_string(p->security));
|
||||
SSID(p->ssid, p->ssid_len);
|
||||
ADD(",\"password_configured\":%s}", json_bool(p->password_configured));
|
||||
}
|
||||
const wifi_manager_snapshot_t *r = &wifi.runtime;
|
||||
/* IPv4 bytes are already in network order, independent of host endianness. */
|
||||
const uint8_t *ip = (const uint8_t *)&r->ip;
|
||||
ADD("]},\"runtime\":{\"started\":%s,\"state\":\"%s\",\"active_profile\":%d,\"ip\":\"%u.%u.%u.%u\","
|
||||
"\"ap_running\":%s,\"ap_clients\":%u,\"last_error\":%d},",
|
||||
json_bool(r->started), wifi_manager_state_to_string(r->state), (int)r->active_profile,
|
||||
ip[0], ip[1], ip[2], ip[3], json_bool(r->ap_running), (unsigned)r->ap_client_count, (int)r->last_error);
|
||||
ADD("\"mdns\":{\"generation\":%" PRIu32 ",\"suffix\":\"%s\",\"hostname\":\"%s\",\"announced\":%s,\"last_error\":%d}}",
|
||||
mdns.config_generation, mdns.suffix, mdns.hostname, json_bool(mdns.announced), (int)mdns.last_error);
|
||||
#undef SSID
|
||||
#undef ADD
|
||||
return respond(request, "200 OK", response);
|
||||
}
|
||||
|
||||
esp_err_t web_network_snapshot_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0}; bool allowed = false;
|
||||
esp_err_t error = authorize(request, false, &view, &allowed);
|
||||
if (error == ESP_OK && allowed) {
|
||||
error = request->method == HTTP_GET ? snapshot_response(request) :
|
||||
respond(request, "405 Method Not Allowed", "{\"error\":\"method\"}");
|
||||
}
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_network_operation_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0}; bool allowed = false;
|
||||
network_operation_t operation = {0};
|
||||
bool mutation = request->method == HTTP_POST;
|
||||
esp_err_t error = authorize(request, mutation, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (request->method != HTTP_GET && !mutation) {
|
||||
error = respond(request, "405 Method Not Allowed", "{\"error\":\"method\"}"); goto done;
|
||||
}
|
||||
if (mutation) {
|
||||
char type[40] = {0}, body[WEB_NETWORK_REQUEST_MAX]; size_t received = 0;
|
||||
bool valid = request->content_len > 0 && request->content_len <= sizeof(body) &&
|
||||
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
|
||||
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
|
||||
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
|
||||
int count = httpd_req_recv(request, body + received, request->content_len - received);
|
||||
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
|
||||
else received += (size_t)count;
|
||||
}
|
||||
valid = valid && received == request->content_len && parse_request(body, received, &operation);
|
||||
secure_wipe(body, sizeof(body));
|
||||
if (!valid) {
|
||||
wipe_input(&operation);
|
||||
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_network_request\"}"); goto done;
|
||||
}
|
||||
if (!ensure_secret_timer()) {
|
||||
wipe_input(&operation);
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"timer_unavailable\"}"); goto done;
|
||||
}
|
||||
operation.session = view.id; operation.principal = view.principal;
|
||||
operation.deadline = esp_timer_get_time() + 30000000LL; operation.state = PENDING;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
|
||||
if (!busy) { operation.id = ++s_next_id; s_operation = operation; }
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
wipe_input(&operation);
|
||||
if (busy || admin_ssh_console_submit_network_settings(operation.id) != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
error = httpd_resp_set_hdr(request, "Retry-After", "1");
|
||||
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
|
||||
goto done;
|
||||
}
|
||||
} else {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.session == view.id) {
|
||||
operation.id = s_operation.id; operation.action = s_operation.action;
|
||||
operation.state = s_operation.state; operation.error = s_operation.error;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
/* Input is not needed for formatting or potentially blocking socket IO. */
|
||||
wipe_input(&operation);
|
||||
char response[128];
|
||||
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\",\"error\":%d}",
|
||||
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state], (int)operation.error);
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
||||
respond(request, mutation ? "202 Accepted" : "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_http_server.h"
|
||||
|
||||
#define WEB_NETWORK_REQUEST_MAX 768U
|
||||
#define WEB_NETWORK_SNAPSHOT_MAX 2048U
|
||||
|
||||
/* Integration: optional exact GET /api/settings/network -> snapshot_handler;
|
||||
* exact GET and POST /api/settings/network-operation -> operation_handler.
|
||||
* All require current admin cookie, same Origin; POST additionally CSRF/JSON.
|
||||
* No query strings. No changes to browser-shell command authorization.
|
||||
*
|
||||
* One session-bound replaceable result, no durable history/idempotency. Only an
|
||||
* ID enters the existing dispatcher. A periodic one-second ESP timer wipes and
|
||||
* cancels non-executing input at 30 seconds plus scheduling latency. Executing
|
||||
* locals wipe on return; already-admitted work can finish after session loss.
|
||||
* 'accepted' means RAM/owner queue admission, NEVER association or DHCP success.
|
||||
*
|
||||
* SSID JSON is a BYTE string: raw printable ASCII, standard single-character
|
||||
* JSON escapes, and \\u00HH only; each decoded codepoint maps to one byte. NUL and
|
||||
* non-UTF-8 bytes round-trip. No raw non-ASCII, other Unicode or surrogates. UI
|
||||
* must encode UTF-8 text into bytes before encoding this field, and retain a
|
||||
* reversible byte editor for existing arbitrary SSIDs. Length limit: 32 bytes.
|
||||
* No saved PSK/length is returned, only password_configured. Omitted password
|
||||
* preserves current bytes; clear_password:true is distinct from replacement.
|
||||
* Enabled STA requires a PSK; AP clear/open is always rejected, even policy off.
|
||||
* Wi-Fi Load is stored-only, no generated-default/reset/secret-delivery route.
|
||||
*/
|
||||
esp_err_t web_network_snapshot_handler(httpd_req_t *request);
|
||||
esp_err_t web_network_operation_handler(httpd_req_t *request);
|
||||
void web_network_settings_execute(uint32_t id);
|
||||
+129
-94
@@ -1,5 +1,5 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Canonical NVS storage for HTTPS identity with private v1 storage compatibility. */
|
||||
/* Canonical NVS storage for HTTPS identity and legacy recovery credentials. */
|
||||
|
||||
#include "web_security.h"
|
||||
|
||||
@@ -20,10 +20,12 @@
|
||||
#include "nvs.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
#define WEB_SECURITY_SCHEMA_VERSION 2U
|
||||
#define WEB_SECURITY_BLOB_SIZE 1340U
|
||||
#define LEGACY_BLOB_SIZE 1392U
|
||||
#define WEB_SECURITY_SCHEMA_VERSION 1U
|
||||
#define WEB_SECURITY_BLOB_SIZE 1392U
|
||||
|
||||
static const uint8_t s_admin_username[] = "admin";
|
||||
static const char s_password_alphabet[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
static const uint8_t s_ap_ipv4_address[4] = {192U, 168U, 4U, 1U};
|
||||
|
||||
typedef struct {
|
||||
@@ -31,20 +33,25 @@ typedef struct {
|
||||
uint16_t blob_size;
|
||||
uint16_t reserved_header;
|
||||
uint32_t generation;
|
||||
uint8_t username_length;
|
||||
uint8_t password_length;
|
||||
uint16_t private_key_length;
|
||||
uint16_t certificate_length;
|
||||
uint16_t reserved_lengths;
|
||||
uint8_t username[WEB_SECURITY_USERNAME_CAPACITY];
|
||||
uint8_t password[WEB_SECURITY_PASSWORD_CAPACITY];
|
||||
uint8_t private_key_der[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY];
|
||||
uint8_t certificate_der[WEB_SECURITY_CERTIFICATE_DER_CAPACITY];
|
||||
uint8_t certificate_fingerprint[WEB_SECURITY_SHA256_LENGTH];
|
||||
uint8_t reserved[12];
|
||||
} web_security_blob_t;
|
||||
|
||||
_Static_assert(offsetof(web_security_blob_t, private_key_der) == 16U,
|
||||
_Static_assert(offsetof(web_security_blob_t, username) == 20U,
|
||||
"web security schema offsets changed");
|
||||
_Static_assert(offsetof(web_security_blob_t, private_key_der) == 68U,
|
||||
"web security key offset changed");
|
||||
_Static_assert(offsetof(web_security_blob_t, certificate_der) == 272U,
|
||||
_Static_assert(offsetof(web_security_blob_t, certificate_der) == 324U,
|
||||
"web security certificate offset changed");
|
||||
_Static_assert(offsetof(web_security_blob_t, certificate_fingerprint) == 1296U,
|
||||
"web security fingerprint offset changed");
|
||||
_Static_assert(sizeof(web_security_blob_t) == WEB_SECURITY_BLOB_SIZE,
|
||||
"web security schema size changed");
|
||||
|
||||
@@ -109,6 +116,28 @@ static esp_err_t build_device_names(char *common_name, size_t common_name_size,
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t generate_credentials(web_security_blob_t *blob)
|
||||
{
|
||||
uint8_t random_bytes[WEB_SECURITY_PASSWORD_LENGTH] = {0};
|
||||
|
||||
memset(blob->username, 0, sizeof(blob->username));
|
||||
memset(blob->password, 0, sizeof(blob->password));
|
||||
memcpy(blob->username, s_admin_username, sizeof(s_admin_username) - 1U);
|
||||
blob->username_length = sizeof(s_admin_username) - 1U;
|
||||
blob->password_length = WEB_SECURITY_PASSWORD_LENGTH;
|
||||
|
||||
esp_err_t error = secure_random_fill(random_bytes, sizeof(random_bytes));
|
||||
if (error == ESP_OK) {
|
||||
/* Sixty-four symbols consume six random bits exactly, without modulo bias. */
|
||||
for (size_t i = 0U; i < sizeof(random_bytes); ++i) {
|
||||
blob->password[i] =
|
||||
(uint8_t)s_password_alphabet[random_bytes[i] & 0x3fU];
|
||||
}
|
||||
}
|
||||
secure_wipe(random_bytes, sizeof(random_bytes));
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t normalize_der(unsigned char *buffer, size_t capacity,
|
||||
int written, uint16_t *output_length)
|
||||
{
|
||||
@@ -367,16 +396,6 @@ cleanup:
|
||||
return valid;
|
||||
}
|
||||
|
||||
static bool der_is_exact_sequence(const uint8_t *der, size_t size)
|
||||
{
|
||||
unsigned char *cursor = (unsigned char *)der;
|
||||
const unsigned char *end = der + size;
|
||||
size_t length = 0U;
|
||||
return mbedtls_asn1_get_tag(&cursor, end, &length,
|
||||
MBEDTLS_ASN1_CONSTRUCTED | MBEDTLS_ASN1_SEQUENCE) == 0 &&
|
||||
length == (size_t)(end - cursor);
|
||||
}
|
||||
|
||||
static esp_err_t validate_certificate_and_key(const web_security_blob_t *blob)
|
||||
{
|
||||
char common_name[WEB_SECURITY_COMMON_NAME_CAPACITY] = {0};
|
||||
@@ -400,10 +419,6 @@ static esp_err_t validate_certificate_and_key(const web_security_blob_t *blob)
|
||||
sizeof(fingerprint))) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (!der_is_exact_sequence(blob->private_key_der, blob->private_key_length) ||
|
||||
!der_is_exact_sequence(blob->certificate_der, blob->certificate_length)) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (mbedtls_pk_parse_key(&private_key,
|
||||
blob->private_key_der, blob->private_key_length,
|
||||
NULL, 0U, secure_random_mbedtls, NULL) != 0 ||
|
||||
@@ -477,7 +492,16 @@ static esp_err_t validate_blob(const web_security_blob_t *blob)
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
if (blob->generation == 0U || blob->reserved_header != 0U ||
|
||||
blob->reserved_lengths != 0U ||
|
||||
!bytes_are_zero(blob->reserved, sizeof(blob->reserved)) ||
|
||||
blob->username_length != sizeof(s_admin_username) - 1U ||
|
||||
memcmp(blob->username, s_admin_username,
|
||||
sizeof(s_admin_username) - 1U) != 0 ||
|
||||
!unused_bytes_are_zero(blob->username, blob->username_length,
|
||||
sizeof(blob->username)) ||
|
||||
blob->password_length != WEB_SECURITY_PASSWORD_LENGTH ||
|
||||
!unused_bytes_are_zero(blob->password, blob->password_length,
|
||||
sizeof(blob->password)) ||
|
||||
blob->private_key_length == 0U ||
|
||||
blob->private_key_length > sizeof(blob->private_key_der) ||
|
||||
!unused_bytes_are_zero(blob->private_key_der, blob->private_key_length,
|
||||
@@ -489,6 +513,16 @@ static esp_err_t validate_blob(const web_security_blob_t *blob)
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
|
||||
for (size_t i = 0U; i < blob->password_length; ++i) {
|
||||
const uint8_t value = blob->password[i];
|
||||
bool valid = (value >= 'A' && value <= 'Z') ||
|
||||
(value >= 'a' && value <= 'z') ||
|
||||
(value >= '0' && value <= '9') ||
|
||||
value == '-' || value == '_';
|
||||
if (!valid) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
}
|
||||
return validate_certificate_and_key(blob);
|
||||
}
|
||||
|
||||
@@ -499,7 +533,10 @@ static esp_err_t generate_all(web_security_blob_t *blob, uint32_t generation)
|
||||
blob->blob_size = WEB_SECURITY_BLOB_SIZE;
|
||||
blob->generation = generation;
|
||||
|
||||
esp_err_t error = generate_certificate(blob);
|
||||
esp_err_t error = generate_credentials(blob);
|
||||
if (error == ESP_OK) {
|
||||
error = generate_certificate(blob);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = validate_blob(blob);
|
||||
}
|
||||
@@ -519,7 +556,7 @@ static esp_err_t save_blob(const web_security_blob_t *blob)
|
||||
return error;
|
||||
}
|
||||
|
||||
/* Publish only after commit. NVS replacement is not secure flash erasure. */
|
||||
/* NVS append semantics retain the committed predecessor until commit succeeds. */
|
||||
error = nvs_set_blob(handle, WEB_SECURITY_NVS_BLOB_KEY,
|
||||
blob, sizeof(*blob));
|
||||
if (error == ESP_OK) {
|
||||
@@ -529,57 +566,9 @@ static esp_err_t save_blob(const web_security_blob_t *blob)
|
||||
return error;
|
||||
}
|
||||
|
||||
/* The shipped ESP32 v1 wire layout is little-endian, independent of host ABI.
|
||||
* Credentials exist only in this transient decoder input, never live state. */
|
||||
static uint16_t legacy_u16(const uint8_t *p)
|
||||
{
|
||||
return (uint16_t)p[0] | (uint16_t)((uint16_t)p[1] << 8);
|
||||
}
|
||||
|
||||
static uint32_t legacy_u32(const uint8_t *p)
|
||||
{
|
||||
return (uint32_t)legacy_u16(p) | ((uint32_t)legacy_u16(p + 2) << 16);
|
||||
}
|
||||
|
||||
static esp_err_t decode_legacy(const uint8_t raw[LEGACY_BLOB_SIZE],
|
||||
web_security_blob_t *blob)
|
||||
{
|
||||
if (legacy_u32(raw) != 1U || legacy_u16(raw + 4) != LEGACY_BLOB_SIZE) {
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
if (legacy_u16(raw + 6) != 0U || legacy_u16(raw + 18) != 0U ||
|
||||
raw[12] != 5U || raw[13] != 24U ||
|
||||
memcmp(raw + 20, "admin", 5U) != 0 ||
|
||||
!bytes_are_zero(raw + 25, 11U) ||
|
||||
!bytes_are_zero(raw + 60, 8U) ||
|
||||
!bytes_are_zero(raw + 1380, 12U)) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
for (size_t i = 36U; i < 60U; ++i) {
|
||||
uint8_t c = raw[i];
|
||||
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '-' || c == '_')) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
}
|
||||
memset(blob, 0, sizeof(*blob));
|
||||
blob->schema_version = WEB_SECURITY_SCHEMA_VERSION;
|
||||
blob->blob_size = WEB_SECURITY_BLOB_SIZE;
|
||||
blob->generation = legacy_u32(raw + 8);
|
||||
blob->private_key_length = legacy_u16(raw + 14);
|
||||
blob->certificate_length = legacy_u16(raw + 16);
|
||||
memcpy(blob->private_key_der, raw + 68, sizeof(blob->private_key_der));
|
||||
memcpy(blob->certificate_der, raw + 324, sizeof(blob->certificate_der));
|
||||
memcpy(blob->certificate_fingerprint, raw + 1348,
|
||||
sizeof(blob->certificate_fingerprint));
|
||||
return validate_blob(blob);
|
||||
}
|
||||
|
||||
static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing,
|
||||
bool *migrated)
|
||||
static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing)
|
||||
{
|
||||
*missing = false;
|
||||
*migrated = false;
|
||||
nvs_handle_t handle;
|
||||
esp_err_t error = nvs_open(WEB_SECURITY_NVS_NAMESPACE, NVS_READONLY, &handle);
|
||||
if (error == ESP_ERR_NVS_NOT_FOUND) {
|
||||
@@ -605,21 +594,6 @@ static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing,
|
||||
nvs_close(handle);
|
||||
return error;
|
||||
}
|
||||
if (size == LEGACY_BLOB_SIZE) {
|
||||
uint8_t legacy[LEGACY_BLOB_SIZE] = {0};
|
||||
error = nvs_get_blob(handle, WEB_SECURITY_NVS_BLOB_KEY, legacy, &size);
|
||||
nvs_close(handle);
|
||||
if (error == ESP_OK) {
|
||||
error = size == LEGACY_BLOB_SIZE ? decode_legacy(legacy, blob)
|
||||
: ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
secure_wipe(legacy, sizeof(legacy));
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(blob);
|
||||
*migrated = error == ESP_OK;
|
||||
}
|
||||
return error == ESP_ERR_NVS_INVALID_LENGTH ? ESP_ERR_INVALID_VERSION : error;
|
||||
}
|
||||
if (size != sizeof(*blob)) {
|
||||
nvs_close(handle);
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
@@ -634,7 +608,7 @@ static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing,
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
return size == sizeof(*blob) ? validate_blob(blob) : ESP_ERR_INVALID_VERSION;
|
||||
return validate_blob(blob);
|
||||
}
|
||||
|
||||
esp_err_t web_security_init(web_security_load_result_t *load_result)
|
||||
@@ -659,8 +633,7 @@ esp_err_t web_security_init(web_security_load_result_t *load_result)
|
||||
|
||||
web_security_blob_t candidate;
|
||||
bool missing = false;
|
||||
bool migrated = false;
|
||||
error = load_stored_blob(&candidate, &missing, &migrated);
|
||||
error = load_stored_blob(&candidate, &missing);
|
||||
if (error == ESP_OK && missing) {
|
||||
error = generate_all(&candidate, 1U);
|
||||
if (error == ESP_OK) {
|
||||
@@ -671,8 +644,7 @@ esp_err_t web_security_init(web_security_load_result_t *load_result)
|
||||
s_material = candidate;
|
||||
s_material_ready = true;
|
||||
s_load_result = missing ? WEB_SECURITY_LOAD_GENERATED_MISSING
|
||||
: migrated ? WEB_SECURITY_LOAD_MIGRATED_V1
|
||||
: WEB_SECURITY_LOAD_STORED;
|
||||
: WEB_SECURITY_LOAD_STORED;
|
||||
if (load_result != NULL) {
|
||||
*load_result = s_load_result;
|
||||
}
|
||||
@@ -728,6 +700,35 @@ esp_err_t web_security_copy_tls_material(
|
||||
}
|
||||
|
||||
|
||||
static void copy_credentials_locked(web_security_credentials_t *credentials,
|
||||
const web_security_blob_t *blob)
|
||||
{
|
||||
memset(credentials, 0, sizeof(*credentials));
|
||||
credentials->username_length = blob->username_length;
|
||||
credentials->password_length = blob->password_length;
|
||||
memcpy(credentials->username, blob->username, blob->username_length);
|
||||
memcpy(credentials->password, blob->password, blob->password_length);
|
||||
}
|
||||
|
||||
esp_err_t web_security_show_credentials(web_security_credentials_t *credentials)
|
||||
{
|
||||
if (credentials == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_security_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
if (s_material_ready) {
|
||||
copy_credentials_locked(credentials, &s_material);
|
||||
error = ESP_OK;
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_security_get_certificate_metadata(
|
||||
web_security_certificate_metadata_t *metadata)
|
||||
{
|
||||
@@ -781,6 +782,37 @@ static void install_committed_blob(const web_security_blob_t *candidate)
|
||||
s_load_result = WEB_SECURITY_LOAD_STORED;
|
||||
}
|
||||
|
||||
esp_err_t web_security_rotate_credentials(web_security_credentials_t *new_credentials)
|
||||
{
|
||||
if (s_security_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
web_security_blob_t candidate;
|
||||
memset(&candidate, 0, sizeof(candidate));
|
||||
if (s_material_ready) {
|
||||
candidate = s_material;
|
||||
error = increment_generation(&candidate);
|
||||
if (error == ESP_OK) {
|
||||
error = generate_credentials(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_committed_blob(&candidate);
|
||||
if (new_credentials != NULL) {
|
||||
copy_credentials_locked(new_credentials, &s_material);
|
||||
}
|
||||
}
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_security_rotate_certificate(void)
|
||||
{
|
||||
if (s_security_mutex == NULL) {
|
||||
@@ -809,7 +841,7 @@ esp_err_t web_security_rotate_certificate(void)
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_security_reset_all(void)
|
||||
esp_err_t web_security_reset_all(web_security_credentials_t *new_credentials)
|
||||
{
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error != ESP_OK) {
|
||||
@@ -837,6 +869,9 @@ esp_err_t web_security_reset_all(void)
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_committed_blob(&candidate);
|
||||
if (new_credentials != NULL) {
|
||||
copy_credentials_locked(new_credentials, &s_material);
|
||||
}
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
|
||||
+20
-14
@@ -1,5 +1,5 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Persistent HTTPS identity; authentication belongs to the user database. */
|
||||
/* Persistent HTTPS identity and legacy migration/recovery credentials. */
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -16,7 +16,9 @@ extern "C" {
|
||||
#define WEB_SECURITY_NVS_NAMESPACE "web_sec"
|
||||
#define WEB_SECURITY_NVS_BLOB_KEY "material"
|
||||
|
||||
|
||||
#define WEB_SECURITY_USERNAME_CAPACITY 16U
|
||||
#define WEB_SECURITY_PASSWORD_CAPACITY 32U
|
||||
#define WEB_SECURITY_PASSWORD_LENGTH 24U
|
||||
#define WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY 256U
|
||||
#define WEB_SECURITY_CERTIFICATE_DER_CAPACITY 1024U
|
||||
#define WEB_SECURITY_SHA256_LENGTH 32U
|
||||
@@ -29,9 +31,18 @@ extern "C" {
|
||||
typedef enum {
|
||||
WEB_SECURITY_LOAD_STORED = 0,
|
||||
WEB_SECURITY_LOAD_GENERATED_MISSING = 1,
|
||||
WEB_SECURITY_LOAD_MIGRATED_V1 = 2,
|
||||
} web_security_load_result_t;
|
||||
|
||||
/*
|
||||
* This intentionally contains a displayable secret. UART callers should call
|
||||
* secure_wipe() on it immediately after rendering the length-delimited fields.
|
||||
*/
|
||||
typedef struct {
|
||||
size_t username_length;
|
||||
size_t password_length;
|
||||
char username[WEB_SECURITY_USERNAME_CAPACITY + 1U];
|
||||
char password[WEB_SECURITY_PASSWORD_CAPACITY + 1U];
|
||||
} web_security_credentials_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t material_generation;
|
||||
@@ -44,11 +55,7 @@ typedef struct {
|
||||
} web_security_certificate_metadata_t;
|
||||
|
||||
/*
|
||||
* NVS must already be initialized. Missing TLS material is generated and saved.
|
||||
* Valid v1 material is migrated to certificate-only v2 before publication,
|
||||
* preserving exact TLS identity and generation. Replacement is logical NVS
|
||||
* deletion of legacy fields, not secure flash erasure. Migration failure never
|
||||
* triggers regeneration or fallback overwrite;
|
||||
* NVS must already be initialized. Missing material is generated and saved;
|
||||
* an existing wrong-version blob returns ESP_ERR_INVALID_VERSION, while any
|
||||
* malformed or cryptographically inconsistent blob returns
|
||||
* ESP_ERR_INVALID_RESPONSE and is never overwritten. Call before radio startup
|
||||
@@ -66,18 +73,17 @@ esp_err_t web_security_copy_tls_material(
|
||||
uint8_t *private_key, size_t private_key_capacity,
|
||||
size_t *private_key_length);
|
||||
|
||||
|
||||
/* Explicit secret-bearing API intended for a physically attached UART CLI. */
|
||||
esp_err_t web_security_show_credentials(web_security_credentials_t *credentials);
|
||||
esp_err_t web_security_get_certificate_metadata(
|
||||
web_security_certificate_metadata_t *metadata);
|
||||
|
||||
/* Mutations become visible only after a complete blob has committed to NVS. */
|
||||
|
||||
esp_err_t web_security_rotate_credentials(web_security_credentials_t *new_credentials);
|
||||
esp_err_t web_security_rotate_certificate(void);
|
||||
|
||||
/* TLS ONLY: explicitly replaces missing, valid, or incompatible material.
|
||||
* Generation increments from live state, or starts at one if unavailable.
|
||||
* No user database mutation. */
|
||||
esp_err_t web_security_reset_all(void);
|
||||
/* Explicitly replaces missing, valid, or incompatible stored material. */
|
||||
esp_err_t web_security_reset_all(web_security_credentials_t *new_credentials);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_serial_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "secure_random.h"
|
||||
#include "serial_service.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
|
||||
enum { APPLY, START, STOP, SAVE, LOAD, DEFAULTS, RESET, ACTION_COUNT };
|
||||
static const char *const s_actions[] = {"apply", "start", "stop", "save", "load", "defaults", "reset"};
|
||||
enum { IDLE, PENDING, OK, FAILED, CANCELLED, LOADED_DEFAULTS, ROLLBACK_FAILED };
|
||||
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "loaded_defaults", "rollback_failed"};
|
||||
typedef struct {
|
||||
uint32_t id;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
int64_t deadline;
|
||||
serial_config_t config;
|
||||
unsigned action, state;
|
||||
} serial_operation_t;
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static serial_operation_t s_operation;
|
||||
static uint32_t s_next_id;
|
||||
|
||||
/* Deliberately narrow flat JSON: ASCII names/enums, unsigned decimal integers,
|
||||
* no escapes, nesting, duplicate/unknown fields, exponent or fractional values. */
|
||||
static bool parse(const char *body, size_t length, serial_operation_t *operation)
|
||||
{
|
||||
const char *keys[] = {"action", "baud", "data_bits", "parity", "stop_bits", "flow", "dtr", "rts_threshold"};
|
||||
unsigned seen = 0;
|
||||
size_t pos = 0;
|
||||
serial_config_defaults(&operation->config);
|
||||
operation->action = ACTION_COUNT;
|
||||
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
|
||||
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
|
||||
TAKE('{');
|
||||
for (unsigned field = 0; field < 8; ++field) {
|
||||
if (field) { TAKE(','); }
|
||||
TAKE('"');
|
||||
size_t start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
unsigned key = 0;
|
||||
for (; key < 8; ++key)
|
||||
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
||||
if (key == 8 || (seen & (1U << key))) return false;
|
||||
++pos; TAKE(':'); SPACE();
|
||||
uint32_t number = 0;
|
||||
char value[16] = {0};
|
||||
if (key == 1 || key == 7) {
|
||||
start = pos;
|
||||
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
|
||||
if (number > 1000000U) return false;
|
||||
number = number * 10 + (unsigned)(body[pos++] - '0');
|
||||
}
|
||||
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
||||
} else {
|
||||
TAKE('"'); start = pos;
|
||||
while (pos < length && body[pos] != '"') {
|
||||
if (body[pos] < ' ' || body[pos] > '~' || body[pos] == '\\' || pos - start >= sizeof(value) - 1) return false;
|
||||
++pos;
|
||||
}
|
||||
if (pos == length) return false;
|
||||
memcpy(value, body + start, pos - start); ++pos;
|
||||
}
|
||||
switch (key) {
|
||||
case 0:
|
||||
for (unsigned i = 0; i < ACTION_COUNT; ++i)
|
||||
if (!strcmp(value, s_actions[i])) operation->action = i;
|
||||
if (operation->action == ACTION_COUNT) return false;
|
||||
break;
|
||||
case 1: operation->config.baud_rate = number; break;
|
||||
case 2: if (!serial_config_parse_data_bits(value, &operation->config.data_bits)) return false; break;
|
||||
case 3: if (!serial_config_parse_parity(value, &operation->config.parity)) return false; break;
|
||||
case 4: if (!serial_config_parse_stop_bits(value, &operation->config.stop_bits)) return false; break;
|
||||
case 5: if (!serial_config_parse_flow_control(value, &operation->config.flow_control)) return false; break;
|
||||
case 6: if (!serial_config_parse_dtr_behavior(value, &operation->config.dtr_behavior)) return false; break;
|
||||
case 7: operation->config.rts_threshold = number; break;
|
||||
}
|
||||
seen |= 1U << key;
|
||||
SPACE();
|
||||
if (pos < length && body[pos] == '}') break;
|
||||
}
|
||||
TAKE('}'); SPACE();
|
||||
#undef TAKE
|
||||
#undef SPACE
|
||||
return pos == length && seen == (operation->action == APPLY ? 255U : 1U) &&
|
||||
serial_config_validate(&operation->config) == ESP_OK;
|
||||
}
|
||||
|
||||
void web_serial_settings_execute(uint32_t id)
|
||||
{
|
||||
serial_operation_t operation;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
operation = s_operation;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!id || operation.id != id || operation.state != PENDING) {
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
return;
|
||||
}
|
||||
bool current = false;
|
||||
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t);
|
||||
unsigned state = CANCELLED;
|
||||
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
||||
esp_timer_get_time() < operation.deadline) {
|
||||
/* Operation-admission currentness, not cancellation of an admitted NVS
|
||||
* commit. CLI commands cannot interleave on this single dispatcher. */
|
||||
serial_config_t config, previous;
|
||||
bool stored = true;
|
||||
state = OK;
|
||||
switch (operation.action) {
|
||||
case APPLY: error = serial_service_apply_config(&operation.config); break;
|
||||
case START: error = serial_service_start(); break;
|
||||
case STOP: error = serial_service_stop(); break;
|
||||
case SAVE:
|
||||
error = serial_service_get_config(&config);
|
||||
if (error == ESP_OK) error = serial_config_save(&config);
|
||||
break;
|
||||
case LOAD:
|
||||
error = serial_config_load(&config, &stored);
|
||||
if (error == ESP_OK) error = serial_service_apply_config(&config);
|
||||
if (!stored) state = LOADED_DEFAULTS;
|
||||
break;
|
||||
case DEFAULTS:
|
||||
serial_config_defaults(&config);
|
||||
error = serial_service_apply_config(&config);
|
||||
break;
|
||||
case RESET:
|
||||
serial_config_defaults(&config);
|
||||
error = serial_service_get_config(&previous);
|
||||
if (error == ESP_OK) error = serial_service_apply_config(&config);
|
||||
if (error == ESP_OK) {
|
||||
error = serial_config_reset_storage();
|
||||
if (error != ESP_OK && serial_service_apply_config(&previous) != ESP_OK)
|
||||
state = ROLLBACK_FAILED;
|
||||
}
|
||||
break;
|
||||
default: error = ESP_ERR_INVALID_ARG; break;
|
||||
}
|
||||
if (error != ESP_OK && state != ROLLBACK_FAILED) state = FAILED;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.id == id && s_operation.state == PENDING) {
|
||||
s_operation.state = state;
|
||||
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
|
||||
secure_wipe(&s_operation.config, sizeof(s_operation.config));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
}
|
||||
|
||||
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(request, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
esp_err_t web_serial_settings_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
bool mutation = request->method == HTTP_POST;
|
||||
esp_err_t error = mutation
|
||||
? web_cookie_auth_require_json(request, 256, &view, &allowed)
|
||||
: web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
serial_operation_t operation = {0};
|
||||
if (mutation) {
|
||||
char type[40] = {0}, body[256];
|
||||
size_t received = 0;
|
||||
bool valid = request->content_len &&
|
||||
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
|
||||
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
|
||||
/* Finite bytes and receive calls; timeout/error closes, never retry/drain. */
|
||||
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
|
||||
int count = httpd_req_recv(request, body + received, request->content_len - received);
|
||||
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
|
||||
else received += (size_t)count;
|
||||
}
|
||||
valid = valid && received == request->content_len && parse(body, received, &operation);
|
||||
secure_wipe(body, sizeof(body));
|
||||
if (!valid) {
|
||||
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_serial_request\"}");
|
||||
goto done;
|
||||
}
|
||||
operation.session = view.id;
|
||||
operation.principal = view.principal;
|
||||
operation.deadline = esp_timer_get_time() + 30000000LL;
|
||||
operation.state = PENDING;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
|
||||
if (!busy) {
|
||||
operation.id = ++s_next_id;
|
||||
s_operation = operation;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (busy || admin_ssh_console_submit_serial_settings(operation.id) != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
error = httpd_resp_set_hdr(request, "Retry-After", "1");
|
||||
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
goto done;
|
||||
}
|
||||
} else {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.session == view.id) {
|
||||
operation.id = s_operation.id;
|
||||
operation.action = s_operation.action;
|
||||
operation.state = s_operation.state;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
char response[96];
|
||||
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
|
||||
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
||||
respond(request, mutation ? "202 Accepted" : "200 OK", response);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
done:
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/* HTTPD owns requests/responses; the existing admin dispatcher alone executes.
|
||||
* One global slot rejects mutations while pending (including execution). GET
|
||||
* exposes only the caller's session result; a later admitted operation replaces
|
||||
* that result, so this is not a durable history or an idempotent retry API.
|
||||
* The 30-second deadline is checked when dequeued, not a completion deadline or
|
||||
* a timer that frees the slot. Revocation/expiry cancels before operation
|
||||
* admission; admitted serial/NVS work may finish after the session is gone. */
|
||||
esp_err_t web_serial_settings_handler(httpd_req_t *request);
|
||||
void web_serial_settings_execute(uint32_t id);
|
||||
+231
-178
@@ -14,9 +14,6 @@
|
||||
#include "sdkconfig.h"
|
||||
#include "secure_random.h"
|
||||
#include "serial_service.h"
|
||||
#include "web_auth_parse.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "web_admin_transport.h"
|
||||
|
||||
#if !defined(CONFIG_HTTPD_WS_SUPPORT) || !CONFIG_HTTPD_WS_SUPPORT
|
||||
#error "web_serial_transport requires CONFIG_HTTPD_WS_SUPPORT"
|
||||
@@ -52,7 +49,7 @@ typedef struct {
|
||||
uint8_t digest[WEB_SERIAL_SHA256_BYTES];
|
||||
int64_t expires_at_us;
|
||||
user_principal_t principal;
|
||||
web_session_id_t web_session_id;
|
||||
web_session_ref_t session_reference;
|
||||
bool active;
|
||||
} web_serial_ticket_t;
|
||||
|
||||
@@ -72,7 +69,7 @@ typedef struct web_serial_slot {
|
||||
uint32_t generation;
|
||||
session_broker_client_id_t broker_client_id;
|
||||
user_principal_t principal;
|
||||
web_session_id_t web_session_id;
|
||||
web_session_ref_t session_reference;
|
||||
int64_t next_currentness_check_us;
|
||||
bool writer;
|
||||
bool hello_pending;
|
||||
@@ -100,15 +97,6 @@ static web_serial_slot_t s_slots[WEB_SERIAL_TRANSPORT_MAX_SESSIONS];
|
||||
static web_serial_transport_counters_t s_counters;
|
||||
static uint32_t s_httpd_close_operations;
|
||||
static uint32_t s_inflight_handlers;
|
||||
/* Cancels ticket publication across revocation and server detach/re-attach.
|
||||
* Never wraps: exhaustion disables minting for the remainder of the boot. */
|
||||
static uint64_t s_ticket_epoch;
|
||||
|
||||
static esp_err_t identity_is_current(const user_principal_t *principal,
|
||||
web_session_id_t id, bool *current)
|
||||
{
|
||||
return web_session_store_check_principal(id, principal, current);
|
||||
}
|
||||
|
||||
static TickType_t milliseconds_to_ticks(uint32_t milliseconds)
|
||||
{
|
||||
@@ -159,8 +147,9 @@ static void clear_ticket_locked(web_serial_ticket_t *ticket)
|
||||
{
|
||||
secure_wipe(ticket->digest, sizeof(ticket->digest));
|
||||
secure_wipe(&ticket->principal, sizeof(ticket->principal));
|
||||
secure_wipe(&ticket->session_reference,
|
||||
sizeof(ticket->session_reference));
|
||||
ticket->expires_at_us = 0;
|
||||
ticket->web_session_id = 0U;
|
||||
ticket->active = false;
|
||||
}
|
||||
|
||||
@@ -192,6 +181,40 @@ static bool constant_time_equal(const uint8_t *left, const uint8_t *right,
|
||||
return difference == 0U;
|
||||
}
|
||||
|
||||
static bool principal_equal(const user_principal_t *left,
|
||||
const user_principal_t *right)
|
||||
{
|
||||
if (left->username_length > USER_DATABASE_USERNAME_CAPACITY ||
|
||||
right->username_length > USER_DATABASE_USERNAME_CAPACITY) {
|
||||
return false;
|
||||
}
|
||||
return left->user_id == right->user_id &&
|
||||
left->auth_generation == right->auth_generation &&
|
||||
left->role == right->role && left->method == right->method &&
|
||||
left->username_length == right->username_length &&
|
||||
memcmp(left->username, right->username, left->username_length) == 0;
|
||||
}
|
||||
|
||||
static bool session_reference_equal(const web_session_ref_t *left,
|
||||
const web_session_ref_t *right)
|
||||
{
|
||||
return web_session_ref_valid(left) && web_session_ref_valid(right) &&
|
||||
left->slot_index == right->slot_index &&
|
||||
left->generation == right->generation;
|
||||
}
|
||||
|
||||
static esp_err_t authentication_binding_is_current(
|
||||
const user_principal_t *principal,
|
||||
const web_session_ref_t *session_reference, bool *current)
|
||||
{
|
||||
if (principal == NULL || !web_session_ref_valid(session_reference) ||
|
||||
current == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
*current = false;
|
||||
return web_session_ref_is_current(session_reference, principal, current);
|
||||
}
|
||||
|
||||
static void encode_base64url_24(const uint8_t input[WEB_SERIAL_RANDOM_BYTES],
|
||||
char output[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY])
|
||||
{
|
||||
@@ -283,22 +306,27 @@ static esp_err_t validate_origin(httpd_req_t *request)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
bool matches = web_auth_parse_origin(host, strlen(host), origin, strlen(origin), expected);
|
||||
int written = snprintf(expected, sizeof(expected), "https://%s", host);
|
||||
bool matches = written > 0 && (size_t)written < sizeof(expected) &&
|
||||
strcmp(origin, expected) == 0;
|
||||
secure_wipe(origin, sizeof(origin));
|
||||
secure_wipe(host, sizeof(host));
|
||||
secure_wipe(expected, sizeof(expected));
|
||||
return matches ? ESP_OK : ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
static esp_err_t consume_ticket(const char *ticket,
|
||||
web_session_id_t web_session_id,
|
||||
user_principal_t *principal, bool *consumed)
|
||||
|
||||
static esp_err_t consume_ticket(
|
||||
const char *ticket, user_principal_t *principal,
|
||||
web_session_ref_t *session_reference, bool *consumed)
|
||||
{
|
||||
uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0};
|
||||
user_principal_t candidate = {0};
|
||||
web_session_ref_t candidate_reference = {0};
|
||||
bool ticket_found = false;
|
||||
*consumed = false;
|
||||
memset(principal, 0, sizeof(*principal));
|
||||
memset(session_reference, 0, sizeof(*session_reference));
|
||||
|
||||
esp_err_t result = sha256_ticket(ticket, digest);
|
||||
if (result != ESP_OK) {
|
||||
@@ -326,9 +354,9 @@ static esp_err_t consume_ticket(const char *ticket,
|
||||
if (matching_count == 1U &&
|
||||
matching_index < WEB_SERIAL_TRANSPORT_MAX_TICKETS) {
|
||||
web_serial_ticket_t *entry = &s_tickets[matching_index];
|
||||
if (entry->active && entry->expires_at_us > now_us &&
|
||||
entry->web_session_id == web_session_id) {
|
||||
if (entry->active && entry->expires_at_us > now_us) {
|
||||
candidate = entry->principal;
|
||||
candidate_reference = entry->session_reference;
|
||||
clear_ticket_locked(entry);
|
||||
ticket_found = true;
|
||||
}
|
||||
@@ -348,9 +376,11 @@ static esp_err_t consume_ticket(const char *ticket,
|
||||
|
||||
if (ticket_found) {
|
||||
bool current = false;
|
||||
result = identity_is_current(&candidate, web_session_id, ¤t);
|
||||
result = authentication_binding_is_current(
|
||||
&candidate, &candidate_reference, ¤t);
|
||||
if (result == ESP_OK && current) {
|
||||
*principal = candidate;
|
||||
*session_reference = candidate_reference;
|
||||
*consumed = true;
|
||||
add_counter(&s_counters.tickets_consumed, 1U);
|
||||
}
|
||||
@@ -360,6 +390,7 @@ static esp_err_t consume_ticket(const char *ticket,
|
||||
}
|
||||
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
secure_wipe(&candidate_reference, sizeof(candidate_reference));
|
||||
secure_wipe(digest, sizeof(digest));
|
||||
return result;
|
||||
}
|
||||
@@ -383,7 +414,8 @@ static web_serial_slot_t *reserve_slot(httpd_handle_t server, int socket_fd,
|
||||
slot->socket_fd = socket_fd;
|
||||
slot->broker_client_id = SESSION_BROKER_NO_CLIENT;
|
||||
secure_wipe(&slot->principal, sizeof(slot->principal));
|
||||
slot->web_session_id = 0U;
|
||||
secure_wipe(&slot->session_reference,
|
||||
sizeof(slot->session_reference));
|
||||
slot->next_currentness_check_us = 0;
|
||||
slot->writer = false;
|
||||
slot->hello_pending = false;
|
||||
@@ -409,7 +441,7 @@ static void make_slot_free_locked(web_serial_slot_t *slot)
|
||||
slot->socket_fd = -1;
|
||||
slot->broker_client_id = SESSION_BROKER_NO_CLIENT;
|
||||
secure_wipe(&slot->principal, sizeof(slot->principal));
|
||||
slot->web_session_id = 0U;
|
||||
secure_wipe(&slot->session_reference, sizeof(slot->session_reference));
|
||||
slot->next_currentness_check_us = 0;
|
||||
slot->writer = false;
|
||||
slot->hello_pending = false;
|
||||
@@ -445,6 +477,9 @@ static void close_unpublished_broker_session(web_serial_slot_t *slot,
|
||||
} else {
|
||||
slot->state = WEB_SERIAL_SLOT_CLOSING;
|
||||
slot->broker_client_id = client_id;
|
||||
secure_wipe(&slot->principal, sizeof(slot->principal));
|
||||
secure_wipe(&slot->session_reference,
|
||||
sizeof(slot->session_reference));
|
||||
++s_counters.broker_failures;
|
||||
}
|
||||
}
|
||||
@@ -494,6 +529,8 @@ static void web_serial_session_free(void *context)
|
||||
if (slot->state == WEB_SERIAL_SLOT_ACTIVE) {
|
||||
slot->state = WEB_SERIAL_SLOT_CLOSING;
|
||||
secure_wipe(&slot->principal, sizeof(slot->principal));
|
||||
secure_wipe(&slot->session_reference,
|
||||
sizeof(slot->session_reference));
|
||||
slot->next_currentness_check_us = 0;
|
||||
slot->writer = false;
|
||||
slot->hello_pending = false;
|
||||
@@ -526,13 +563,13 @@ static esp_err_t send_plain_bad_request(httpd_req_t *request)
|
||||
return result;
|
||||
}
|
||||
|
||||
static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd,
|
||||
web_session_id_t web_session_id)
|
||||
static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
|
||||
{
|
||||
char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY] = {0};
|
||||
uint32_t slot_generation = 0U;
|
||||
web_serial_slot_t *slot = NULL;
|
||||
user_principal_t principal = {0};
|
||||
web_session_ref_t session_reference = {0};
|
||||
bool consumed = false;
|
||||
esp_err_t result;
|
||||
|
||||
@@ -550,22 +587,22 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd,
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
result = consume_ticket(ticket, web_session_id, &principal, &consumed);
|
||||
result = consume_ticket(ticket, &principal, &session_reference, &consumed);
|
||||
if (result != ESP_OK || !consumed) {
|
||||
release_reserved_slot(slot, slot_generation);
|
||||
result = ESP_FAIL;
|
||||
goto cleanup;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool principal_staged = slot->state == WEB_SERIAL_SLOT_RESERVED &&
|
||||
slot->generation == slot_generation &&
|
||||
!slot->close_requested;
|
||||
if (principal_staged) {
|
||||
bool binding_staged = slot->state == WEB_SERIAL_SLOT_RESERVED &&
|
||||
slot->generation == slot_generation &&
|
||||
!slot->close_requested;
|
||||
if (binding_staged) {
|
||||
slot->principal = principal;
|
||||
slot->web_session_id = web_session_id;
|
||||
slot->session_reference = session_reference;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!principal_staged) {
|
||||
if (!binding_staged) {
|
||||
release_reserved_slot(slot, slot_generation);
|
||||
result = ESP_FAIL;
|
||||
goto cleanup;
|
||||
@@ -586,9 +623,10 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd,
|
||||
}
|
||||
}
|
||||
|
||||
bool principal_current = false;
|
||||
result = identity_is_current(&principal, web_session_id, &principal_current);
|
||||
if (result != ESP_OK || !principal_current) {
|
||||
bool binding_current = false;
|
||||
result = authentication_binding_is_current(
|
||||
&principal, &session_reference, &binding_current);
|
||||
if (result != ESP_OK || !binding_current) {
|
||||
release_reserved_slot(slot, slot_generation);
|
||||
result = ESP_FAIL;
|
||||
goto cleanup;
|
||||
@@ -617,9 +655,10 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd,
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
principal_current = false;
|
||||
result = identity_is_current(&principal, web_session_id, &principal_current);
|
||||
if (result != ESP_OK || !principal_current) {
|
||||
binding_current = false;
|
||||
result = authentication_binding_is_current(
|
||||
&principal, &session_reference, &binding_current);
|
||||
if (result != ESP_OK || !binding_current) {
|
||||
close_unpublished_broker_session(slot, slot_generation, client_id);
|
||||
result = ESP_FAIL;
|
||||
goto cleanup;
|
||||
@@ -640,21 +679,40 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd,
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
principal_current = false;
|
||||
result = identity_is_current(&principal, web_session_id, &principal_current);
|
||||
if (result != ESP_OK || !principal_current) {
|
||||
binding_current = false;
|
||||
result = authentication_binding_is_current(
|
||||
&principal, &session_reference, &binding_current);
|
||||
if (result != ESP_OK || !binding_current) {
|
||||
close_unpublished_broker_session(slot, slot_generation, client_id);
|
||||
result = ESP_FAIL;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
bool staged_current;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
staged_current = slot->state == WEB_SERIAL_SLOT_RESERVED &&
|
||||
slot->generation == slot_generation &&
|
||||
!slot->close_requested &&
|
||||
principal_equal(&slot->principal, &principal) &&
|
||||
session_reference_equal(&slot->session_reference,
|
||||
&session_reference);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!staged_current) {
|
||||
close_unpublished_broker_session(slot, slot_generation, client_id);
|
||||
result = ESP_FAIL;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
binding_current = false;
|
||||
result = authentication_binding_is_current(
|
||||
&principal, &session_reference, &binding_current);
|
||||
if (result != ESP_OK || !binding_current) {
|
||||
close_unpublished_broker_session(slot, slot_generation, client_id);
|
||||
result = ESP_FAIL;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
bool activated = false;
|
||||
/* Ticket and principal admission must succeed before HTTP 101. */
|
||||
result = web_httpd_upgrade(request, web_serial_transport_ws_handler);
|
||||
if (result != ESP_OK) {
|
||||
close_unpublished_broker_session(slot, slot_generation, client_id);
|
||||
goto cleanup;
|
||||
}
|
||||
int64_t next_currentness_check_us =
|
||||
monotonic_time_us() + WEB_SERIAL_CURRENTNESS_INTERVAL_US;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
@@ -664,6 +722,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd,
|
||||
slot->state = WEB_SERIAL_SLOT_ACTIVE;
|
||||
slot->broker_client_id = client_id;
|
||||
slot->principal = principal;
|
||||
slot->session_reference = session_reference;
|
||||
slot->next_currentness_check_us = next_currentness_check_us;
|
||||
slot->writer = writer;
|
||||
slot->hello_pending = true;
|
||||
@@ -687,15 +746,15 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd,
|
||||
|
||||
cleanup:
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
secure_wipe(&session_reference, sizeof(session_reference));
|
||||
secure_wipe(ticket, sizeof(ticket));
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool capture_active_session(httpd_req_t *request, web_serial_slot_t **slot_out,
|
||||
uint32_t *generation,
|
||||
session_broker_client_id_t *client_id,
|
||||
user_principal_t *principal,
|
||||
web_session_id_t *web_session_id)
|
||||
static bool capture_active_session(
|
||||
httpd_req_t *request, web_serial_slot_t **slot_out, uint32_t *generation,
|
||||
session_broker_client_id_t *client_id, user_principal_t *principal,
|
||||
web_session_ref_t *session_reference)
|
||||
{
|
||||
web_serial_slot_t *slot = request->sess_ctx;
|
||||
int socket_fd = httpd_req_to_sockfd(request);
|
||||
@@ -715,7 +774,7 @@ static bool capture_active_session(httpd_req_t *request, web_serial_slot_t **slo
|
||||
*generation = slot->generation;
|
||||
*client_id = slot->broker_client_id;
|
||||
*principal = slot->principal;
|
||||
*web_session_id = slot->web_session_id;
|
||||
*session_reference = slot->session_reference;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return valid;
|
||||
@@ -827,9 +886,9 @@ static esp_err_t process_websocket_frame(httpd_req_t *request)
|
||||
uint32_t generation = 0U;
|
||||
session_broker_client_id_t client_id = SESSION_BROKER_NO_CLIENT;
|
||||
user_principal_t principal = {0};
|
||||
web_session_id_t web_session_id = 0U;
|
||||
web_session_ref_t session_reference = {0};
|
||||
if (!capture_active_session(request, &slot, &generation, &client_id,
|
||||
&principal, &web_session_id)) {
|
||||
&principal, &session_reference)) {
|
||||
add_counter(&s_counters.protocol_errors, 1U);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
@@ -838,6 +897,7 @@ static esp_err_t process_websocket_frame(httpd_req_t *request)
|
||||
if (httpd_ws_get_fd_info(request->handle, socket_fd) !=
|
||||
HTTPD_WS_CLIENT_WEBSOCKET) {
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
secure_wipe(&session_reference, sizeof(session_reference));
|
||||
return reject_protocol_frame(slot, generation, 0U);
|
||||
}
|
||||
|
||||
@@ -845,6 +905,7 @@ static esp_err_t process_websocket_frame(httpd_req_t *request)
|
||||
esp_err_t result = httpd_ws_recv_frame(request, &frame, 0U);
|
||||
if (result != ESP_OK) {
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
secure_wipe(&session_reference, sizeof(session_reference));
|
||||
return reject_protocol_frame(slot, generation, frame.len);
|
||||
}
|
||||
if (!frame.final || frame.type == HTTPD_WS_TYPE_CONTINUE ||
|
||||
@@ -852,6 +913,7 @@ static esp_err_t process_websocket_frame(httpd_req_t *request)
|
||||
(frame.type != HTTPD_WS_TYPE_BINARY &&
|
||||
frame.type != HTTPD_WS_TYPE_TEXT)) {
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
secure_wipe(&session_reference, sizeof(session_reference));
|
||||
return reject_protocol_frame(slot, generation, frame.len);
|
||||
}
|
||||
|
||||
@@ -859,14 +921,16 @@ static esp_err_t process_websocket_frame(httpd_req_t *request)
|
||||
result = httpd_ws_recv_frame(request, &frame, sizeof(slot->rx_data));
|
||||
if (result != ESP_OK) {
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
secure_wipe(&session_reference, sizeof(session_reference));
|
||||
return reject_protocol_frame(slot, generation, frame.len);
|
||||
}
|
||||
|
||||
bool current = false;
|
||||
esp_err_t currentness_result =
|
||||
identity_is_current(&principal, web_session_id, ¤t);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
esp_err_t currentness_result = authentication_binding_is_current(
|
||||
&principal, &session_reference, ¤t);
|
||||
if (currentness_result != ESP_OK || !current) {
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
secure_wipe(&session_reference, sizeof(session_reference));
|
||||
request_handler_close(slot, generation);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
@@ -877,8 +941,13 @@ static esp_err_t process_websocket_frame(httpd_req_t *request)
|
||||
slot->generation == generation &&
|
||||
slot->broker_client_id == client_id &&
|
||||
slot->server == request->handle &&
|
||||
slot->server == s_server;
|
||||
slot->server == s_server &&
|
||||
principal_equal(&slot->principal, &principal) &&
|
||||
session_reference_equal(&slot->session_reference,
|
||||
&session_reference);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
secure_wipe(&session_reference, sizeof(session_reference));
|
||||
if (!still_active) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
@@ -1223,6 +1292,8 @@ static void process_close_request(web_serial_slot_t *slot)
|
||||
if (session_gone) {
|
||||
slot->state = WEB_SERIAL_SLOT_CLOSING;
|
||||
secure_wipe(&slot->principal, sizeof(slot->principal));
|
||||
secure_wipe(&slot->session_reference,
|
||||
sizeof(slot->session_reference));
|
||||
slot->next_currentness_check_us = 0;
|
||||
slot->writer = false;
|
||||
slot->hello_pending = false;
|
||||
@@ -1275,10 +1346,10 @@ static void process_broker_disconnect(web_serial_slot_t *slot)
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static void process_principal_currentness(web_serial_slot_t *slot)
|
||||
static void process_authentication_currentness(web_serial_slot_t *slot)
|
||||
{
|
||||
user_principal_t principal = {0};
|
||||
web_session_id_t web_session_id = 0U;
|
||||
web_session_ref_t session_reference = {0};
|
||||
uint32_t generation = 0U;
|
||||
bool check = false;
|
||||
int64_t now_us = monotonic_time_us();
|
||||
@@ -1288,7 +1359,7 @@ static void process_principal_currentness(web_serial_slot_t *slot)
|
||||
slot->next_currentness_check_us <= now_us) {
|
||||
generation = slot->generation;
|
||||
principal = slot->principal;
|
||||
web_session_id = slot->web_session_id;
|
||||
session_reference = slot->session_reference;
|
||||
slot->next_currentness_check_us =
|
||||
now_us + WEB_SERIAL_CURRENTNESS_INTERVAL_US;
|
||||
check = true;
|
||||
@@ -1299,18 +1370,20 @@ static void process_principal_currentness(web_serial_slot_t *slot)
|
||||
}
|
||||
|
||||
bool current = false;
|
||||
esp_err_t result = identity_is_current(&principal, web_session_id, ¤t);
|
||||
esp_err_t result = authentication_binding_is_current(
|
||||
&principal, &session_reference, ¤t);
|
||||
if (result != ESP_OK || !current) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (slot->state == WEB_SERIAL_SLOT_ACTIVE &&
|
||||
slot->generation == generation &&
|
||||
session_reference_equal(&slot->session_reference,
|
||||
&session_reference)) {
|
||||
slot->close_requested = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
if (result == ESP_OK && current) {
|
||||
return;
|
||||
}
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (slot->state == WEB_SERIAL_SLOT_ACTIVE &&
|
||||
slot->generation == generation) {
|
||||
slot->close_requested = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&session_reference, sizeof(session_reference));
|
||||
}
|
||||
|
||||
static void process_active_output(web_serial_slot_t *slot)
|
||||
@@ -1370,7 +1443,7 @@ static void transport_task(void *context)
|
||||
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS;
|
||||
++index) {
|
||||
web_serial_slot_t *slot = &s_slots[index];
|
||||
process_principal_currentness(slot);
|
||||
process_authentication_currentness(slot);
|
||||
process_close_request(slot);
|
||||
process_broker_disconnect(slot);
|
||||
process_active_output(slot);
|
||||
@@ -1444,9 +1517,6 @@ esp_err_t web_serial_transport_attach_server(httpd_handle_t server)
|
||||
} else {
|
||||
clear_all_tickets_locked();
|
||||
s_server = server;
|
||||
if (s_ticket_epoch != UINT64_MAX) {
|
||||
++s_ticket_epoch;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (result == ESP_OK) {
|
||||
@@ -1473,9 +1543,6 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server)
|
||||
* observes the cleared server and disconnects unpublished broker state.
|
||||
*/
|
||||
s_server = NULL;
|
||||
if (s_ticket_epoch != UINT64_MAX) {
|
||||
++s_ticket_epoch;
|
||||
}
|
||||
clear_all_tickets_locked();
|
||||
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS;
|
||||
++index) {
|
||||
@@ -1489,6 +1556,8 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server)
|
||||
if (slot->state == WEB_SERIAL_SLOT_ACTIVE) {
|
||||
slot->state = WEB_SERIAL_SLOT_CLOSING;
|
||||
secure_wipe(&slot->principal, sizeof(slot->principal));
|
||||
secure_wipe(&slot->session_reference,
|
||||
sizeof(slot->session_reference));
|
||||
slot->next_currentness_check_us = 0;
|
||||
slot->writer = false;
|
||||
slot->hello_pending = false;
|
||||
@@ -1528,7 +1597,7 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server)
|
||||
if (quiescent) {
|
||||
break;
|
||||
}
|
||||
if (operations_done && monotonic_time_us() >= detach_deadline) {
|
||||
if (monotonic_time_us() >= detach_deadline) {
|
||||
session_broker_client_id_t writer_id =
|
||||
session_broker_get_writer_id();
|
||||
bool web_writer = false;
|
||||
@@ -1542,11 +1611,14 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server)
|
||||
break;
|
||||
}
|
||||
}
|
||||
bool httpd_close_idle = s_httpd_close_operations == 0U;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (web_writer) {
|
||||
(void)session_broker_force_writer(SESSION_BROKER_NO_CLIENT);
|
||||
(void)session_broker_force_release_writer(writer_id);
|
||||
}
|
||||
result = ESP_ERR_TIMEOUT;
|
||||
/* HTTPD must remain alive around permanent-task API calls. */
|
||||
result = httpd_close_idle ? ESP_ERR_TIMEOUT
|
||||
: ESP_ERR_INVALID_STATE;
|
||||
break;
|
||||
}
|
||||
notify_transport_task();
|
||||
@@ -1556,22 +1628,19 @@ esp_err_t web_serial_transport_detach_server(httpd_handle_t server)
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
|
||||
web_session_id_t web_session_id,
|
||||
char *ticket, size_t capacity)
|
||||
esp_err_t web_serial_transport_mint_ticket(
|
||||
const user_principal_t *principal,
|
||||
const web_session_ref_t *session_reference, char *ticket, size_t capacity)
|
||||
{
|
||||
if (principal == NULL || ticket == NULL ||
|
||||
capacity < WEB_SERIAL_TRANSPORT_TICKET_CAPACITY) {
|
||||
if (principal == NULL || !web_session_ref_valid(session_reference) ||
|
||||
ticket == NULL || capacity < WEB_SERIAL_TRANSPORT_TICKET_CAPACITY) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
ticket[0] = '\0';
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
uint64_t epoch = s_ticket_epoch;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
bool current = false;
|
||||
esp_err_t result = identity_is_current(principal, web_session_id, ¤t);
|
||||
esp_err_t result = authentication_binding_is_current(
|
||||
principal, session_reference, ¤t);
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
@@ -1579,26 +1648,6 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
/* Reclaim stale identities without database calls under the transport lock.
|
||||
* A late result must not clear a ticket published into the same array slot. */
|
||||
for (size_t i = 0; i < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++i) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
web_serial_ticket_t candidate = s_tickets[i];
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
bool live = false;
|
||||
if (candidate.active &&
|
||||
(identity_is_current(&candidate.principal, candidate.web_session_id, &live) != ESP_OK || !live)) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
web_serial_ticket_t *entry = &s_tickets[i];
|
||||
if (entry->active && entry->web_session_id == candidate.web_session_id &&
|
||||
entry->expires_at_us == candidate.expires_at_us &&
|
||||
constant_time_equal(entry->digest, candidate.digest, sizeof(entry->digest)))
|
||||
clear_ticket_locked(entry);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
}
|
||||
|
||||
uint8_t random_bytes[WEB_SERIAL_RANDOM_BYTES] = {0};
|
||||
uint8_t digest[WEB_SERIAL_SHA256_BYTES] = {0};
|
||||
result = secure_random_fill(random_bytes, sizeof(random_bytes));
|
||||
@@ -1612,26 +1661,39 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
|
||||
}
|
||||
|
||||
current = false;
|
||||
result = identity_is_current(principal, web_session_id, ¤t);
|
||||
result = authentication_binding_is_current(
|
||||
principal, session_reference, ¤t);
|
||||
if (result != ESP_OK || !current) {
|
||||
result = ESP_ERR_INVALID_STATE;
|
||||
ticket[0] = '\0';
|
||||
if (result == ESP_OK) {
|
||||
result = ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
int64_t now_us = monotonic_time_us();
|
||||
bool stored = false;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_initialized && s_server != NULL && epoch == s_ticket_epoch &&
|
||||
epoch != UINT64_MAX) {
|
||||
if (s_initialized && s_server != NULL) {
|
||||
purge_tickets_locked(now_us);
|
||||
size_t selected = WEB_SERIAL_TRANSPORT_MAX_TICKETS;
|
||||
size_t free_slot = WEB_SERIAL_TRANSPORT_MAX_TICKETS;
|
||||
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS;
|
||||
++index) {
|
||||
web_serial_ticket_t *entry = &s_tickets[index];
|
||||
if (!entry->active) {
|
||||
if (entry->active && session_reference_equal(
|
||||
&entry->session_reference,
|
||||
session_reference)) {
|
||||
selected = index;
|
||||
break;
|
||||
}
|
||||
if (!entry->active &&
|
||||
free_slot == WEB_SERIAL_TRANSPORT_MAX_TICKETS) {
|
||||
free_slot = index;
|
||||
}
|
||||
}
|
||||
if (selected == WEB_SERIAL_TRANSPORT_MAX_TICKETS) {
|
||||
selected = free_slot;
|
||||
}
|
||||
if (selected < WEB_SERIAL_TRANSPORT_MAX_TICKETS) {
|
||||
web_serial_ticket_t *entry = &s_tickets[selected];
|
||||
@@ -1641,7 +1703,7 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
|
||||
now_us + (int64_t)WEB_SERIAL_TRANSPORT_TICKET_LIFETIME_SECONDS *
|
||||
1000000LL;
|
||||
entry->principal = *principal;
|
||||
entry->web_session_id = web_session_id;
|
||||
entry->session_reference = *session_reference;
|
||||
entry->active = true;
|
||||
++s_counters.tickets_issued;
|
||||
stored = true;
|
||||
@@ -1662,9 +1724,10 @@ cleanup:
|
||||
|
||||
esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
||||
httpd_req_t *request, const user_principal_t *principal,
|
||||
web_session_id_t web_session_id)
|
||||
const web_session_ref_t *session_reference)
|
||||
{
|
||||
if (request == NULL || principal == NULL) {
|
||||
if (request == NULL || principal == NULL ||
|
||||
!web_session_ref_valid(session_reference)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (request->method != HTTP_POST || request->content_len != 0U ||
|
||||
@@ -1683,7 +1746,7 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
||||
char ticket[WEB_SERIAL_TRANSPORT_TICKET_CAPACITY] = {0};
|
||||
char response[WEB_SERIAL_TICKET_RESPONSE_CAPACITY];
|
||||
esp_err_t result = web_serial_transport_mint_ticket(
|
||||
principal, web_session_id, ticket, sizeof(ticket));
|
||||
principal, session_reference, ticket, sizeof(ticket));
|
||||
if (result != ESP_OK) {
|
||||
secure_wipe(ticket, sizeof(ticket));
|
||||
return result;
|
||||
@@ -1706,9 +1769,6 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
||||
if (result == ESP_OK) {
|
||||
result = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = httpd_resp_send(request, response, written);
|
||||
}
|
||||
@@ -1717,13 +1777,8 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request)
|
||||
{
|
||||
return web_serial_transport_session_ws_handler(request, 0U);
|
||||
}
|
||||
|
||||
esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request,
|
||||
web_session_id_t web_session_id)
|
||||
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request)
|
||||
{
|
||||
if (request == NULL || request->handle == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
@@ -1736,14 +1791,12 @@ esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request,
|
||||
|
||||
httpd_ws_client_info_t info =
|
||||
httpd_ws_get_fd_info(request->handle, socket_fd);
|
||||
bool opening = request->sess_ctx == NULL && web_session_id != 0U &&
|
||||
request->method == HTTP_GET && web_httpd_upgrade_requested(request);
|
||||
if (info == HTTPD_WS_CLIENT_HTTP && !opening) {
|
||||
if (info == HTTPD_WS_CLIENT_HTTP) {
|
||||
(void)send_plain_bad_request(request);
|
||||
add_counter(&s_counters.protocol_errors, 1U);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
if (info != HTTPD_WS_CLIENT_WEBSOCKET && !opening) {
|
||||
if (info != HTTPD_WS_CLIENT_WEBSOCKET) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
@@ -1759,8 +1812,8 @@ esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request,
|
||||
|
||||
esp_err_t result;
|
||||
if (request->sess_ctx == NULL) {
|
||||
/* The registered HTTP route defers 101 until admission. */
|
||||
result = connect_websocket(request, socket_fd, web_session_id);
|
||||
/* IDF has already sent 101; authentication failures must only close. */
|
||||
result = connect_websocket(request, socket_fd);
|
||||
} else {
|
||||
result = process_websocket_frame(request);
|
||||
}
|
||||
@@ -1836,6 +1889,44 @@ esp_err_t web_serial_transport_clear_counters(void)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_serial_transport_revoke_session(
|
||||
const web_session_ref_t *session_reference)
|
||||
{
|
||||
if (!web_session_ref_valid(session_reference)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
bool notify = false;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!s_initialized) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++index) {
|
||||
web_serial_ticket_t *ticket = &s_tickets[index];
|
||||
if (ticket->active && session_reference_equal(
|
||||
&ticket->session_reference,
|
||||
session_reference)) {
|
||||
clear_ticket_locked(ticket);
|
||||
}
|
||||
}
|
||||
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
web_serial_slot_t *slot = &s_slots[index];
|
||||
if ((slot->state == WEB_SERIAL_SLOT_RESERVED ||
|
||||
slot->state == WEB_SERIAL_SLOT_ACTIVE) &&
|
||||
session_reference_equal(&slot->session_reference,
|
||||
session_reference)) {
|
||||
slot->close_requested = true;
|
||||
notify = true;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (notify) {
|
||||
notify_transport_task();
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
|
||||
size_t username_length)
|
||||
{
|
||||
@@ -1843,13 +1934,8 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
web_session_store_invalidate_username(username, username_length);
|
||||
web_admin_transport_revoke(0, username, username_length);
|
||||
bool notify = false;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_ticket_epoch != UINT64_MAX) {
|
||||
++s_ticket_epoch;
|
||||
}
|
||||
if (!s_initialized) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
@@ -1880,12 +1966,7 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
|
||||
|
||||
esp_err_t web_serial_transport_revoke_sessions(void)
|
||||
{
|
||||
web_session_store_invalidate_username(NULL, 0U);
|
||||
web_admin_transport_revoke(0, NULL, 0);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_ticket_epoch != UINT64_MAX) {
|
||||
++s_ticket_epoch;
|
||||
}
|
||||
if (!s_initialized) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
@@ -1894,36 +1975,8 @@ esp_err_t web_serial_transport_revoke_sessions(void)
|
||||
clear_all_tickets_locked();
|
||||
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
web_serial_slot_t *slot = &s_slots[index];
|
||||
if (slot->state == WEB_SERIAL_SLOT_ACTIVE ||
|
||||
slot->state == WEB_SERIAL_SLOT_RESERVED) {
|
||||
slot->close_requested = true;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
notify_transport_task();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id)
|
||||
{
|
||||
if (id == 0U) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
web_session_store_invalidate(id);
|
||||
web_admin_transport_revoke(id, NULL, 0);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_ticket_epoch != UINT64_MAX) {
|
||||
++s_ticket_epoch;
|
||||
}
|
||||
for (size_t i = 0U; i < WEB_SERIAL_TRANSPORT_MAX_TICKETS; ++i) {
|
||||
if (s_tickets[i].active && s_tickets[i].web_session_id == id) {
|
||||
clear_ticket_locked(&s_tickets[i]);
|
||||
}
|
||||
}
|
||||
for (size_t i = 0U; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) {
|
||||
web_serial_slot_t *slot = &s_slots[i];
|
||||
if ((slot->state == WEB_SERIAL_SLOT_RESERVED ||
|
||||
slot->state == WEB_SERIAL_SLOT_ACTIVE) && slot->web_session_id == id) {
|
||||
if (slot->state == WEB_SERIAL_SLOT_RESERVED ||
|
||||
slot->state == WEB_SERIAL_SLOT_ACTIVE) {
|
||||
slot->close_requested = true;
|
||||
}
|
||||
}
|
||||
|
||||
+21
-25
@@ -11,7 +11,7 @@
|
||||
#include "esp_http_server.h"
|
||||
#include "session_broker.h"
|
||||
#include "user_database.h"
|
||||
#include "web_session_store.h"
|
||||
#include "web_session.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -99,38 +99,32 @@ esp_err_t web_serial_transport_attach_server(httpd_handle_t server);
|
||||
esp_err_t web_serial_transport_detach_server(httpd_handle_t server);
|
||||
|
||||
/*
|
||||
* Mint a one-time bearer ticket bound to a current authenticated principal and
|
||||
* nonzero originating web-session ID. The
|
||||
* principal is copied; the output is exactly 32 Base64URL characters plus a
|
||||
* terminator and expires after 30 monotonic seconds. Never log or persist it.
|
||||
* Mint a one-time bearer ticket bound to one exact current browser login session.
|
||||
* The principal and session reference are copied; the output is exactly 32
|
||||
* Base64URL characters plus a terminator and expires after 30 monotonic seconds.
|
||||
* Never log or persist the ticket.
|
||||
*/
|
||||
esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
|
||||
web_session_id_t web_session_id,
|
||||
char *ticket, size_t capacity);
|
||||
esp_err_t web_serial_transport_mint_ticket(
|
||||
const user_principal_t *principal, const web_session_ref_t *session_reference,
|
||||
char *ticket, size_t capacity);
|
||||
|
||||
/*
|
||||
* Convenience POST response helper for /api/ws-ticket. Authentication is
|
||||
* intentionally outside this module: pass the principal returned by successful
|
||||
* authentication, and its session ID. Callers must also
|
||||
* enforce CSRF/Origin policy. Register as HTTP_POST, not as a public handler.
|
||||
* intentionally outside this module: pass the principal and exact session
|
||||
* reference returned by successful cookie-session authentication. Register it as
|
||||
* HTTP_POST, not as a public handler.
|
||||
*/
|
||||
esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
||||
httpd_req_t *request, const user_principal_t *principal,
|
||||
web_session_id_t web_session_id);
|
||||
const web_session_ref_t *session_reference);
|
||||
|
||||
|
||||
/*
|
||||
* Frame callback installed by the HTTPD adapter after authorized admission.
|
||||
* Do not register directly: the initial HTTP GET must pass cookie/Origin policy
|
||||
* and call the session handler below before any 101 response.
|
||||
* Handler for /ws/serial. Register as HTTP_GET with is_websocket=true and
|
||||
* handle_ws_control_frames=false. The initial upgraded GET authenticates the
|
||||
* ticket; later invocations process one complete data frame.
|
||||
*/
|
||||
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request);
|
||||
/* Trusted cookie-authorized upgrade caller; validate cookie/Origin first.
|
||||
* Zero is invalid for initial admission; no Basic fallback exists. */
|
||||
esp_err_t web_serial_transport_session_ws_handler(httpd_req_t *request,
|
||||
web_session_id_t web_session_id);
|
||||
/* Invalidates the store first, then marks only matching tickets/slots for owner
|
||||
* cleanup. Safe to repeat after either store or transport slot reuse. */
|
||||
esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id);
|
||||
|
||||
esp_err_t web_serial_transport_get_snapshot(
|
||||
web_serial_transport_snapshot_t *snapshot);
|
||||
@@ -138,9 +132,11 @@ esp_err_t web_serial_transport_get_snapshot(
|
||||
/* Clearing counters does not alter tickets, sessions, ownership, or queued data. */
|
||||
esp_err_t web_serial_transport_clear_counters(void);
|
||||
|
||||
/* Invalidate cookie records and tickets/sockets for one username (also after
|
||||
* deletion), or all accounts. Store invalidation occurs even if serial init
|
||||
* failed. Authoritative store/principal checks supplement notifications. */
|
||||
/* Invalidate tickets and request closure for one exact browser login session. */
|
||||
esp_err_t web_serial_transport_revoke_session(
|
||||
const web_session_ref_t *session_reference);
|
||||
|
||||
/* Invalidate tickets/sessions for one account, or all authenticated sessions. */
|
||||
esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
|
||||
size_t username_length);
|
||||
esp_err_t web_serial_transport_revoke_sessions(void);
|
||||
|
||||
+3533
-300
File diff suppressed because it is too large
Load Diff
+40
-1
@@ -1,12 +1,14 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Authenticated HTTPS administration foundation. */
|
||||
/* Session-authenticated HTTPS administration foundation. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "web_session.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -22,17 +24,39 @@ typedef struct {
|
||||
uint64_t root_requests;
|
||||
uint64_t status_requests;
|
||||
uint64_t ticket_requests;
|
||||
uint64_t admin_ticket_requests;
|
||||
uint64_t admin_authorization_failures;
|
||||
uint64_t admin_api_requests;
|
||||
uint64_t admin_request_rejections;
|
||||
uint64_t admin_operation_failures;
|
||||
uint64_t writer_transfer_attempts;
|
||||
uint64_t writer_transfer_successes;
|
||||
uint64_t writer_transfer_conflicts;
|
||||
uint64_t asset_requests;
|
||||
uint64_t response_errors;
|
||||
uint64_t login_requests;
|
||||
uint64_t login_successes;
|
||||
uint64_t login_failures;
|
||||
uint64_t login_throttled;
|
||||
uint64_t logout_requests;
|
||||
uint64_t session_requests;
|
||||
uint64_t cookie_rejections;
|
||||
uint64_t origin_rejections;
|
||||
uint64_t csrf_rejections;
|
||||
} web_server_counters_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
bool running;
|
||||
bool transitioning;
|
||||
bool desired_running;
|
||||
uint32_t lifecycle_generation;
|
||||
uint16_t port;
|
||||
esp_err_t last_error;
|
||||
esp_err_t serial_transport_error;
|
||||
esp_err_t admin_transport_error;
|
||||
uint32_t active_sessions;
|
||||
web_session_counters_t session_counters;
|
||||
web_server_counters_t counters;
|
||||
} web_server_snapshot_t;
|
||||
|
||||
@@ -42,10 +66,25 @@ esp_err_t web_server_init(void);
|
||||
/* Start one TLS-only server on all active network interfaces. */
|
||||
esp_err_t web_server_start(void);
|
||||
esp_err_t web_server_stop(void);
|
||||
/*
|
||||
* Stop only while expected_lifecycle_generation still names the latest
|
||||
* explicit HTTPS intent. A newer start/stop/refresh wins and returns
|
||||
* ESP_ERR_INVALID_STATE without changing server state.
|
||||
*/
|
||||
esp_err_t web_server_stop_if_generation(uint32_t expected_lifecycle_generation);
|
||||
/*
|
||||
* Apply current TLS material to a live server. If it is stopped, start only
|
||||
* when requested and no newer explicit lifecycle intent superseded the caller.
|
||||
*/
|
||||
esp_err_t web_server_refresh_tls(uint32_t expected_lifecycle_generation,
|
||||
bool start_if_unchanged);
|
||||
|
||||
esp_err_t web_server_get_snapshot(web_server_snapshot_t *snapshot);
|
||||
esp_err_t web_server_clear_counters(void);
|
||||
|
||||
/* Revoke one account's browser sessions and both WebSocket transports. */
|
||||
esp_err_t web_server_revoke_user(const uint8_t *username, size_t username_length);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
+1097
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded opaque browser sessions with principal and CSRF validation. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "user_database.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define WEB_SESSION_MAX_SESSIONS 8U
|
||||
#define WEB_SESSION_MAX_SESSIONS_PER_ACCOUNT 2U
|
||||
#define WEB_SESSION_BASE64URL_SOURCE_LENGTH 24U
|
||||
#define WEB_SESSION_TOKEN_RANDOM_LENGTH WEB_SESSION_BASE64URL_SOURCE_LENGTH
|
||||
#define WEB_SESSION_TOKEN_LENGTH 32U
|
||||
#define WEB_SESSION_TOKEN_CAPACITY (WEB_SESSION_TOKEN_LENGTH + 1U)
|
||||
#define WEB_SESSION_TOKEN_DIGEST_LENGTH 32U
|
||||
#define WEB_SESSION_CSRF_KEY_LENGTH 32U
|
||||
#define WEB_SESSION_HMAC_BLOCK_LENGTH 64U
|
||||
#define WEB_SESSION_HMAC_DIGEST_LENGTH 32U
|
||||
#define WEB_SESSION_CSRF_SOURCE_LENGTH WEB_SESSION_BASE64URL_SOURCE_LENGTH
|
||||
#define WEB_SESSION_CSRF_TOKEN_LENGTH 32U
|
||||
#define WEB_SESSION_CSRF_TOKEN_CAPACITY (WEB_SESSION_CSRF_TOKEN_LENGTH + 1U)
|
||||
#define WEB_SESSION_LIFETIME_SECONDS (8U * 60U * 60U)
|
||||
#define WEB_SESSION_LIFETIME_US \
|
||||
((int64_t)WEB_SESSION_LIFETIME_SECONDS * 1000000LL)
|
||||
#define WEB_SESSION_ERR_CAPACITY ESP_ERR_NO_MEM
|
||||
|
||||
typedef struct {
|
||||
uint8_t slot_index;
|
||||
uint32_t generation;
|
||||
} web_session_ref_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t created;
|
||||
uint64_t create_failures;
|
||||
uint64_t capacity_failures;
|
||||
uint64_t authenticated;
|
||||
uint64_t rejected;
|
||||
uint64_t expired;
|
||||
uint64_t stale_principal;
|
||||
uint64_t destroyed;
|
||||
uint64_t revocations;
|
||||
uint64_t csrf_accepted;
|
||||
uint64_t csrf_rejected;
|
||||
} web_session_counters_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
uint32_t active_sessions;
|
||||
web_session_counters_t counters;
|
||||
} web_session_snapshot_t;
|
||||
|
||||
/*
|
||||
* Generate the boot-local CSRF key and initialize the fixed session table.
|
||||
* secure_random_init() and user_database_init() must already have succeeded.
|
||||
* Repeated calls after successful initialization return ESP_OK without changing
|
||||
* sessions, the boot-local key, or counters.
|
||||
*/
|
||||
esp_err_t web_session_init(void);
|
||||
|
||||
/*
|
||||
* Authenticate bounded username/password input and create a fixed eight-hour
|
||||
* session. On success, session_token contains exactly 32 Base64URL characters
|
||||
* plus a terminator and principal is a copied secret-free value. The caller must send
|
||||
* the session token only through a suitably protected host-only cookie and must
|
||||
* never log either token. Invalid credentials return ESP_OK with created=false;
|
||||
* a full table of current, unexpired sessions returns WEB_SESSION_ERR_CAPACITY.
|
||||
* At most two sessions are retained per account; a later login replaces that
|
||||
* account's oldest session. All secret-bearing outputs are cleared on failure.
|
||||
*/
|
||||
esp_err_t web_session_create(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
char *session_token, size_t session_token_capacity,
|
||||
user_principal_t *principal, bool *created);
|
||||
|
||||
/*
|
||||
* Authenticate an exact length-delimited session token. Missing, malformed,
|
||||
* expired, destroyed, or unknown tokens return ESP_OK with authenticated=false.
|
||||
* Database/currentness failures fail closed and are returned to the caller.
|
||||
*/
|
||||
esp_err_t web_session_authenticate(const char *session_token,
|
||||
size_t session_token_length,
|
||||
user_principal_t *principal,
|
||||
bool *authenticated);
|
||||
|
||||
|
||||
/* A syntactically valid reference names a bounded slot and nonzero generation. */
|
||||
bool web_session_ref_valid(const web_session_ref_t *reference);
|
||||
|
||||
/*
|
||||
* Authenticate exactly as web_session_authenticate() and additionally return the
|
||||
* reference of that exact live browser login session. reference is cleared unless
|
||||
* authenticated is true. No raw session token is retained.
|
||||
*/
|
||||
esp_err_t web_session_get_reference(
|
||||
const char *session_token, size_t session_token_length,
|
||||
user_principal_t *principal, web_session_ref_t *reference,
|
||||
bool *authenticated);
|
||||
|
||||
/*
|
||||
* Check that reference still names the same live session carrying principal and
|
||||
* that the copied principal remains current in the user database. The database
|
||||
* check is performed without holding the browser-session mutex, followed by
|
||||
* locked reference revalidation. Database failures fail closed.
|
||||
*/
|
||||
esp_err_t web_session_ref_is_current(const web_session_ref_t *reference,
|
||||
const user_principal_t *principal,
|
||||
bool *current);
|
||||
|
||||
/*
|
||||
* Authenticate a session and reproduce its deterministic boot-local CSRF token.
|
||||
* This supports rendering a fresh page without storing separate per-session CSRF
|
||||
* material. csrf_token is cleared unless authenticated is true.
|
||||
*/
|
||||
esp_err_t web_session_copy_csrf_token(
|
||||
const char *session_token, size_t session_token_length,
|
||||
char *csrf_token, size_t csrf_token_capacity,
|
||||
user_principal_t *principal, bool *authenticated);
|
||||
|
||||
/*
|
||||
* Authenticate the session and compare an exact length-delimited CSRF token in
|
||||
* constant time. accepted is true only when both the current session and its
|
||||
* session-bound CSRF token are valid. The copied principal is cleared otherwise.
|
||||
*/
|
||||
esp_err_t web_session_authenticate_csrf(
|
||||
const char *session_token, size_t session_token_length,
|
||||
const char *csrf_token, size_t csrf_token_length,
|
||||
user_principal_t *principal, bool *accepted);
|
||||
|
||||
/* Explicitly destroy every entry matching one opaque token digest. */
|
||||
esp_err_t web_session_destroy(const char *session_token,
|
||||
size_t session_token_length,
|
||||
bool *destroyed);
|
||||
|
||||
/* Revoke sessions for one bounded username, or every browser session. */
|
||||
esp_err_t web_session_revoke_username(const uint8_t *username,
|
||||
size_t username_length);
|
||||
esp_err_t web_session_revoke_all(void);
|
||||
|
||||
/* The snapshot contains no token, digest, CSRF key, expiry, or principal data. */
|
||||
esp_err_t web_session_get_snapshot(web_session_snapshot_t *snapshot);
|
||||
|
||||
/* Clearing counters never changes live sessions or the boot-local CSRF key. */
|
||||
esp_err_t web_session_clear_counters(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -1,468 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_session_store.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <string.h>
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
typedef struct {
|
||||
web_session_id_t id;
|
||||
int64_t expires_at_us;
|
||||
user_principal_t principal;
|
||||
uint8_t token_digest[WEB_SESSION_STORE_SECRET_BYTES];
|
||||
uint8_t origin_digest[WEB_SESSION_STORE_SECRET_BYTES];
|
||||
uint8_t csrf[WEB_SESSION_STORE_SECRET_BYTES];
|
||||
} session_entry_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static struct {
|
||||
session_entry_t entries[WEB_SESSION_STORE_CAPACITY];
|
||||
uint64_t next_id;
|
||||
uint64_t epoch;
|
||||
bool ready;
|
||||
bool initializing;
|
||||
uint32_t issued;
|
||||
uint32_t capacity_rejections;
|
||||
uint32_t expired;
|
||||
uint32_t invalidated;
|
||||
uint32_t lookup_rejections;
|
||||
uint32_t init_failures;
|
||||
} s_state;
|
||||
|
||||
static bool equal_bytes(const uint8_t *a, const uint8_t *b, size_t length)
|
||||
{
|
||||
uint8_t difference = 0U;
|
||||
for (size_t i = 0U; i < length; ++i) {
|
||||
difference |= a[i] ^ b[i];
|
||||
}
|
||||
return difference == 0U;
|
||||
}
|
||||
|
||||
static void encode_hex(const uint8_t *bytes, char *text)
|
||||
{
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_SECRET_BYTES; ++i) {
|
||||
text[2U * i] = hex[bytes[i] >> 4U];
|
||||
text[2U * i + 1U] = hex[bytes[i] & 15U];
|
||||
}
|
||||
text[WEB_SESSION_STORE_TOKEN_LENGTH] = '\0';
|
||||
}
|
||||
|
||||
static esp_err_t digest(const void *input, size_t length, uint8_t *output)
|
||||
{
|
||||
return mbedtls_sha256(input, length, output, 0) == 0 ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
|
||||
static esp_err_t origin_digest(const char *origin, size_t length, uint8_t *output)
|
||||
{
|
||||
if (origin == NULL || length <= 8U ||
|
||||
length > WEB_SESSION_STORE_ORIGIN_MAX_LENGTH ||
|
||||
memcmp(origin, "https://", 8U) != 0 || memchr(origin, '\0', length) != NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
return digest(origin, length, output);
|
||||
}
|
||||
|
||||
static session_entry_t *find_locked(web_session_id_t id)
|
||||
{
|
||||
if (id != 0U) {
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
if (s_state.entries[i].id == id) {
|
||||
return &s_state.entries[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void retire_locked(session_entry_t *entry, bool expired)
|
||||
{
|
||||
if (entry->id != 0U) {
|
||||
if (expired) {
|
||||
++s_state.expired;
|
||||
} else {
|
||||
++s_state.invalidated;
|
||||
}
|
||||
secure_wipe(entry, sizeof(*entry));
|
||||
}
|
||||
}
|
||||
|
||||
static void expire_locked(int64_t now)
|
||||
{
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
session_entry_t *entry = &s_state.entries[i];
|
||||
if (entry->id != 0U && entry->expires_at_us <= now) {
|
||||
retire_locked(entry, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Also cancels issuance already outside the lock, even if no record matched.
|
||||
* Exhaustion is fail-closed rather than allowing an epoch/identity ABA. */
|
||||
static void advance_epoch_locked(void)
|
||||
{
|
||||
if (s_state.epoch != UINT64_MAX) {
|
||||
++s_state.epoch;
|
||||
} else {
|
||||
s_state.ready = false;
|
||||
}
|
||||
}
|
||||
|
||||
static void export_view(const session_entry_t *entry, web_session_view_t *view)
|
||||
{
|
||||
view->id = entry->id;
|
||||
view->expires_at_us = entry->expires_at_us;
|
||||
view->principal = entry->principal;
|
||||
encode_hex(entry->csrf, view->csrf);
|
||||
}
|
||||
|
||||
esp_err_t web_session_store_init(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_state.ready) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (s_state.initializing || s_state.epoch == UINT64_MAX) {
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_state.initializing = true;
|
||||
uint64_t epoch = s_state.epoch;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
uint8_t probe[WEB_SESSION_STORE_SECRET_BYTES] = {0};
|
||||
esp_err_t error = secure_random_fill(probe, sizeof(probe));
|
||||
secure_wipe(probe, sizeof(probe));
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (epoch != s_state.epoch) {
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_state.initializing = false;
|
||||
s_state.ready = error == ESP_OK;
|
||||
if (error != ESP_OK) {
|
||||
++s_state.init_failures;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_session_store_stop(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
s_state.ready = false;
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
retire_locked(&s_state.entries[i], false);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
/* Never enter the database while holding our lock. On return, re-find the
|
||||
* non-reused ID and deadline: logout/stop/slot reuse may have raced the call. */
|
||||
static esp_err_t resolve(web_session_id_t id, web_session_view_t *view,
|
||||
const user_principal_t *expected)
|
||||
{
|
||||
session_entry_t candidate = {0};
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
session_entry_t *entry = find_locked(id);
|
||||
esp_err_t error = s_state.ready ? ESP_ERR_NOT_FOUND : ESP_ERR_INVALID_STATE;
|
||||
if (s_state.ready && entry != NULL) {
|
||||
candidate = *entry;
|
||||
error = ESP_OK;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
|
||||
if (error == ESP_OK && expected != NULL &&
|
||||
(candidate.principal.user_id != expected->user_id ||
|
||||
candidate.principal.auth_generation != expected->auth_generation ||
|
||||
candidate.principal.role != expected->role ||
|
||||
candidate.principal.method != expected->method ||
|
||||
candidate.principal.username_length != expected->username_length ||
|
||||
memcmp(candidate.principal.username, expected->username,
|
||||
candidate.principal.username_length) != 0)) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
bool current = false;
|
||||
error = user_database_principal_is_current(&candidate.principal, ¤t);
|
||||
now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
entry = find_locked(id);
|
||||
if (!s_state.ready || entry == NULL) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
} else if (error != ESP_OK || !current) {
|
||||
retire_locked(entry, false);
|
||||
if (error == ESP_OK) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
if (error == ESP_OK && view != NULL) {
|
||||
export_view(&candidate, view);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
++s_state.lookup_rejections;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_session_store_prune(void)
|
||||
{
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
web_session_id_t id = s_state.entries[i].id;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (id != 0U) {
|
||||
(void)resolve(id, NULL, NULL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t web_session_store_issue(
|
||||
const user_principal_t *principal, const char *origin, size_t origin_length,
|
||||
char token[WEB_SESSION_STORE_TOKEN_LENGTH + 1U], web_session_view_t *view)
|
||||
{
|
||||
if (token != NULL) {
|
||||
secure_wipe(token, WEB_SESSION_STORE_TOKEN_LENGTH + 1U);
|
||||
}
|
||||
if (view != NULL) {
|
||||
secure_wipe(view, sizeof(*view));
|
||||
}
|
||||
if (principal == NULL || token == NULL || view == NULL ||
|
||||
principal->user_id == 0U || principal->auth_generation == 0U ||
|
||||
principal->method != USER_AUTH_METHOD_PASSWORD ||
|
||||
(principal->role != USER_ROLE_USER && principal->role != USER_ROLE_ADMIN) ||
|
||||
principal->username_length == 0U ||
|
||||
principal->username_length > USER_DATABASE_USERNAME_CAPACITY ||
|
||||
principal->username[principal->username_length] != '\0') {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_state.ready;
|
||||
uint64_t epoch = s_state.epoch;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
web_session_store_prune();
|
||||
|
||||
session_entry_t candidate = {0};
|
||||
uint8_t random[2U * WEB_SESSION_STORE_SECRET_BYTES] = {0};
|
||||
esp_err_t error = origin_digest(origin, origin_length, candidate.origin_digest);
|
||||
if (error == ESP_OK) {
|
||||
error = secure_random_fill(random, sizeof(random));
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
encode_hex(random, token);
|
||||
memcpy(candidate.csrf, random + WEB_SESSION_STORE_SECRET_BYTES,
|
||||
sizeof(candidate.csrf));
|
||||
error = digest(token, WEB_SESSION_STORE_TOKEN_LENGTH, candidate.token_digest);
|
||||
}
|
||||
bool current = false;
|
||||
if (error == ESP_OK) {
|
||||
candidate.principal = *principal;
|
||||
error = user_database_principal_is_current(&candidate.principal, ¤t);
|
||||
if (error == ESP_OK && !current) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
session_entry_t *available = NULL;
|
||||
bool duplicate = false;
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
session_entry_t *entry = &s_state.entries[i];
|
||||
if (entry->id == 0U) {
|
||||
if (available == NULL) {
|
||||
available = entry;
|
||||
}
|
||||
} else if (equal_bytes(entry->token_digest, candidate.token_digest,
|
||||
sizeof(entry->token_digest))) {
|
||||
duplicate = true;
|
||||
}
|
||||
}
|
||||
if (!s_state.ready || epoch != s_state.epoch ||
|
||||
s_state.next_id == UINT64_MAX || now < 0 ||
|
||||
now > INT64_MAX - WEB_SESSION_STORE_LIFETIME_US) {
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
} else if (duplicate) {
|
||||
error = ESP_FAIL;
|
||||
} else if (available == NULL) {
|
||||
++s_state.capacity_rejections;
|
||||
error = ESP_ERR_NO_MEM;
|
||||
} else {
|
||||
candidate.id = ++s_state.next_id;
|
||||
candidate.expires_at_us = now + WEB_SESSION_STORE_LIFETIME_US;
|
||||
*available = candidate;
|
||||
++s_state.issued;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
export_view(&candidate, view);
|
||||
} else {
|
||||
secure_wipe(token, WEB_SESSION_STORE_TOKEN_LENGTH + 1U);
|
||||
}
|
||||
secure_wipe(random, sizeof(random));
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_session_store_lookup(
|
||||
const char *token, size_t token_length, const char *origin,
|
||||
size_t origin_length, web_session_view_t *view)
|
||||
{
|
||||
if (view == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
secure_wipe(view, sizeof(*view));
|
||||
if (token == NULL || token_length != WEB_SESSION_STORE_TOKEN_LENGTH) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
for (size_t i = 0U; i < token_length; ++i) {
|
||||
if (!((token[i] >= '0' && token[i] <= '9') ||
|
||||
(token[i] >= 'a' && token[i] <= 'f'))) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
}
|
||||
uint8_t token_hash[WEB_SESSION_STORE_SECRET_BYTES] = {0};
|
||||
uint8_t origin_hash[WEB_SESSION_STORE_SECRET_BYTES] = {0};
|
||||
esp_err_t error = origin_digest(origin, origin_length, origin_hash);
|
||||
if (error == ESP_OK) {
|
||||
error = digest(token, token_length, token_hash);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
web_session_id_t id = 0U;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
const session_entry_t *entry = &s_state.entries[i];
|
||||
bool matches = equal_bytes(entry->token_digest, token_hash, sizeof(token_hash));
|
||||
matches &= equal_bytes(entry->origin_digest, origin_hash, sizeof(origin_hash));
|
||||
if (entry->id != 0U && matches) {
|
||||
id = entry->id;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
error = resolve(id, view, NULL);
|
||||
}
|
||||
secure_wipe(token_hash, sizeof(token_hash));
|
||||
secure_wipe(origin_hash, sizeof(origin_hash));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_session_store_is_current(web_session_id_t id, bool *current)
|
||||
{
|
||||
if (current == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
*current = false;
|
||||
esp_err_t error = resolve(id, NULL, NULL);
|
||||
if (error == ESP_OK) {
|
||||
*current = true;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_session_store_check_principal(web_session_id_t id,
|
||||
const user_principal_t *principal,
|
||||
bool *current)
|
||||
{
|
||||
if (current == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
*current = false;
|
||||
if (principal == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
esp_err_t error = resolve(id, NULL, principal);
|
||||
*current = error == ESP_OK;
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_session_store_invalidate_username(const uint8_t *username, size_t length)
|
||||
{
|
||||
if ((username == NULL && length != 0U) ||
|
||||
(username != NULL && !user_database_username_valid(username, length))) {
|
||||
return;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
session_entry_t *entry = &s_state.entries[i];
|
||||
if (username == NULL ||
|
||||
(entry->principal.username_length == length &&
|
||||
memcmp(entry->principal.username, username, length) == 0)) {
|
||||
retire_locked(entry, false);
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_session_store_invalidate(web_session_id_t id)
|
||||
{
|
||||
if (id == 0U) {
|
||||
return;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
session_entry_t *entry = find_locked(id);
|
||||
if (entry != NULL) {
|
||||
retire_locked(entry, false);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_session_store_invalidate_user(uint32_t user_id)
|
||||
{
|
||||
if (user_id == 0U) {
|
||||
return;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
if (s_state.entries[i].principal.user_id == user_id) {
|
||||
retire_locked(&s_state.entries[i], false);
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
esp_err_t web_session_store_get_snapshot(web_session_store_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
snapshot->initialized = s_state.ready;
|
||||
for (size_t i = 0U; i < WEB_SESSION_STORE_CAPACITY; ++i) {
|
||||
snapshot->active += s_state.entries[i].id != 0U;
|
||||
}
|
||||
snapshot->issued = s_state.issued;
|
||||
snapshot->capacity_rejections = s_state.capacity_rejections;
|
||||
snapshot->expired = s_state.expired;
|
||||
snapshot->invalidated = s_state.invalidated;
|
||||
snapshot->lookup_rejections = s_state.lookup_rejections;
|
||||
snapshot->init_failures = s_state.init_failures;
|
||||
snapshot->storage_bytes = sizeof(s_state) + sizeof(s_lock);
|
||||
snapshot->slot_bytes = sizeof(session_entry_t);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Internal session primitives; no HTTP authorization is enabled by this module. */
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
#include "user_database.h"
|
||||
|
||||
#define WEB_SESSION_STORE_CAPACITY 4U
|
||||
#define WEB_SESSION_STORE_SECRET_BYTES 32U
|
||||
#define WEB_SESSION_STORE_TOKEN_LENGTH 64U
|
||||
#define WEB_SESSION_STORE_ORIGIN_MAX_LENGTH 128U
|
||||
#define WEB_SESSION_STORE_LIFETIME_US 3600000000LL
|
||||
|
||||
typedef uint64_t web_session_id_t;
|
||||
|
||||
/* Sensitive request-local result, NOT a routine snapshot. Wipe after use. */
|
||||
typedef struct {
|
||||
web_session_id_t id;
|
||||
int64_t expires_at_us;
|
||||
user_principal_t principal;
|
||||
char csrf[WEB_SESSION_STORE_TOKEN_LENGTH + 1U];
|
||||
} web_session_view_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
uint32_t active;
|
||||
uint32_t issued;
|
||||
uint32_t capacity_rejections;
|
||||
uint32_t expired;
|
||||
uint32_t invalidated;
|
||||
uint32_t lookup_rejections;
|
||||
uint32_t init_failures;
|
||||
size_t storage_bytes;
|
||||
size_t slot_bytes;
|
||||
} web_session_store_snapshot_t;
|
||||
|
||||
/* Idempotent; probes the already-seeded shared RNG, never seeds it here.
|
||||
* Stop cancels in-flight initialization/issuance and wipes all records. IDs and
|
||||
* invalidation epochs never reset within a boot, even across stop/init. */
|
||||
esp_err_t web_session_store_init(void);
|
||||
void web_session_store_stop(void);
|
||||
|
||||
/* Trusted callers only. principal must be a current password-authenticated
|
||||
* principal. origin is the canonical, already HTTP-policy-validated HTTPS
|
||||
* origin, not an unchecked Host header; this module only binds its digest.
|
||||
* No live eviction. ESP_ERR_NO_MEM means fixed session capacity exhausted.
|
||||
* Raw token is returned only by issue; both outputs must be wiped by caller.
|
||||
* Output buffers must not alias inputs or each other. */
|
||||
esp_err_t web_session_store_issue(
|
||||
const user_principal_t *principal, const char *origin, size_t origin_length,
|
||||
char token[WEB_SESSION_STORE_TOKEN_LENGTH + 1U], web_session_view_t *view);
|
||||
esp_err_t web_session_store_lookup(
|
||||
const char *token, size_t token_length, const char *origin,
|
||||
size_t origin_length, web_session_view_t *view);
|
||||
|
||||
/* Trusted transport identity check, not a replacement for HTTP cookie/origin
|
||||
* authorization. Every successful lookup/check revalidates the principal.
|
||||
* No API result is a lease: recheck at later sensitive boundaries. */
|
||||
esp_err_t web_session_store_is_current(web_session_id_t id, bool *current);
|
||||
/* Also verifies that the transport's copied principal belongs to this ID. */
|
||||
esp_err_t web_session_store_check_principal(web_session_id_t id,
|
||||
const user_principal_t *principal,
|
||||
bool *current);
|
||||
void web_session_store_invalidate(web_session_id_t id);
|
||||
void web_session_store_invalidate_user(uint32_t user_id);
|
||||
/* Command notifications use names, including after account deletion. NULL/0
|
||||
* invalidates all records without disabling the store. */
|
||||
void web_session_store_invalidate_username(const uint8_t *username, size_t length);
|
||||
void web_session_store_prune(void);
|
||||
|
||||
/* Counts only: never token/digest/CSRF/principal material. Expired records are
|
||||
* reclaimed here; stale principals are reclaimed by prune or lookup/check. */
|
||||
esp_err_t web_session_store_get_snapshot(web_session_store_snapshot_t *snapshot);
|
||||
+1216
-1187
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -1,5 +1,5 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Offline browser UI response helpers for authenticated HTTPS routes. */
|
||||
/* Offline browser UI response helpers for public and session-authenticated HTTPS routes. */
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -12,6 +12,8 @@ extern "C" {
|
||||
|
||||
typedef enum {
|
||||
WEB_UI_RESOURCE_INDEX = 0,
|
||||
WEB_UI_RESOURCE_LOGIN,
|
||||
WEB_UI_RESOURCE_LOGIN_ERROR,
|
||||
WEB_UI_RESOURCE_XTERM_JS,
|
||||
WEB_UI_RESOURCE_XTERM_CSS,
|
||||
WEB_UI_RESOURCE_ADDON_FIT_JS,
|
||||
@@ -20,8 +22,9 @@ typedef enum {
|
||||
} web_ui_resource_t;
|
||||
|
||||
/*
|
||||
* Send one UI resource after the caller has authenticated the request.
|
||||
* This module deliberately performs no authentication or URI dispatch.
|
||||
* Send one UI resource after the caller has applied the route's public or
|
||||
* session-authenticated access policy. This module deliberately performs no
|
||||
* authentication, authorization, or URI dispatch.
|
||||
*/
|
||||
esp_err_t web_ui_send_response(httpd_req_t *request,
|
||||
web_ui_resource_t resource);
|
||||
|
||||
@@ -352,17 +352,4 @@ esp_err_t wifi_config_save(const wifi_app_config_t *config)
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t wifi_config_reset_storage(const wifi_app_config_t *defaults)
|
||||
{
|
||||
if (defaults != NULL) {
|
||||
return wifi_config_save(defaults);
|
||||
}
|
||||
|
||||
wifi_app_config_t generated_defaults;
|
||||
esp_err_t err = wifi_config_defaults(&generated_defaults);
|
||||
if (err == ESP_OK) {
|
||||
err = wifi_config_save(&generated_defaults);
|
||||
}
|
||||
wifi_config_secure_wipe(&generated_defaults, sizeof(generated_defaults));
|
||||
return err;
|
||||
}
|
||||
|
||||
@@ -104,8 +104,6 @@ esp_err_t wifi_config_load(wifi_app_config_t *config,
|
||||
wifi_config_load_source_t *source);
|
||||
esp_err_t wifi_config_save(const wifi_app_config_t *config);
|
||||
|
||||
/* Pass NULL to generate fresh defaults, or supply validated defaults to save. */
|
||||
esp_err_t wifi_config_reset_storage(const wifi_app_config_t *defaults);
|
||||
|
||||
/* Compatibility wrapper around the shared secure_wipe() implementation. */
|
||||
void wifi_config_secure_wipe(void *data, size_t size);
|
||||
|
||||
+84
-40
@@ -120,13 +120,16 @@ static void print_ipv4(uint32_t address)
|
||||
|
||||
static int show_status(void)
|
||||
{
|
||||
wifi_manager_snapshot_t snapshot;
|
||||
wifi_app_config_t config;
|
||||
wifi_manager_snapshot_t snapshot = {0};
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t config_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_snapshot(&snapshot);
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_get_working_config(&config);
|
||||
error = wifi_manager_get_working_config_versioned(
|
||||
&config, &config_generation);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Wi-Fi manager unavailable: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -136,7 +139,7 @@ static int show_status(void)
|
||||
config.enabled_at_boot ? "yes" : "no",
|
||||
snapshot.started ? "yes" : "no",
|
||||
wifi_manager_state_to_string(snapshot.state),
|
||||
snapshot.config_generation);
|
||||
config_generation);
|
||||
|
||||
if (snapshot.active_profile >= 0) {
|
||||
printf("STA: profile=%d SSID=", snapshot.active_profile);
|
||||
@@ -177,7 +180,7 @@ static int show_status(void)
|
||||
|
||||
static int show_profiles(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
wifi_app_config_t config = {0};
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read Wi-Fi profiles: %s\n", esp_err_to_name(error));
|
||||
@@ -231,9 +234,12 @@ static int show_counters(void)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static esp_err_t apply_candidate(wifi_app_config_t *candidate)
|
||||
static esp_err_t apply_candidate(wifi_app_config_t *candidate,
|
||||
uint32_t expected_generation,
|
||||
uint32_t *resulting_generation)
|
||||
{
|
||||
esp_err_t error = wifi_manager_apply_working_config(candidate);
|
||||
esp_err_t error = wifi_manager_compare_exchange_working_config(
|
||||
candidate, expected_generation, resulting_generation);
|
||||
wifi_config_secure_wipe(candidate, sizeof(*candidate));
|
||||
return error;
|
||||
}
|
||||
@@ -270,9 +276,12 @@ static int set_profile(char **argv)
|
||||
return 1;
|
||||
}
|
||||
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -283,7 +292,7 @@ static int set_profile(char **argv)
|
||||
profile->ssid_len = (uint8_t)ssid_len;
|
||||
profile->priority = (uint8_t)priority;
|
||||
profile->security = security;
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not apply profile: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
@@ -302,9 +311,12 @@ static int set_profile_secret(const char *slot_text)
|
||||
return 1;
|
||||
}
|
||||
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -317,7 +329,7 @@ static int set_profile_secret(const char *slot_text)
|
||||
error = read_secret_no_echo(config.profiles[slot].psk,
|
||||
&config.profiles[slot].psk_len);
|
||||
if (error == ESP_OK) {
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
} else {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
}
|
||||
@@ -341,9 +353,12 @@ static int change_profile_state(const char *operation, const char *slot_text)
|
||||
return 1;
|
||||
}
|
||||
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -355,7 +370,7 @@ static int change_profile_state(const char *operation, const char *slot_text)
|
||||
profile->enabled = strcmp(operation, "enable") == 0 ? 1U : 0U;
|
||||
}
|
||||
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not %s profile %u: %s\n", operation,
|
||||
(unsigned int)slot, esp_err_to_name(error));
|
||||
@@ -368,9 +383,12 @@ static int change_profile_state(const char *operation, const char *slot_text)
|
||||
|
||||
static int set_ap_parameter(const char *parameter, const char *value)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -403,7 +421,7 @@ static int set_ap_parameter(const char *parameter, const char *value)
|
||||
config.ap_channel = (uint8_t)channel;
|
||||
}
|
||||
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not apply AP configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
@@ -414,16 +432,19 @@ static int set_ap_parameter(const char *parameter, const char *value)
|
||||
|
||||
static int set_ap_secret(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
error = read_secret_no_echo(config.ap_psk, &config.ap_psk_len);
|
||||
if (error == ESP_OK) {
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
} else {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
}
|
||||
@@ -439,7 +460,7 @@ static int set_ap_secret(void)
|
||||
|
||||
static int show_ap_secret(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
wifi_app_config_t config = {0};
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read AP secret: %s\n", esp_err_to_name(error));
|
||||
@@ -456,12 +477,15 @@ static int show_ap_secret(void)
|
||||
|
||||
static int save_config(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_config_save(&config);
|
||||
}
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_save_working_config_if_generation(
|
||||
expected_generation);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not save Wi-Fi configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
@@ -472,11 +496,19 @@ static int save_config(void)
|
||||
|
||||
static int load_config(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
wifi_config_load_source_t source;
|
||||
esp_err_t error = wifi_config_load(&config, &source);
|
||||
wifi_app_config_t current = {0};
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
wifi_config_load_source_t source = WIFI_CONFIG_LOAD_GENERATED_MISSING;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
¤t, &expected_generation);
|
||||
wifi_config_secure_wipe(¤t, sizeof(current));
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_apply_working_config(&config);
|
||||
error = wifi_config_load(&config, &source);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_compare_exchange_working_config(
|
||||
&config, expected_generation, NULL);
|
||||
}
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
if (error != ESP_OK) {
|
||||
@@ -494,20 +526,32 @@ static int load_config(void)
|
||||
|
||||
static int apply_defaults(bool persist)
|
||||
{
|
||||
wifi_app_config_t previous;
|
||||
wifi_app_config_t defaults;
|
||||
esp_err_t error = wifi_manager_get_working_config(&previous);
|
||||
wifi_app_config_t previous = {0};
|
||||
wifi_app_config_t defaults = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
uint32_t defaults_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&previous, &expected_generation);
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_config_defaults(&defaults);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_apply_working_config(&defaults);
|
||||
error = wifi_manager_compare_exchange_working_config(
|
||||
&defaults, expected_generation, &defaults_generation);
|
||||
}
|
||||
if (error == ESP_OK && persist) {
|
||||
error = wifi_config_reset_storage(&defaults);
|
||||
if (error != ESP_OK) {
|
||||
/* Restore RAM behavior if persistence failed. */
|
||||
(void)wifi_manager_apply_working_config(&previous);
|
||||
error = wifi_manager_save_working_config_if_generation(
|
||||
defaults_generation);
|
||||
if (error != ESP_OK && error != WIFI_MANAGER_ERR_CONFIG_CONFLICT) {
|
||||
/* Restore RAM behavior only if no newer writer has won. */
|
||||
esp_err_t rollback_error =
|
||||
wifi_manager_compare_exchange_working_config(
|
||||
&previous, defaults_generation, NULL);
|
||||
if (rollback_error != ESP_OK &&
|
||||
rollback_error != WIFI_MANAGER_ERR_CONFIG_CONFLICT) {
|
||||
printf("Warning: could not restore the prior RAM configuration: %s\n",
|
||||
esp_err_to_name(rollback_error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+127
-123
@@ -18,7 +18,6 @@
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "mdns_service.h"
|
||||
#include "nvs.h"
|
||||
|
||||
#define WIFI_MANAGER_QUEUE_LENGTH 16U
|
||||
#define WIFI_MANAGER_TASK_STACK_SIZE 6144U
|
||||
@@ -95,6 +94,8 @@ typedef struct {
|
||||
} manager_runtime_t;
|
||||
|
||||
static SemaphoreHandle_t s_mutex;
|
||||
/* Serializes every mutation of s_shared.config, including persistence. */
|
||||
static SemaphoreHandle_t s_config_writer_mutex;
|
||||
static QueueHandle_t s_queue;
|
||||
static TaskHandle_t s_task;
|
||||
static esp_netif_t *s_sta_netif;
|
||||
@@ -131,6 +132,16 @@ static void unlock_shared(void)
|
||||
(void)xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
static void lock_config_writer(void)
|
||||
{
|
||||
(void)xSemaphoreTake(s_config_writer_mutex, portMAX_DELAY);
|
||||
}
|
||||
|
||||
static void unlock_config_writer(void)
|
||||
{
|
||||
(void)xSemaphoreGive(s_config_writer_mutex);
|
||||
}
|
||||
|
||||
static bool manager_is_started(void)
|
||||
{
|
||||
bool started;
|
||||
@@ -1278,10 +1289,15 @@ static void cleanup_failed_init(bool wifi_initialized, bool wifi_handler_registe
|
||||
vQueueDelete(s_queue);
|
||||
s_queue = NULL;
|
||||
}
|
||||
if (s_config_writer_mutex != NULL) {
|
||||
vSemaphoreDelete(s_config_writer_mutex);
|
||||
s_config_writer_mutex = NULL;
|
||||
}
|
||||
if (s_mutex != NULL) {
|
||||
vSemaphoreDelete(s_mutex);
|
||||
s_mutex = NULL;
|
||||
}
|
||||
wifi_config_secure_wipe(&s_shared, sizeof(s_shared));
|
||||
s_task = NULL;
|
||||
}
|
||||
|
||||
@@ -1291,7 +1307,7 @@ esp_err_t wifi_manager_init(const wifi_app_config_t *config)
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
if (s_mutex != NULL) {
|
||||
if (s_mutex != NULL || s_config_writer_mutex != NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
@@ -1299,6 +1315,11 @@ esp_err_t wifi_manager_init(const wifi_app_config_t *config)
|
||||
if (s_mutex == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
s_config_writer_mutex = xSemaphoreCreateMutex();
|
||||
if (s_config_writer_mutex == NULL) {
|
||||
cleanup_failed_init(false, false, false, false);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
s_queue = xQueueCreate(WIFI_MANAGER_QUEUE_LENGTH, sizeof(manager_message_t));
|
||||
if (s_queue == NULL) {
|
||||
cleanup_failed_init(false, false, false, false);
|
||||
@@ -1402,185 +1423,168 @@ esp_err_t wifi_manager_init(const wifi_app_config_t *config)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config)
|
||||
esp_err_t wifi_manager_get_working_config_versioned(wifi_app_config_t *config,
|
||||
uint32_t *generation)
|
||||
{
|
||||
if (config == NULL) {
|
||||
if (config != NULL) {
|
||||
wifi_config_secure_wipe(config, sizeof(*config));
|
||||
}
|
||||
if (generation != NULL) {
|
||||
*generation = 0U;
|
||||
}
|
||||
if (config == NULL || generation == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
copy_working_config(config);
|
||||
lock_shared();
|
||||
*config = s_shared.config;
|
||||
*generation = s_shared.snapshot.config_generation;
|
||||
unlock_shared();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Caller holds s_mutex; publication and owner admission are one transaction. */
|
||||
static esp_err_t apply_config_locked(const wifi_app_config_t *config)
|
||||
esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config)
|
||||
{
|
||||
esp_err_t error = wifi_config_validate(config);
|
||||
if (error != ESP_OK) return error;
|
||||
if (s_shared.snapshot.config_generation == UINT32_MAX) return ESP_ERR_INVALID_STATE;
|
||||
uint32_t generation = 0U;
|
||||
return wifi_manager_get_working_config_versioned(config, &generation);
|
||||
}
|
||||
|
||||
/* s_config_writer_mutex must be held by the caller. */
|
||||
static esp_err_t apply_working_config_serialized(
|
||||
const wifi_app_config_t *config, bool compare_generation,
|
||||
uint32_t expected_generation, uint32_t *resulting_generation)
|
||||
{
|
||||
lock_shared();
|
||||
if (compare_generation &&
|
||||
s_shared.snapshot.config_generation != expected_generation) {
|
||||
unlock_shared();
|
||||
return WIFI_MANAGER_ERR_CONFIG_CONFLICT;
|
||||
}
|
||||
if (s_shared.snapshot.config_generation == UINT32_MAX) {
|
||||
unlock_shared();
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
bool restart_radio = config_requires_radio_restart(&s_shared.config, config);
|
||||
if (restart_radio) {
|
||||
manager_message_t message = {.type = MESSAGE_COMMAND_APPLY};
|
||||
if (!enqueue_message(&message)) return ESP_ERR_TIMEOUT;
|
||||
if (!enqueue_message(&message)) {
|
||||
unlock_shared();
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
}
|
||||
|
||||
s_shared.config = *config;
|
||||
++s_shared.snapshot.config_generation;
|
||||
s_shared.snapshot.ap_policy = config->ap_policy;
|
||||
++s_shared.snapshot.counters.applies;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
|
||||
{
|
||||
if (!config) return ESP_ERR_INVALID_ARG;
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
lock_shared();
|
||||
esp_err_t error = apply_config_locked(config);
|
||||
unlock_shared();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *settings)
|
||||
{
|
||||
if (!settings) return ESP_ERR_INVALID_ARG;
|
||||
memset(settings, 0, sizeof(*settings));
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
settings->runtime = s_shared.snapshot;
|
||||
portENTER_CRITICAL(&s_drop_mux);
|
||||
settings->runtime.counters.queue_drops = s_queue_drops;
|
||||
portEXIT_CRITICAL(&s_drop_mux);
|
||||
settings->enabled_at_boot = s_shared.config.enabled_at_boot;
|
||||
settings->ap_policy = s_shared.config.ap_policy;
|
||||
settings->ap_channel = s_shared.config.ap_channel;
|
||||
settings->ap_ssid_len = s_shared.config.ap_ssid_len;
|
||||
memcpy(settings->ap_ssid, s_shared.config.ap_ssid, sizeof(settings->ap_ssid));
|
||||
settings->ap_password_configured = s_shared.config.ap_psk_len != 0;
|
||||
for (unsigned i = 0; i < WIFI_CONFIG_STA_PROFILE_COUNT; ++i) {
|
||||
const wifi_config_sta_profile_t *source = &s_shared.config.profiles[i];
|
||||
wifi_manager_profile_settings_t *target = &settings->profiles[i];
|
||||
target->enabled = source->enabled;
|
||||
target->priority = source->priority;
|
||||
target->security = source->security;
|
||||
target->ssid_len = source->ssid_len;
|
||||
memcpy(target->ssid, source->ssid, sizeof(target->ssid));
|
||||
target->password_configured = source->psk_len != 0;
|
||||
if (resulting_generation != NULL) {
|
||||
*resulting_generation = s_shared.snapshot.config_generation;
|
||||
}
|
||||
unlock_shared();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static bool generation_matches(uint32_t generation)
|
||||
{
|
||||
return generation && generation == s_shared.snapshot.config_generation;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_patch_current(uint32_t generation, const wifi_manager_patch_t *patch)
|
||||
esp_err_t wifi_manager_compare_exchange_working_config(
|
||||
const wifi_app_config_t *config, uint32_t expected_generation,
|
||||
uint32_t *resulting_generation)
|
||||
{
|
||||
if (!patch || patch->profile < -1 || patch->profile >= (int)WIFI_CONFIG_STA_PROFILE_COUNT ||
|
||||
!patch->fields || patch->ssid_len > WIFI_CONFIG_SSID_MAX_LEN ||
|
||||
patch->password_len > WIFI_CONFIG_PSK_MAX_LEN) return ESP_ERR_INVALID_ARG;
|
||||
uint32_t allowed = WIFI_PATCH_SSID | WIFI_PATCH_PASSWORD |
|
||||
(patch->profile < 0 ? WIFI_PATCH_BOOT | WIFI_PATCH_POLICY | WIFI_PATCH_CHANNEL :
|
||||
WIFI_PATCH_ENABLED | WIFI_PATCH_PRIORITY | WIFI_PATCH_SECURITY);
|
||||
if (patch->fields & ~allowed) return ESP_ERR_INVALID_ARG;
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
lock_shared();
|
||||
if (!generation_matches(generation)) { unlock_shared(); return ESP_ERR_NOT_FOUND; }
|
||||
wifi_app_config_t candidate = s_shared.config;
|
||||
uint8_t *ssid, *ssid_len, *password, *password_len;
|
||||
if (patch->profile < 0) {
|
||||
if (patch->fields & WIFI_PATCH_BOOT) candidate.enabled_at_boot = patch->enabled_at_boot;
|
||||
if (patch->fields & WIFI_PATCH_POLICY) candidate.ap_policy = patch->ap_policy;
|
||||
if (patch->fields & WIFI_PATCH_CHANNEL) candidate.ap_channel = patch->ap_channel;
|
||||
ssid = candidate.ap_ssid; ssid_len = &candidate.ap_ssid_len;
|
||||
password = candidate.ap_psk; password_len = &candidate.ap_psk_len;
|
||||
} else {
|
||||
wifi_config_sta_profile_t *profile = &candidate.profiles[(unsigned)patch->profile];
|
||||
if (patch->fields & WIFI_PATCH_ENABLED) profile->enabled = patch->enabled;
|
||||
if (patch->fields & WIFI_PATCH_PRIORITY) profile->priority = patch->priority;
|
||||
if (patch->fields & WIFI_PATCH_SECURITY) profile->security = patch->security;
|
||||
ssid = profile->ssid; ssid_len = &profile->ssid_len;
|
||||
password = profile->psk; password_len = &profile->psk_len;
|
||||
if (resulting_generation != NULL) {
|
||||
*resulting_generation = 0U;
|
||||
}
|
||||
if (patch->fields & WIFI_PATCH_SSID) {
|
||||
memset(ssid, 0, WIFI_CONFIG_SSID_MAX_LEN);
|
||||
memcpy(ssid, patch->ssid, patch->ssid_len); *ssid_len = patch->ssid_len;
|
||||
if (expected_generation == 0U) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (patch->fields & WIFI_PATCH_PASSWORD) {
|
||||
wifi_config_secure_wipe(password, WIFI_CONFIG_PSK_MAX_LEN);
|
||||
memcpy(password, patch->password, patch->password_len); *password_len = patch->password_len;
|
||||
esp_err_t error = wifi_config_validate(config);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
esp_err_t error = apply_config_locked(&candidate);
|
||||
wifi_config_secure_wipe(&candidate, sizeof(candidate));
|
||||
unlock_shared();
|
||||
if (s_mutex == NULL || s_config_writer_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
lock_config_writer();
|
||||
error = apply_working_config_serialized(
|
||||
config, true, expected_generation, resulting_generation);
|
||||
unlock_config_writer();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_save_current(uint32_t generation)
|
||||
esp_err_t wifi_manager_save_working_config_if_generation(
|
||||
uint32_t expected_generation)
|
||||
{
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
lock_shared();
|
||||
/* Hold the config lock through persistence, not HTTPD. Local controls cannot
|
||||
* change the selected generation while its bytes are being committed. */
|
||||
esp_err_t error = generation_matches(generation) ? wifi_config_save(&s_shared.config) : ESP_ERR_NOT_FOUND;
|
||||
unlock_shared();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_load_current(uint32_t generation)
|
||||
{
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
lock_shared();
|
||||
if (!generation_matches(generation)) { unlock_shared(); return ESP_ERR_NOT_FOUND; }
|
||||
/* wifi_config_load intentionally generates fallback credentials. Browser
|
||||
* load must instead read the same canonical blob without that fallback. */
|
||||
wifi_app_config_t candidate = {0};
|
||||
esp_err_t error = wifi_config_storage_init();
|
||||
nvs_handle_t handle;
|
||||
if (error == ESP_OK) {
|
||||
error = nvs_open(WIFI_CONFIG_NVS_NAMESPACE, NVS_READONLY, &handle);
|
||||
if (error == ESP_OK) {
|
||||
size_t size = sizeof(candidate);
|
||||
error = nvs_get_blob(handle, WIFI_CONFIG_NVS_BLOB_KEY, &candidate, &size);
|
||||
nvs_close(handle);
|
||||
if (error == ESP_OK && size != sizeof(candidate)) error = ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
if (expected_generation == 0U) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (error == ESP_OK) error = apply_config_locked(&candidate);
|
||||
wifi_config_secure_wipe(&candidate, sizeof(candidate));
|
||||
if (expected_generation == UINT32_MAX) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (s_mutex == NULL || s_config_writer_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
wifi_app_config_t config = {0};
|
||||
lock_config_writer();
|
||||
lock_shared();
|
||||
if (s_shared.snapshot.config_generation != expected_generation) {
|
||||
unlock_shared();
|
||||
unlock_config_writer();
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
return WIFI_MANAGER_ERR_CONFIG_CONFLICT;
|
||||
}
|
||||
config = s_shared.config;
|
||||
unlock_shared();
|
||||
|
||||
esp_err_t error = wifi_config_save(&config);
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
unlock_config_writer();
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
|
||||
int enabled_at_boot)
|
||||
{
|
||||
if (s_mutex == NULL) {
|
||||
if (s_mutex == NULL || s_config_writer_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
bool writes_config = enabled_at_boot >= 0;
|
||||
if (writes_config) {
|
||||
lock_config_writer();
|
||||
}
|
||||
|
||||
manager_message_t message = {.type = type};
|
||||
lock_shared();
|
||||
if (enabled_at_boot >= 0 && s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot &&
|
||||
s_shared.snapshot.config_generation == UINT32_MAX) {
|
||||
bool changes_config =
|
||||
writes_config &&
|
||||
s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot;
|
||||
if (changes_config && s_shared.snapshot.config_generation == UINT32_MAX) {
|
||||
unlock_shared();
|
||||
if (writes_config) {
|
||||
unlock_config_writer();
|
||||
}
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (!enqueue_message(&message)) {
|
||||
unlock_shared();
|
||||
if (writes_config) {
|
||||
unlock_config_writer();
|
||||
}
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
if (enabled_at_boot >= 0 &&
|
||||
s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot) {
|
||||
if (changes_config) {
|
||||
s_shared.config.enabled_at_boot = (uint8_t)enabled_at_boot;
|
||||
++s_shared.snapshot.config_generation;
|
||||
}
|
||||
unlock_shared();
|
||||
if (writes_config) {
|
||||
unlock_config_writer();
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
+32
-49
@@ -72,6 +72,9 @@ typedef struct {
|
||||
wifi_manager_counters_t counters;
|
||||
} wifi_manager_snapshot_t;
|
||||
|
||||
/* A stale expected working-config generation maps cleanly to HTTP 409. */
|
||||
#define WIFI_MANAGER_ERR_CONFIG_CONFLICT ESP_ERR_INVALID_VERSION
|
||||
|
||||
/*
|
||||
* Initializes ESP-NETIF, the default event loop, both default Wi-Fi netifs,
|
||||
* Wi-Fi itself, and the permanent policy task. The manager never aborts the
|
||||
@@ -79,61 +82,41 @@ typedef struct {
|
||||
*/
|
||||
esp_err_t wifi_manager_init(const wifi_app_config_t *config);
|
||||
|
||||
/* Returns a copy of the RAM working configuration, including credentials. */
|
||||
/*
|
||||
* Returns a copy of the RAM working configuration, including credentials.
|
||||
* The caller owns the returned copy and must securely wipe it after use.
|
||||
*/
|
||||
esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config);
|
||||
|
||||
/*
|
||||
* Replaces the RAM working configuration. Disabled-profile-only edits do not
|
||||
* interrupt a running radio; changes to effective station/AP policy are
|
||||
* applied asynchronously by restarting with the newest generation.
|
||||
* Atomically copies the credential-bearing working configuration and the exact
|
||||
* nonzero generation that identified it. Both outputs are cleared on failure;
|
||||
* the caller must securely wipe config after every successful call.
|
||||
*/
|
||||
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config);
|
||||
esp_err_t wifi_manager_get_working_config_versioned(wifi_app_config_t *config,
|
||||
uint32_t *generation);
|
||||
|
||||
/* Secret-free working projection, copied together with runtime under the mutex.
|
||||
* Zero wait: ESP_ERR_TIMEOUT means no snapshot was obtained. Password presence
|
||||
* is the only credential metadata, needed to stage/enable disabled profiles. */
|
||||
typedef struct {
|
||||
uint8_t enabled, priority;
|
||||
wifi_config_security_t security;
|
||||
uint8_t ssid_len, ssid[WIFI_CONFIG_SSID_MAX_LEN];
|
||||
bool password_configured;
|
||||
} wifi_manager_profile_settings_t;
|
||||
typedef struct {
|
||||
wifi_manager_snapshot_t runtime;
|
||||
uint8_t enabled_at_boot, ap_channel;
|
||||
wifi_config_ap_policy_t ap_policy;
|
||||
uint8_t ap_ssid_len, ap_ssid[WIFI_CONFIG_SSID_MAX_LEN];
|
||||
bool ap_password_configured;
|
||||
wifi_manager_profile_settings_t profiles[WIFI_CONFIG_STA_PROFILE_COUNT];
|
||||
} wifi_manager_settings_t;
|
||||
esp_err_t wifi_manager_get_settings(wifi_manager_settings_t *settings);
|
||||
|
||||
enum {
|
||||
WIFI_PATCH_BOOT = 1U << 0, WIFI_PATCH_POLICY = 1U << 1,
|
||||
WIFI_PATCH_CHANNEL = 1U << 2, WIFI_PATCH_ENABLED = 1U << 3,
|
||||
WIFI_PATCH_PRIORITY = 1U << 4, WIFI_PATCH_SECURITY = 1U << 5,
|
||||
WIFI_PATCH_SSID = 1U << 6, WIFI_PATCH_PASSWORD = 1U << 7,
|
||||
};
|
||||
/* profile=-1 selects AP/global fields; 0..3 selects a station profile.
|
||||
* Absent bits preserve CURRENT bytes, never a stale caller's secret copy.
|
||||
* PASSWORD with length zero clears only when canonical validation permits it.
|
||||
* Caller owns and must wipe this transient input after every exit path. */
|
||||
typedef struct {
|
||||
uint32_t fields;
|
||||
int8_t profile;
|
||||
uint8_t enabled_at_boot, ap_channel, enabled, priority;
|
||||
wifi_config_ap_policy_t ap_policy;
|
||||
wifi_config_security_t security;
|
||||
uint8_t ssid_len, ssid[WIFI_CONFIG_SSID_MAX_LEN];
|
||||
uint8_t password_len, password[WIFI_CONFIG_PSK_MAX_LEN];
|
||||
} wifi_manager_patch_t;
|
||||
/* Dispatcher-only conditional operations. A nonzero expected generation must
|
||||
* match under the mutation mutex; ESP_ERR_NOT_FOUND denotes stale selection.
|
||||
* No generation wrap/reuse. Queue failure leaves RAM untouched. */
|
||||
esp_err_t wifi_manager_patch_current(uint32_t generation, const wifi_manager_patch_t *patch);
|
||||
esp_err_t wifi_manager_save_current(uint32_t generation);
|
||||
/* Stored-only load: never generates or installs unknown default credentials. */
|
||||
esp_err_t wifi_manager_load_current(uint32_t generation);
|
||||
/*
|
||||
* Atomically replace the complete validated working configuration only when
|
||||
* expected_generation still identifies the current configuration. A stale
|
||||
* expectation returns WIFI_MANAGER_ERR_CONFIG_CONFLICT without queueing a
|
||||
* restart or changing state. resulting_generation is optional and is set to
|
||||
* zero on failure. Generation exhaustion returns ESP_ERR_INVALID_STATE.
|
||||
*/
|
||||
esp_err_t wifi_manager_compare_exchange_working_config(
|
||||
const wifi_app_config_t *config, uint32_t expected_generation,
|
||||
uint32_t *resulting_generation);
|
||||
|
||||
/*
|
||||
* Persist exactly the working configuration identified by expected_generation.
|
||||
* All config writers are excluded through the NVS operation. A stale
|
||||
* expectation returns WIFI_MANAGER_ERR_CONFIG_CONFLICT. Generation exhaustion
|
||||
* returns ESP_ERR_INVALID_STATE. The saved generation is not incremented because
|
||||
* the RAM working configuration is unchanged.
|
||||
*/
|
||||
esp_err_t wifi_manager_save_working_config_if_generation(
|
||||
uint32_t expected_generation);
|
||||
|
||||
/* Lifecycle requests are asynchronous and serialized by the manager task. */
|
||||
esp_err_t wifi_manager_start(void);
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
/* Included in accounts.py's canonical DB/console transaction harness.
|
||||
* Production SSH parsing, with OpenSSL-backed curve/SHA adapters, not mbedTLS. */
|
||||
static user_database_account_t key_target(void)
|
||||
{
|
||||
user_database_accounts_t accounts;
|
||||
assert(user_database_get_accounts(&accounts)==ESP_OK);
|
||||
return accounts.users[1];
|
||||
}
|
||||
static size_t ssh_string(uint8_t *out, const void *value, size_t length)
|
||||
{
|
||||
out[0]=out[1]=out[2]=0; out[3]=(uint8_t)length;
|
||||
memcpy(out+4,value,length); return length+4;
|
||||
}
|
||||
static void stale_keys(const user_database_account_t *target, const uint8_t *blob, size_t length)
|
||||
{
|
||||
stored_database_t before=s_database;
|
||||
unsigned saved=commits;
|
||||
uint8_t index=0;
|
||||
user_database_user_snapshot_t snapshot;
|
||||
assert(user_database_add_ssh_key_current(target,s_ed25519_type,11,blob,length,&index)==ESP_ERR_NOT_FOUND);
|
||||
assert(user_database_remove_ssh_key_current(target,0)==ESP_ERR_NOT_FOUND);
|
||||
assert(user_database_clear_ssh_keys_current(target)==ESP_ERR_NOT_FOUND);
|
||||
memset(&snapshot,0xff,sizeof(snapshot));
|
||||
assert(user_database_get_account_keys(target,&snapshot)==ESP_ERR_NOT_FOUND);
|
||||
assert(all_zero(&snapshot,sizeof(snapshot)) && commits==saved); unchanged(&before);
|
||||
}
|
||||
static void typed_key_tests(void)
|
||||
{
|
||||
reset();
|
||||
uint8_t ed[128]={0}, p256[128]={0}, point[65], public[32]={1};
|
||||
size_t en=ssh_string(ed,s_ed25519_type,11); en+=ssh_string(ed+en,public,32);
|
||||
EC_GROUP *group=EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1);
|
||||
assert(group && EC_POINT_point2oct(group,EC_GROUP_get0_generator(group),POINT_CONVERSION_UNCOMPRESSED,point,sizeof(point),NULL)==65);
|
||||
EC_GROUP_free(group);
|
||||
size_t pn=ssh_string(p256,s_ecdsa_type,19);
|
||||
pn+=ssh_string(p256+pn,s_ecdsa_curve,8); pn+=ssh_string(p256+pn,point,65);
|
||||
assert(user_database_key_valid(s_ed25519_type,11,ed,en));
|
||||
assert(user_database_key_valid(s_ecdsa_type,19,p256,pn));
|
||||
for (size_t n=0;n<en;++n) assert(!user_database_key_valid(s_ed25519_type,11,ed,n));
|
||||
for (size_t n=0;n<pn;++n) assert(!user_database_key_valid(s_ecdsa_type,19,p256,n));
|
||||
assert(!user_database_key_valid(s_ed25519_type,11,ed,en+1));
|
||||
assert(!user_database_key_valid(s_ecdsa_type,19,p256,pn+1));
|
||||
assert(!user_database_key_valid(s_ed25519_type,11,p256,pn));
|
||||
assert(!user_database_key_valid((const uint8_t *)"ssh-rsa",7,ed,en));
|
||||
uint8_t bad[129]; memcpy(bad,p256,pn); bad[pn-65]=2;
|
||||
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
|
||||
memset(bad+pn-64,0,64); bad[pn-65]=4;
|
||||
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
|
||||
memcpy(bad,p256,pn); bad[27]='x';
|
||||
assert(!user_database_key_valid(s_ecdsa_type,19,bad,pn));
|
||||
memset(bad,0xff,sizeof(bad));
|
||||
assert(!user_database_key_valid(s_ed25519_type,11,bad,sizeof(bad)));
|
||||
user_database_account_t target=key_target();
|
||||
user_database_user_snapshot_t snapshot;
|
||||
snapshot_busy=true; memset(&snapshot,0xff,sizeof(snapshot));
|
||||
assert(user_database_get_account_keys(&target,&snapshot)==ESP_ERR_TIMEOUT && last_wait==0);
|
||||
assert(all_zero(&snapshot,sizeof(snapshot))); snapshot_busy=false;
|
||||
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && last_wait==0 && !snapshot.public_key_count);
|
||||
uint8_t index=255;
|
||||
stored_database_t invalid_before=s_database;
|
||||
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,bad,sizeof(bad),&index)==ESP_ERR_INVALID_ARG);
|
||||
unchanged(&invalid_before);
|
||||
for (fail_stage=1;fail_stage<=3;++fail_stage) {
|
||||
stored_database_t before=s_database;
|
||||
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_FAIL);
|
||||
unchanged(&before);
|
||||
}
|
||||
fail_stage=0;
|
||||
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_OK && index==0);
|
||||
stale_keys(&target,ed,en); target=key_target();
|
||||
user_principal_t authenticated; bool authorized=false;
|
||||
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ed25519_type,11,ed,en,&authenticated,&authorized)==ESP_OK && authorized);
|
||||
assert(authenticated.method==USER_AUTH_METHOD_SSH_PUBLIC_KEY && authenticated.auth_generation==target.auth_generation);
|
||||
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && snapshot.public_key_count==1);
|
||||
uint8_t digest[32]; assert(SHA256(ed,en,digest));
|
||||
assert(snapshot.public_keys[0].active && !strcmp(snapshot.public_keys[0].key_type,"ssh-ed25519"));
|
||||
assert(!memcmp(snapshot.public_keys[0].sha256_fingerprint,digest,32));
|
||||
unsigned saved=commits;
|
||||
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==USER_DATABASE_ERR_DUPLICATE_SSH_KEY && commits==saved);
|
||||
assert(user_database_add_ssh_key_current(&target,s_ecdsa_type,19,p256,pn,&index)==ESP_OK && index==1);
|
||||
target=key_target();
|
||||
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && authorized);
|
||||
assert(user_database_authorize_ssh_public_key((const uint8_t *)"observer",8,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
|
||||
ed[en-1]=2;
|
||||
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_OK && index==2);
|
||||
target=key_target(); saved=commits;
|
||||
/* Reload the persisted v1 record with all three keys and verifiers intact. */
|
||||
stored_database_t stored=s_database;
|
||||
storage_test=true; unload_database();
|
||||
user_database_load_result_t loaded;
|
||||
assert(user_database_init(&loaded)==ESP_OK && loaded==USER_DATABASE_LOAD_STORED);
|
||||
assert(!memcmp(&stored,&s_database,sizeof(stored)) && commits==saved);
|
||||
storage_test=false;
|
||||
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==USER_DATABASE_ERR_DUPLICATE_SSH_KEY);
|
||||
ed[en-1]=3;
|
||||
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_ERR_NO_MEM && commits==saved);
|
||||
for (fail_stage=1;fail_stage<=3;++fail_stage) {
|
||||
stored_database_t before=s_database;
|
||||
assert(user_database_remove_ssh_key_current(&target,1)==ESP_FAIL); unchanged(&before);
|
||||
assert(user_database_clear_ssh_keys_current(&target)==ESP_FAIL); unchanged(&before);
|
||||
}
|
||||
fail_stage=0;
|
||||
assert(user_database_remove_ssh_key_current(&target,1)==ESP_OK);
|
||||
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
|
||||
stale_keys(&target,ed,en); target=key_target();
|
||||
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK && snapshot.public_key_count==2);
|
||||
assert(!snapshot.public_keys[1].active && snapshot.public_keys[2].index==2);
|
||||
/* Sparse v1 reload must preserve the entire record, including IDs,
|
||||
* generations, password verifiers, key blobs/types and fingerprints. */
|
||||
stored=s_database;
|
||||
user_database_user_snapshot_t sparse_snapshot=snapshot;
|
||||
unsigned sparse_writes=writes, sparse_commits=commits;
|
||||
assert(persisted_size==sizeof(stored) && !memcmp(persisted,&stored,sizeof(stored)));
|
||||
storage_test=true; unload_database();
|
||||
assert(user_database_init(&loaded)==ESP_OK && loaded==USER_DATABASE_LOAD_STORED);
|
||||
storage_test=false;
|
||||
assert(!memcmp(&stored,&s_database,sizeof(stored)));
|
||||
assert(persisted_size==sizeof(stored) && !memcmp(persisted,&stored,sizeof(stored)));
|
||||
assert(writes==sparse_writes && commits==sparse_commits);
|
||||
assert(user_database_get_account_keys(&target,&snapshot)==ESP_OK);
|
||||
assert(!memcmp(&sparse_snapshot,&snapshot,sizeof(snapshot)));
|
||||
for (size_t slot=0;slot<3;slot+=2) {
|
||||
const stored_key_t *key=&stored.users[1].keys[slot];
|
||||
assert(key->active && snapshot.public_keys[slot].active);
|
||||
assert(snapshot.public_keys[slot].index==slot);
|
||||
assert(user_database_key_valid(key->type,key->type_length,key->blob,key->blob_length));
|
||||
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,
|
||||
key->type,key->type_length,key->blob,key->blob_length,
|
||||
&authenticated,&authorized)==ESP_OK && authorized);
|
||||
assert(authenticated.user_id==target.user_id && authenticated.auth_generation==target.auth_generation);
|
||||
assert(authenticated.role==target.role && authenticated.method==USER_AUTH_METHOD_SSH_PUBLIC_KEY);
|
||||
bool current=false;
|
||||
assert(user_database_principal_is_current(&authenticated,¤t)==ESP_OK && current);
|
||||
}
|
||||
assert(!snapshot.public_keys[1].active);
|
||||
assert(user_database_authorize_ssh_public_key((const uint8_t *)"other",5,
|
||||
s_ecdsa_type,19,p256,pn,&authenticated,&authorized)==ESP_OK && !authorized);
|
||||
assert(writes==sparse_writes && commits==sparse_commits);
|
||||
assert(user_database_remove_ssh_key_current(&target,1)==ESP_ERR_NOT_FOUND);
|
||||
assert(user_database_remove_ssh_key_current(&target,3)==ESP_ERR_INVALID_ARG);
|
||||
assert(user_database_add_ssh_key_current(&target,s_ecdsa_type,19,p256,pn,&index)==ESP_OK && index==1);
|
||||
target=key_target(); assert(user_database_clear_ssh_keys_current(&target)==ESP_OK);
|
||||
stale_keys(&target,ed,en); target=key_target(); saved=commits;
|
||||
assert(user_database_clear_ssh_keys_current(&target)==ESP_OK && commits==saved);
|
||||
assert(user_database_delete_current(&target)==ESP_OK);
|
||||
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,(const uint8_t *)"test-password",13)==ESP_OK);
|
||||
stale_keys(&target,ed,en);
|
||||
target=key_target(); assert(user_database_set_role_current(&target,USER_ROLE_ADMIN)==ESP_OK); stale_keys(&target,ed,en);
|
||||
target=key_target(); assert(user_database_set_password_current(&target,(const uint8_t *)"test-password",13)==ESP_OK); stale_keys(&target,ed,en);
|
||||
target=key_target(); target.user_id=0; stale_keys(&target,ed,en);
|
||||
target=key_target(); target.auth_generation=0; stale_keys(&target,ed,en);
|
||||
target=key_target(); memset(target.username,'x',sizeof(target.username));
|
||||
assert(user_database_add_ssh_key_current(&target,s_ed25519_type,11,ed,en,&index)==ESP_ERR_INVALID_ARG);
|
||||
assert(user_database_remove_ssh_key_current(&target,0)==ESP_ERR_INVALID_ARG);
|
||||
assert(user_database_clear_ssh_keys_current(NULL)==ESP_ERR_INVALID_ARG);
|
||||
assert(user_database_get_account_keys(NULL,&snapshot)==ESP_ERR_INVALID_ARG && all_zero(&snapshot,sizeof(snapshot)));
|
||||
/* Ordinary CLI APIs retain the exact transaction path and generation changes. */
|
||||
target=key_target(); assert(user_database_add_ssh_key((const uint8_t *)"other",5,s_ed25519_type,11,ed,en,&index)==ESP_OK);
|
||||
stale_keys(&target,ed,en);
|
||||
assert(user_database_remove_ssh_key((const uint8_t *)"other",5,index)==ESP_OK);
|
||||
assert(user_database_clear_ssh_keys((const uint8_t *)"other",5)==ESP_OK);
|
||||
s_initialized=false; memset(&snapshot,0xff,sizeof(snapshot));
|
||||
assert(user_database_get_account_keys(&target,&snapshot)==ESP_ERR_INVALID_STATE && all_zero(&snapshot,sizeof(snapshot)));
|
||||
}
|
||||
@@ -1,374 +0,0 @@
|
||||
/* Included by accounts.py after extracted production functions. */
|
||||
static void reset(void)
|
||||
{
|
||||
memset(&s_database, 0, sizeof(s_database));
|
||||
memset(&candidate_storage, 0, sizeof(candidate_storage));
|
||||
s_candidate=&candidate_storage; s_mutex=(void *)1; s_initialized=true;
|
||||
s_database.version=USER_DATABASE_SCHEMA_VERSION;
|
||||
s_database.size=sizeof(s_database); s_database.generation=1;
|
||||
fail_stage=0; invalidate_during_derivation=false; derivation_invalidations=0;
|
||||
assert(initialize_user(&s_database.users[0], (const uint8_t *)"admin", 5,
|
||||
USER_ROLE_ADMIN, (const uint8_t *)"test-password", 13)==ESP_OK);
|
||||
assert(initialize_user(&s_database.users[1], (const uint8_t *)"other", 5,
|
||||
USER_ROLE_USER, (const uint8_t *)"test-password", 13)==ESP_OK);
|
||||
assert(initialize_user(&s_database.users[2], (const uint8_t *)"observer", 8,
|
||||
USER_ROLE_USER, (const uint8_t *)"test-password", 13)==ESP_OK);
|
||||
recount(&s_database);
|
||||
assert(validate_database(&s_database)==ESP_OK);
|
||||
actor=(user_principal_t){.role=USER_ROLE_ADMIN, .username_length=5,
|
||||
.username="admin", .user_id=s_database.users[0].user_id,
|
||||
.auth_generation=s_database.users[0].auth_generation};
|
||||
writes=commits=prompts=checks=web_revokes=ssh_revokes=0;
|
||||
revoke_prompt=revoke_check=0; owner_current=true; remote=web=true;
|
||||
mismatch=cancel_prompt=stale_prompt=false; notify_error=ESP_OK;
|
||||
memset(revoked_name,0,sizeof(revoked_name));
|
||||
}
|
||||
static int run(const char *line)
|
||||
{
|
||||
char copy[257]; char *argv[10]={0};
|
||||
assert(strlen(line)<sizeof(copy)); strcpy(copy,line);
|
||||
size_t argc=esp_console_split_argv(copy,argv,10);
|
||||
/* Direct canonical handler, deliberately bypassing dispatcher policy. */
|
||||
return command_user((int)argc,argv);
|
||||
}
|
||||
static void unchanged(const stored_database_t *before)
|
||||
{
|
||||
assert(!memcmp(before,&s_database,sizeof(*before)));
|
||||
assert(!web_revokes && !ssh_revokes);
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)));
|
||||
assert(!locks);
|
||||
}
|
||||
static void typed_account_tests(void)
|
||||
{
|
||||
reset(); user_database_accounts_t list;
|
||||
assert(user_database_get_accounts(&list)==ESP_OK && last_wait==0 && list.count==3);
|
||||
assert(!strcmp(list.users[1].username,"other"));
|
||||
user_database_account_t other=list.users[1], admin=list.users[0];
|
||||
snapshot_busy=true; memset(&list,0xff,sizeof(list));
|
||||
assert(user_database_get_accounts(&list)==ESP_ERR_TIMEOUT && all_zero(&list,sizeof(list)));
|
||||
snapshot_busy=false;
|
||||
assert(user_database_delete_current(&admin)==ESP_ERR_INVALID_STATE);
|
||||
assert(user_database_set_role_current(&admin,USER_ROLE_USER)==ESP_ERR_INVALID_STATE);
|
||||
assert(!writes && !commits);
|
||||
assert(user_database_set_role_current(&other,USER_ROLE_ADMIN)==ESP_OK);
|
||||
unsigned saved=commits;
|
||||
assert(user_database_delete_current(&other)==ESP_ERR_NOT_FOUND && commits==saved);
|
||||
assert(user_database_set_role_current(&other,USER_ROLE_USER)==ESP_ERR_NOT_FOUND);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
|
||||
for (fail_stage=1;fail_stage<=3;++fail_stage) {
|
||||
stored_database_t before=s_database;
|
||||
assert(user_database_delete_current(&other)==ESP_FAIL); unchanged(&before);
|
||||
assert(user_database_set_role_current(&other,USER_ROLE_USER)==ESP_FAIL); unchanged(&before);
|
||||
}
|
||||
fail_stage=0; assert(user_database_delete_current(&other)==ESP_OK);
|
||||
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,(const uint8_t *)"test-password",13)==ESP_OK);
|
||||
assert(user_database_delete_current(&other)==ESP_ERR_NOT_FOUND);
|
||||
assert(user_database_set_role_current(&other,USER_ROLE_ADMIN)==ESP_ERR_NOT_FOUND);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
|
||||
assert(user_database_delete_current(&other)==ESP_OK);
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)) && !locks);
|
||||
s_initialized=false; memset(&list,0xff,sizeof(list));
|
||||
assert(user_database_get_accounts(&list)==ESP_ERR_INVALID_STATE && all_zero(&list,sizeof(list)));
|
||||
assert(user_database_delete_current(NULL)==ESP_ERR_INVALID_ARG);
|
||||
}
|
||||
static void typed_password_tests(void)
|
||||
{
|
||||
reset(); user_database_accounts_t list;
|
||||
assert(user_database_get_accounts(&list)==ESP_OK);
|
||||
user_database_account_t other=list.users[1], admin=list.users[0];
|
||||
const uint8_t password[]="quote\"slash\\ space";
|
||||
for (unsigned stage=1;stage<=5;++stage) {
|
||||
fail_stage=stage; stored_database_t before=s_database;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_FAIL);
|
||||
unchanged(&before);
|
||||
}
|
||||
fail_stage=0; writes=commits=0;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_OK);
|
||||
assert(writes==1 && commits==1 && s_database.users[1].auth_generation==other.auth_generation+1);
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)));
|
||||
stored_database_t before=s_database; unsigned rng=random_calls;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
|
||||
unchanged(&before); assert(writes==1 && commits==1 && random_calls==rng);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); other=list.users[1];
|
||||
assert(user_database_delete_current(&other)==ESP_OK);
|
||||
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_USER,password,sizeof(password)-1)==ESP_OK);
|
||||
before=s_database; rng=random_calls;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
|
||||
unchanged(&before); assert(random_calls==rng);
|
||||
assert(user_database_set_password_current(NULL,password,sizeof(password)-1)==ESP_ERR_INVALID_ARG);
|
||||
other.user_id=0;
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_NOT_FOUND);
|
||||
memset(other.username,'x',sizeof(other.username));
|
||||
assert(user_database_set_password_current(&other,password,sizeof(password)-1)==ESP_ERR_INVALID_ARG);
|
||||
assert(user_database_set_password_current(&admin,(const uint8_t *)"short",5)==ESP_ERR_INVALID_ARG);
|
||||
/* Own password is allowed even for the last administrator; old principal is stale. */
|
||||
assert(user_database_set_password_current(&admin,password,sizeof(password)-1)==ESP_OK);
|
||||
bool current=true; assert(user_database_principal_is_current(&actor,¤t)==ESP_OK && !current);
|
||||
assert(s_database.admin_count==1);
|
||||
/* With a second admin, canonical self role/delete invariants allow both. */
|
||||
assert(user_database_set_role((const uint8_t *)"other",5,USER_ROLE_ADMIN)==ESP_OK);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); admin=list.users[0];
|
||||
assert(user_database_set_role_current(&admin,USER_ROLE_USER)==ESP_OK);
|
||||
assert(user_database_get_accounts(&list)==ESP_OK); admin=list.users[0];
|
||||
assert(user_database_delete_current(&admin)==ESP_OK);
|
||||
reset(); before=s_database; rng=random_calls;
|
||||
assert(user_database_create((const uint8_t *)"other",5,USER_ROLE_ADMIN,password,sizeof(password)-1)==ESP_ERR_INVALID_STATE);
|
||||
unchanged(&before); assert(!writes && !commits && rng==random_calls);
|
||||
for (unsigned i=3;i<USER_DATABASE_MAX_USERS;++i) {
|
||||
char name[17]; snprintf(name,sizeof(name),"account%u",i);
|
||||
assert(user_database_create((const uint8_t *)name,strlen(name),USER_ROLE_USER,password,sizeof(password)-1)==ESP_OK);
|
||||
}
|
||||
before=s_database; rng=random_calls; unsigned saved=commits;
|
||||
assert(user_database_create((const uint8_t *)"extra",5,USER_ROLE_USER,password,sizeof(password)-1)==ESP_ERR_NO_MEM);
|
||||
unchanged(&before); assert(commits==saved && rng==random_calls);
|
||||
/* RNG-only helper is independent of initialized storage and leaves all DB state alone. */
|
||||
s_initialized=false; s_mutex=NULL;
|
||||
for (unsigned mode=0;mode<2;++mode) {
|
||||
user_database_generated_password_t generated; memset(&generated,0xa5,sizeof(generated));
|
||||
fail_stage=mode ? 4 : 0;
|
||||
assert(user_database_generate_password_value(&generated)==(mode ? ESP_FAIL : ESP_OK));
|
||||
if (mode) assert(all_zero(&generated,sizeof(generated)));
|
||||
else {
|
||||
assert(generated.password_length==24 && strlen((const char *)generated.password)==24);
|
||||
for (size_t i=0;i<24;++i) assert(strchr((const char *)s_generated_alphabet,generated.password[i]));
|
||||
}
|
||||
assert(!memcmp(&before,&s_database,sizeof(before)) && commits==saved && !locks);
|
||||
secure_wipe(&generated,sizeof(generated));
|
||||
}
|
||||
assert(user_database_generate_password_value(NULL)==ESP_ERR_INVALID_ARG);
|
||||
}
|
||||
static void unload_database(void)
|
||||
{
|
||||
s_initialized=false; s_mutex=NULL; release_candidate();
|
||||
secure_wipe(&s_database,sizeof(s_database));
|
||||
}
|
||||
|
||||
static void storage_tests(void)
|
||||
{
|
||||
reset(); storage_test=true;
|
||||
/* Both historical v1 states load without any account/verifier/ID changes. */
|
||||
for (unsigned admins=0;admins<2;++admins) {
|
||||
reset();
|
||||
s_database.users[0].role=admins ? USER_ROLE_ADMIN : USER_ROLE_USER;
|
||||
recount(&s_database);
|
||||
stored_database_t before=s_database;
|
||||
memcpy(persisted,&before,sizeof(before)); persisted_size=sizeof(before);
|
||||
unload_database();
|
||||
user_database_load_result_t result;
|
||||
assert(user_database_init(&result)==ESP_OK && result==USER_DATABASE_LOAD_STORED);
|
||||
assert(!memcmp(&before,&s_database,sizeof(before)) && !writes && !commits);
|
||||
assert(user_database_recover_empty()==ESP_ERR_INVALID_STATE);
|
||||
if (!admins) {
|
||||
assert(user_database_delete((const uint8_t *)"admin",5)==ESP_OK);
|
||||
assert(s_database.admin_count==0 && s_database.user_count==2);
|
||||
}
|
||||
}
|
||||
for (unsigned kind=0;kind<4;++kind) {
|
||||
reset(); stored_database_t bad=s_database;
|
||||
if (kind==0) ++bad.version;
|
||||
if (kind==1) bad.v1_admin_marker=0;
|
||||
if (kind==2) bad.users[0].user_id=0;
|
||||
memcpy(persisted,&bad,sizeof(bad)); persisted_size=sizeof(bad)-(kind==3);
|
||||
size_t size=persisted_size;
|
||||
unload_database(); user_database_load_result_t result;
|
||||
assert(user_database_init(&result)!=ESP_OK && !s_initialized && !s_mutex);
|
||||
assert(!writes && !commits && persisted_size==size && !memcmp(persisted,&bad,size));
|
||||
web=false; remote=true;
|
||||
assert(run("user recover --force")!=0 && !writes);
|
||||
remote=false;
|
||||
assert(run("user recover")!=0 && !writes);
|
||||
assert(run("user recover --force")==0 && s_initialized);
|
||||
assert(!s_database.user_count && !s_database.admin_count);
|
||||
assert(validate_database(&s_database)==ESP_OK);
|
||||
}
|
||||
reset(); unload_database(); persisted_size=0;
|
||||
user_database_load_result_t result;
|
||||
assert(user_database_init(&result)==ESP_OK && result==USER_DATABASE_LOAD_EMPTY);
|
||||
assert(s_initialized && !s_database.user_count && writes==1 && commits==1);
|
||||
stored_database_t empty=s_database;
|
||||
assert(persisted_size==sizeof(empty) && !memcmp(persisted,&empty,sizeof(empty)));
|
||||
unload_database();
|
||||
assert(user_database_init(&result)==ESP_OK && result==USER_DATABASE_LOAD_STORED);
|
||||
assert(!memcmp(&empty,&s_database,sizeof(empty)) && writes==1 && commits==1);
|
||||
user_database_snapshot_t snapshot;
|
||||
assert(user_database_get_snapshot(&snapshot)==ESP_OK && snapshot.initialized);
|
||||
assert(!snapshot.user_count && !snapshot.admin_count);
|
||||
web=remote=false;
|
||||
assert(run("user bootstrap")!=0 && run("user bootstrap --generate")!=0);
|
||||
assert(run("user recover --force")!=0 && !memcmp(&empty,&s_database,sizeof(empty)));
|
||||
assert(run("user add chief admin")==0 && s_database.admin_count==1);
|
||||
assert(user_database_delete((const uint8_t *)"chief",5)==ESP_ERR_INVALID_STATE);
|
||||
assert(user_database_set_role((const uint8_t *)"chief",5,USER_ROLE_USER)==ESP_ERR_INVALID_STATE);
|
||||
for (unsigned stage=1;stage<=3;++stage) {
|
||||
reset(); unload_database(); persisted_size=0; fail_stage=stage;
|
||||
assert(user_database_init(&result)==ESP_FAIL && !s_initialized && !s_mutex);
|
||||
assert(!persisted_size);
|
||||
assert(user_database_recover_empty()==ESP_FAIL && !s_initialized && !s_mutex);
|
||||
}
|
||||
storage_test=false;
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
storage_tests();
|
||||
typed_account_tests();
|
||||
typed_password_tests();
|
||||
const char *supported[]={
|
||||
"user add fresh user", "user add fresh admin", "user password other",
|
||||
"user delete other --force", "user role other admin --force",
|
||||
"\"user\" \"password\" \"other\"", "user role other user --force",
|
||||
};
|
||||
for (size_t i=0;i<sizeof(supported)/sizeof(*supported);++i) {
|
||||
reset(); stored_database_t before=s_database;
|
||||
assert(run(supported[i])==0);
|
||||
assert(web_revokes==1 && ssh_revokes==1);
|
||||
assert(!strcmp(revoked_name,i<2 ? "fresh" : "other"));
|
||||
assert(!memcmp(&before.users[0],&s_database.users[0],sizeof(stored_user_t)));
|
||||
assert(!memcmp(&before.users[2],&s_database.users[2],sizeof(stored_user_t)));
|
||||
assert(admin_ssh_console_dispatch_is_current());
|
||||
assert(validate_database(&s_database)==ESP_OK);
|
||||
if (i<2) {
|
||||
assert(s_database.user_count==before.user_count+1);
|
||||
int fresh=find_user(&s_database,(const uint8_t *)"fresh",5);
|
||||
assert(fresh>=0 && s_database.users[fresh].role==(i==0 ? USER_ROLE_USER : USER_ROLE_ADMIN));
|
||||
} else if (i==3) {
|
||||
assert(find_user(&s_database,(const uint8_t *)"other",5)<0);
|
||||
} else if (i!=6) {
|
||||
assert(s_database.users[1].auth_generation==before.users[1].auth_generation+1);
|
||||
} else {
|
||||
assert(!writes && !commits); /* Existing no-op role still revokes target. */
|
||||
}
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)));
|
||||
assert(!locks);
|
||||
}
|
||||
const char *denied[]={
|
||||
"user password admin", "\"user\" \"password\" \"admin\"",
|
||||
"user delete admin --force", "user role admin user --force",
|
||||
"user role admin admin --force", "user add admin admin",
|
||||
"user password admin --generate", "user password other --generate",
|
||||
"user add fresh user --generate", "user key add other",
|
||||
"user key add other ssh-ed25519 AAAA", "user key delete other 0 --force",
|
||||
"user key clear other --force", "user bootstrap", "user bootstrap --generate",
|
||||
"user recover --force", "user password other extra",
|
||||
"user role other admin --force extra", "user delete other --force extra",
|
||||
"user add fresh invalid", "user add fresh user extra",
|
||||
"user delete other", "user role other user",
|
||||
"user role \"admin\" user --force", "user add \"admin\" user",
|
||||
};
|
||||
for (size_t i=0;i<sizeof(denied)/sizeof(*denied);++i) {
|
||||
reset(); stored_database_t before=s_database; unsigned rng=random_calls;
|
||||
assert(run(denied[i])!=0); unchanged(&before);
|
||||
assert(!prompts && !writes && !commits && random_calls==rng);
|
||||
}
|
||||
reset();
|
||||
actor.username_length=0;
|
||||
assert(run("user password other")!=0 && !prompts && !writes);
|
||||
reset();
|
||||
actor.username_length=USER_DATABASE_USERNAME_CAPACITY+1;
|
||||
assert(run("user password other")!=0 && !prompts && !writes);
|
||||
reset();
|
||||
/* Self defense is identity-based, not a hard-coded 'admin' name. */
|
||||
memcpy(s_database.users[0].username,"chief",5);
|
||||
memcpy(actor.username,"chief",5);
|
||||
assert(run("user password \"chief\"")!=0 && !prompts && !writes);
|
||||
assert(run("user role \"chief\" admin --force")!=0 && !writes);
|
||||
const char *prompted[]={"user add fresh admin", "user password other"};
|
||||
for (size_t i=0;i<2;++i) {
|
||||
for (unsigned mode=0;mode<7;++mode) {
|
||||
reset(); stored_database_t before=s_database; unsigned rng=random_calls;
|
||||
if (mode<2) revoke_prompt=mode+1;
|
||||
if (mode==2) revoke_check=2; /* After both successful prompts. */
|
||||
if (mode==3) mismatch=true;
|
||||
if (mode==4) cancel_prompt=true;
|
||||
if (mode==5) { ++actor.auth_generation; }
|
||||
if (mode==6) stale_prompt=true;
|
||||
assert(run(prompted[i])!=0); unchanged(&before);
|
||||
assert(!writes && !commits && random_calls==rng);
|
||||
}
|
||||
}
|
||||
/* Operation admission is the final post-prompt check before the database
|
||||
* API, not the later NVS commit. Once admitted, expiry/closure during
|
||||
* derivation does not cancel the transaction. No browser receipt is proved. */
|
||||
for (size_t i=0;i<2;++i) {
|
||||
for (unsigned stage=0;stage<=3;++stage) {
|
||||
reset(); stored_database_t before=s_database;
|
||||
invalidate_during_derivation=true; fail_stage=stage;
|
||||
int result=run(prompted[i]);
|
||||
assert(derivation_invalidations==1 && !owner_current && checks==2);
|
||||
assert(prompts==2 && !locks);
|
||||
if (stage==0) {
|
||||
assert(result==0 && writes==1 && commits==1);
|
||||
assert(s_database.generation==before.generation+1);
|
||||
if (i==0) {
|
||||
int fresh=find_user(&s_database,(const uint8_t *)"fresh",5);
|
||||
assert(fresh>=0 && s_database.users[fresh].role==USER_ROLE_ADMIN);
|
||||
assert(s_database.user_count==before.user_count+1);
|
||||
assert(!memcmp(&before.users[1],&s_database.users[1],sizeof(stored_user_t)));
|
||||
} else {
|
||||
assert(s_database.users[1].auth_generation==before.users[1].auth_generation+1);
|
||||
assert(memcmp(before.users[1].password_salt,s_database.users[1].password_salt,
|
||||
sizeof(before.users[1].password_salt))!=0);
|
||||
}
|
||||
assert(web_revokes==1 && ssh_revokes==1);
|
||||
assert(!strcmp(revoked_name,i==0 ? "fresh" : "other"));
|
||||
assert(!memcmp(&before.users[0],&s_database.users[0],sizeof(stored_user_t)));
|
||||
assert(!memcmp(&before.users[2],&s_database.users[2],sizeof(stored_user_t)));
|
||||
assert(validate_database(&s_database)==ESP_OK);
|
||||
assert(all_zero(s_candidate,sizeof(*s_candidate)));
|
||||
} else {
|
||||
assert(result!=0); unchanged(&before);
|
||||
assert(commits==(stage==3 ? 1U : 0U));
|
||||
}
|
||||
/* Loss of liveness cannot authorize a subsequent operation. */
|
||||
stored_database_t after=s_database;
|
||||
unsigned prior_writes=writes, prior_commits=commits;
|
||||
unsigned prior_web=web_revokes, prior_ssh=ssh_revokes;
|
||||
assert(run("user password observer")!=0);
|
||||
assert(!memcmp(&after,&s_database,sizeof(after)));
|
||||
assert(prompts==2 && writes==prior_writes && commits==prior_commits);
|
||||
assert(web_revokes==prior_web && ssh_revokes==prior_ssh);
|
||||
}
|
||||
}
|
||||
/* Every enabled mutation fails closed when the originating session or copied
|
||||
* account is stale BEFORE operation admission, including forced mutations. */
|
||||
for (size_t i=0;i<sizeof(supported)/sizeof(*supported);++i) {
|
||||
reset(); stored_database_t before=s_database; owner_current=false;
|
||||
assert(run(supported[i])!=0); unchanged(&before); assert(!prompts && !writes);
|
||||
reset(); before=s_database; revoke_check=2;
|
||||
assert(run(supported[i])!=0); unchanged(&before); assert(!writes && !commits);
|
||||
reset(); before=s_database; actor.role=USER_ROLE_USER;
|
||||
assert(run(supported[i])!=0); unchanged(&before); assert(!prompts && !writes);
|
||||
}
|
||||
for (size_t i=0;i<5;++i) {
|
||||
for (unsigned stage=1;stage<=3;++stage) {
|
||||
reset(); stored_database_t before=s_database; fail_stage=stage;
|
||||
assert(run(supported[i])!=0); unchanged(&before);
|
||||
assert(commits==(stage==3 ? 1U : 0U));
|
||||
}
|
||||
}
|
||||
for (size_t i=0;i<3;++i) {
|
||||
for (unsigned stage=4;stage<=5;++stage) {
|
||||
reset(); stored_database_t before=s_database; fail_stage=stage;
|
||||
assert(run(supported[i])!=0); unchanged(&before); assert(!writes && !commits);
|
||||
}
|
||||
}
|
||||
reset(); notify_error=ESP_FAIL;
|
||||
assert(run("user password other")==0);
|
||||
assert(commits==1 && web_revokes==1 && ssh_revokes==1);
|
||||
assert(s_database.users[1].auth_generation==2);
|
||||
/* Real database invariants, independent of browser self-target policy. */
|
||||
reset(); stored_database_t before=s_database;
|
||||
assert(user_database_delete((const uint8_t *)"admin",5)!=ESP_OK); unchanged(&before);
|
||||
assert(user_database_set_role((const uint8_t *)"admin",5,USER_ROLE_USER)!=ESP_OK);
|
||||
unchanged(&before); assert(!writes && !commits);
|
||||
/* Trusted UART0 bypasses browser admission, not database invariants. */
|
||||
reset(); web=false; remote=false;
|
||||
assert(run("user delete admin --force")!=0); assert(!writes && !web_revokes);
|
||||
reset(); web=false; remote=false;
|
||||
assert(run("user role admin user --force")!=0); assert(!writes && !web_revokes);
|
||||
/* Normal UART0 and SSH prompted nonself commands still use the same handler. */
|
||||
reset(); web=false; assert(run("user password other")==0);
|
||||
reset(); web=false; remote=false; owner_current=false;
|
||||
assert(run("user password other")==0);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Canonical account handlers + production DB transactions; deterministic IO/NVS/crypto.
|
||||
|
||||
Not a concurrent RTOS, cryptographic, real-NVS or target test. Run directly.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
|
||||
|
||||
def function(source, name):
|
||||
start = source.index(name + "(")
|
||||
start = source.rfind("\n", 0, start) + 1
|
||||
return source[start:source.index("\n}", start) + 2] + "\n"
|
||||
|
||||
def strip_includes(text):
|
||||
return "\n".join(line for line in text.splitlines()
|
||||
if not line.startswith(("#include", "#pragma once")))
|
||||
|
||||
db = (ROOT / "src/user_database.c").read_text()
|
||||
console = (ROOT / "src/user_console.c").read_text()
|
||||
admin = (ROOT / "src/admin_ssh_console.c").read_text()
|
||||
prelude = r'''
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
|
||||
ESP_ERR_NO_MEM, ESP_ERR_NOT_FOUND, ESP_ERR_NOT_ALLOWED,
|
||||
ESP_ERR_INVALID_RESPONSE, ESP_ERR_INVALID_VERSION, ESP_ERR_TIMEOUT };
|
||||
#define pdTRUE 1
|
||||
static bool snapshot_busy;
|
||||
static int last_wait;
|
||||
typedef void *SemaphoreHandle_t;
|
||||
#define portMAX_DELAY 0
|
||||
#define NVS_READWRITE 1
|
||||
#define NVS_READONLY 0
|
||||
#define ESP_ERR_NVS_NOT_FOUND 100
|
||||
static uint8_t persisted[65536], staged[65536];
|
||||
static size_t persisted_size, staged_size;
|
||||
static bool storage_test;
|
||||
static void *xSemaphoreCreateMutex(void) { return (void *)1; }
|
||||
static void vSemaphoreDelete(void *m) { (void)m; }
|
||||
static int nvs_flash_init(void) { return ESP_OK; }
|
||||
static int nvs_get_blob(int h, const char *key, void *out, size_t *n) {
|
||||
(void)h; (void)key;
|
||||
if (!persisted_size) return ESP_ERR_NVS_NOT_FOUND;
|
||||
if (out) { assert(*n>=persisted_size); memcpy(out,persisted,persisted_size); }
|
||||
*n=persisted_size; return ESP_OK;
|
||||
}
|
||||
typedef int nvs_handle_t;
|
||||
static unsigned locks, writes, commits, random_calls, prompts, checks, web_revokes, ssh_revokes;
|
||||
static unsigned fail_stage, revoke_prompt, revoke_check, derivation_invalidations;
|
||||
static bool invalidate_during_derivation;
|
||||
static bool owner_current = true, remote = true, web = true, mismatch, cancel_prompt, stale_prompt;
|
||||
static int notify_error = ESP_OK;
|
||||
static char revoked_name[17];
|
||||
static void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
|
||||
static int xSemaphoreTake(void *m, int t) { (void)m; last_wait=t; if (snapshot_busy) return 0; assert(!locks++); return pdTRUE; }
|
||||
static void xSemaphoreGive(void *m) { (void)m; assert(locks-- == 1); }
|
||||
static const char *esp_err_to_name(int e) { (void)e; return "injected error"; }
|
||||
static int nvs_open(const char *ns, int mode, int *h) {
|
||||
(void)ns; (void)mode; assert(locks || storage_test);
|
||||
if (invalidate_during_derivation) {
|
||||
assert(derivation_invalidations==1 && !owner_current);
|
||||
}
|
||||
*h=1; return fail_stage==1 ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
static int nvs_set_blob(int h, const char *key, const void *data, size_t n) {
|
||||
(void)h; (void)key; ++writes;
|
||||
if (fail_stage==2) return ESP_FAIL;
|
||||
assert(n<=sizeof(staged)); memcpy(staged,data,n); staged_size=n; return ESP_OK;
|
||||
}
|
||||
static int nvs_commit(int h) {
|
||||
(void)h; ++commits; if (fail_stage==3) return ESP_FAIL;
|
||||
memcpy(persisted,staged,staged_size); persisted_size=staged_size; return ESP_OK;
|
||||
}
|
||||
static void nvs_close(int h) { (void)h; }
|
||||
static int secure_random_fill(void *p, size_t n) {
|
||||
memset(p, ++random_calls, n); return fail_stage==4 ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
static int derive_password(const uint8_t *p, size_t n, const uint8_t *s,
|
||||
uint32_t iterations, uint8_t *hash) {
|
||||
(void)p; (void)n; (void)s; (void)iterations; memset(hash, 7, 32);
|
||||
if (invalidate_during_derivation) {
|
||||
/* Model originating browser expiry/closure after operation admission.
|
||||
* This is a deterministic derivation double, not real PBKDF2/HTTPD. */
|
||||
assert(locks==1 && prompts==2 && checks==2 && owner_current);
|
||||
assert(!writes && !commits);
|
||||
owner_current=false;
|
||||
++derivation_invalidations;
|
||||
}
|
||||
return fail_stage==5 ? ESP_FAIL : ESP_OK;
|
||||
}
|
||||
#include <openssl/sha.h>
|
||||
#include <openssl/ec.h>
|
||||
#include <openssl/obj_mac.h>
|
||||
typedef EC_GROUP *mbedtls_ecp_group;
|
||||
typedef struct { EC_POINT *point; } mbedtls_ecp_point;
|
||||
#define MBEDTLS_ECP_DP_SECP256R1 1
|
||||
static void mbedtls_ecp_group_init(mbedtls_ecp_group *g) { *g=NULL; }
|
||||
static void mbedtls_ecp_point_init(mbedtls_ecp_point *p) { p->point=NULL; }
|
||||
static int mbedtls_ecp_group_load(mbedtls_ecp_group *g, int id) {
|
||||
assert(id==1); *g=EC_GROUP_new_by_curve_name(NID_X9_62_prime256v1); return *g ? 0 : -1;
|
||||
}
|
||||
static int mbedtls_ecp_point_read_binary(mbedtls_ecp_group *g, mbedtls_ecp_point *p, const uint8_t *b, size_t n) {
|
||||
p->point=EC_POINT_new(*g); return p->point && EC_POINT_oct2point(*g,p->point,b,n,NULL)==1 ? 0 : -1;
|
||||
}
|
||||
static int mbedtls_ecp_check_pubkey(mbedtls_ecp_group *g, mbedtls_ecp_point *p) {
|
||||
return EC_POINT_is_at_infinity(*g,p->point)==0 && EC_POINT_is_on_curve(*g,p->point,NULL)==1 ? 0 : -1;
|
||||
}
|
||||
static void mbedtls_ecp_point_free(mbedtls_ecp_point *p) { EC_POINT_free(p->point); }
|
||||
static void mbedtls_ecp_group_free(mbedtls_ecp_group *g) { EC_GROUP_free(*g); }
|
||||
static int mbedtls_sha256(const uint8_t *p, size_t n, uint8_t *h, int mode) {
|
||||
assert(mode==0); return SHA256(p,n,h) ? 0 : -1;
|
||||
}
|
||||
'''
|
||||
header = strip_includes((ROOT / "src/user_database.h").read_text())
|
||||
state = db[db.index("#define USER_DATABASE_SCHEMA_VERSION"):db.index("static esp_err_t initialize_dummy_verifier(")]
|
||||
fakes = r'''
|
||||
|
||||
static stored_database_t candidate_storage;
|
||||
static int allocate_candidate(void) { s_candidate=&candidate_storage; return ESP_OK; }
|
||||
static void release_candidate(void) { secure_wipe(&candidate_storage,sizeof(candidate_storage)); s_candidate=NULL; }
|
||||
static user_principal_t actor;
|
||||
static bool admin_ssh_console_dispatch_is_remote(void) { return remote; }
|
||||
static bool admin_ssh_console_dispatch_is_web(void) { return web; }
|
||||
static const user_principal_t *admin_ssh_console_dispatch_principal(void) { return remote ? &actor : NULL; }
|
||||
static bool admin_ssh_console_dispatch_is_current(void) {
|
||||
bool current=false; ++checks;
|
||||
if (checks==revoke_check) owner_current=false;
|
||||
return owner_current && user_database_principal_is_current(&actor, ¤t)==ESP_OK && current;
|
||||
}
|
||||
static int admin_command_gate_take(void) { return ESP_OK; }
|
||||
static void admin_command_gate_give(void) {}
|
||||
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;
|
||||
memcpy(out, "test-password", 13); *n=13;
|
||||
if (mismatch && prompts==2) out[0]='X';
|
||||
/* Simulate invalidation just after the prompt boundary returned success. */
|
||||
if (prompts==revoke_prompt) owner_current=false;
|
||||
if (stale_prompt && prompts==2) ++actor.auth_generation;
|
||||
return cancel_prompt ? ESP_ERR_INVALID_STATE : ESP_OK;
|
||||
}
|
||||
static int web_serial_transport_revoke_user(const uint8_t *u, size_t n) {
|
||||
++web_revokes; assert(n<sizeof(revoked_name)); memcpy(revoked_name,u,n); revoked_name[n]=0;
|
||||
assert(strcmp(revoked_name,"admin")); return notify_error;
|
||||
}
|
||||
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; }
|
||||
|
||||
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) {
|
||||
(void)n; (void)t; (void)tl; (void)b; (void)bl; assert(!"key mutation"); return 1;
|
||||
}
|
||||
esp_err_t user_database_create_generated(const uint8_t *u,size_t n,user_role_t r,user_database_generated_password_t *p) {
|
||||
(void)u; (void)n; (void)r; (void)p; assert(!"generated credential"); return ESP_FAIL;
|
||||
}
|
||||
esp_err_t user_database_generate_password(const uint8_t *u,size_t n,user_database_generated_password_t *p) {
|
||||
(void)u; (void)n; (void)p; assert(!"generated credential"); return ESP_FAIL;
|
||||
}
|
||||
|
||||
size_t esp_console_split_argv(char *, char **, size_t);
|
||||
'''
|
||||
db_names = ["constant_time_equal", "all_zero", "user_database_username_valid",
|
||||
"user_database_password_valid", "read_ssh_string", "user_database_key_valid",
|
||||
"user_role_to_string", "user_role_parse", "set_record_password", "find_user",
|
||||
"find_free_user", "stored_keys_equal", "validate_database", "recount",
|
||||
"next_generation", "discard_candidate", "commit_candidate_locked", "initialize_user",
|
||||
"user_database_principal_is_current", "create_locked", "user_database_create",
|
||||
"mutate_user_begin", "target_matches_locked", "delete_user", "set_role",
|
||||
"user_database_delete", "user_database_set_role", "user_database_get_accounts",
|
||||
"user_database_delete_current", "user_database_set_role_current",
|
||||
"set_password", "user_database_set_password", "user_database_set_password_current",
|
||||
"user_database_generate_password_value",
|
||||
"user_database_get_account_keys", "add_ssh_key", "remove_ssh_key", "clear_ssh_keys",
|
||||
"user_database_add_ssh_key", "user_database_remove_ssh_key", "user_database_clear_ssh_keys",
|
||||
"key_target_valid", "user_database_add_ssh_key_current", "user_database_remove_ssh_key_current",
|
||||
"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",
|
||||
"show_generated_password", "mutation_currentness", "add_user", "change_password",
|
||||
"parse_key_index", "recover_database", "command_user_inner", "command_user"]
|
||||
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 += "\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();')
|
||||
assert ' typed_key_tests();' in account_tests
|
||||
unit += account_tests
|
||||
with tempfile.TemporaryDirectory(prefix="admin-accounts-") as directory:
|
||||
path = Path(directory)
|
||||
(path / "test.c").write_text(unit)
|
||||
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)
|
||||
assert "test-password" not in result.stdout
|
||||
assert "Generated password for" not in result.stdout
|
||||
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)")
|
||||
print("PASS: canonical parsed accounts + production DB transactions: nonself isolation, prompt revocation/cancel/mismatch, currentness, persistence/RNG/derive failures, final-admin invariants, self/generated/key/recovery traps; no password output")
|
||||
@@ -1,115 +0,0 @@
|
||||
|
||||
#define SSH_TRANSPORT_MAX_SESSIONS 2U
|
||||
enum { SSH_TRANSPORT_SESSION_FREE=0, SSH_TRANSPORT_SESSION_ACTIVE=2,
|
||||
SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE=2 };
|
||||
enum { USER_AUTH_METHOD_PASSWORD=0 };
|
||||
typedef struct {
|
||||
uint32_t session_id, generation, broker_client_id;
|
||||
int state, route, socket_fd;
|
||||
uint8_t console_slot_index;
|
||||
bool authenticated, principal_valid, writer, close_requested;
|
||||
size_t rx_length, rx_offset, tx_length, tx_offset;
|
||||
user_principal_t principal;
|
||||
char peer[48];
|
||||
} ssh_slot_t;
|
||||
typedef struct {
|
||||
bool active, tx_pending, rx_pending, authenticated, principal_valid, close_requested;
|
||||
bool writer, admin_command_pending;
|
||||
uint32_t session_id, generation, broker_client_id, admin_output_pending;
|
||||
int state, route, socket_fd, user_role, auth_method;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY+1U], peer[48];
|
||||
} ssh_transport_session_snapshot_t;
|
||||
static ssh_transport_session_snapshot_t s_session_snapshots[2];
|
||||
static user_principal_t s_console_principals[2];
|
||||
static uint8_t s_console_slot_indices[2];
|
||||
static uint32_t s_external_close_id[2];
|
||||
static unsigned stopped, disconnected, rotated, reset, restarted;
|
||||
static esp_err_t ssh_transport_stop(void) { ++stopped; return ESP_OK; }
|
||||
static esp_err_t ssh_transport_disconnect(uint32_t id) { disconnected=id; return ESP_OK; }
|
||||
static esp_err_t ssh_transport_replace_host_key(bool r) { if(r) ++reset; else ++rotated; return ESP_OK; }
|
||||
static void esp_restart(void) { ++restarted; }
|
||||
static void publish_slot(const ssh_slot_t *, size_t);
|
||||
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 void test_adapter(void)
|
||||
{
|
||||
admin_ssh_console_token_t token={ .slot_index=0, .session_id=7, .slot_generation=3 };
|
||||
user_principal_t admin={ .role=USER_ROLE_ADMIN, .user_id=11, .auth_generation=2,
|
||||
.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_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. */
|
||||
assert(!admin_console_is_current(&token,&admin));
|
||||
ssh_slot_t active={ .session_id=7, .generation=3, .authenticated=true,
|
||||
.principal_valid=true, .state=SSH_TRANSPORT_SESSION_ACTIVE,
|
||||
.route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE, .principal=admin };
|
||||
publish_slot(&active,0);
|
||||
assert(admin_console_is_current(&token,&admin));
|
||||
/* Production publication carries both console output and principal binding. */
|
||||
assert(s_session_snapshots[0].tx_pending && s_session_snapshots[0].admin_output_pending);
|
||||
assert(!strcmp(s_session_snapshots[0].username,"admin"));
|
||||
uint8_t output[4096]; size_t n;
|
||||
assert(admin_ssh_console_read_output(&token,output,sizeof(output),&n)==ESP_OK && n);
|
||||
publish_slot(&active,0);
|
||||
assert(admin_console_drained(&token));
|
||||
active.state=SSH_TRANSPORT_SESSION_FREE; active.principal_valid=false;
|
||||
publish_slot(&active,0);
|
||||
user_principal_t empty={0};
|
||||
assert(!memcmp(&s_console_principals[0],&empty,sizeof(empty)));
|
||||
assert(!admin_console_is_current(&token,&admin));
|
||||
active.state=SSH_TRANSPORT_SESSION_ACTIVE; active.principal_valid=true;
|
||||
publish_slot(&active,0);
|
||||
assert(admin_console_is_current(&token,&admin));
|
||||
admin.username[0]='A'; assert(!admin_console_is_current(&token,&admin)); admin.username[0]='a';
|
||||
active.route=0; publish_slot(&active,0); assert(!admin_console_is_current(&token,&admin));
|
||||
active.route=SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
|
||||
active.authenticated=false; publish_slot(&active,0); assert(!admin_console_is_current(&token,&admin));
|
||||
active.authenticated=true; publish_slot(&active,0);
|
||||
s_external_close_id[0]=7; assert(!admin_console_is_current(&token,&admin));
|
||||
ssh_slot_t closing={.session_id=7, .state=SSH_TRANSPORT_SESSION_ACTIVE};
|
||||
assert(consume_external_close(&closing,0) && !s_external_close_id[0]);
|
||||
assert(!admin_console_is_current(&token,&admin));
|
||||
s_session_snapshots[0].close_requested=true;
|
||||
assert(!admin_console_is_current(&token,&admin)); s_session_snapshots[0].close_requested=false;
|
||||
++admin.auth_generation; assert(!admin_console_is_current(&token,&admin)); --admin.auth_generation;
|
||||
++admin.user_id; assert(!admin_console_is_current(&token,&admin)); --admin.user_id;
|
||||
++admin.method; assert(!admin_console_is_current(&token,&admin)); --admin.method;
|
||||
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);
|
||||
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;
|
||||
assert(!admin_console_is_current(&wrong,&admin) && !admin_console_drained(&wrong));
|
||||
assert(admin_console_perform(&wrong,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND);
|
||||
/* A colliding published ID with a stale generation cannot steal the lookup. */
|
||||
s_session_snapshots[1]=s_session_snapshots[0];
|
||||
++s_session_snapshots[1].generation; s_console_slot_indices[1]=0;
|
||||
assert(admin_console_is_current(&token,&admin));
|
||||
assert(admin_ssh_console_read_output(&token,output,sizeof(output),&n)==ESP_OK && n);
|
||||
publish_slot(&active,0);
|
||||
s_session_snapshots[0].tx_pending=true;
|
||||
assert(!admin_console_drained(&token)); s_session_snapshots[0].tx_pending=false;
|
||||
assert(admin_console_perform(&token,ADMIN_CONSOLE_DEFER_SELF_CLOSE,99)==ESP_OK && disconnected==7);
|
||||
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_DISCONNECT,99)==ESP_OK && disconnected==99);
|
||||
assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_OK && stopped==1);
|
||||
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");
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
/* Typed deferred work exercises the production dispatcher/control state machine. */
|
||||
static admin_ssh_console_token_t token={.session_id=7, .slot_generation=1,
|
||||
.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
|
||||
static user_principal_t principal={.role=USER_ROLE_ADMIN, .auth_generation=1};
|
||||
static bool live=true, close_in_action;
|
||||
static unsigned validations, invalidate_at;
|
||||
static esp_err_t action_result;
|
||||
static bool current(const admin_ssh_console_token_t *t, const user_principal_t *p) {
|
||||
assert(!lock_depth && t->session_id==7 && p->auth_generation==1);
|
||||
if (++validations==invalidate_at) live=false;
|
||||
return live;
|
||||
}
|
||||
static bool drained(const admin_ssh_console_token_t *t) {
|
||||
assert(!lock_depth && current_task==s_control_task && t->session_id==7);
|
||||
return owner_drained;
|
||||
}
|
||||
static esp_err_t perform(const admin_ssh_console_token_t *t,
|
||||
admin_ssh_deferred_action_type_t action, uint32_t arg);
|
||||
static const admin_console_owner_t owner={
|
||||
.supported_actions=1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
|
||||
.dispatcher_actions=1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
|
||||
.is_current=current, .drained=drained, .perform=perform,
|
||||
};
|
||||
static esp_err_t perform(const admin_ssh_console_token_t *t,
|
||||
admin_ssh_deferred_action_type_t action, uint32_t arg) {
|
||||
assert(!lock_depth && current_task==s_task && current_task!=s_control_task);
|
||||
assert(action==ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE && arg==0);
|
||||
assert(s_sessions[0].executing && s_sessions[0].deferred_action_pending);
|
||||
assert(!admin_ssh_console_accepts_input(t));
|
||||
size_t consumed=99;
|
||||
assert(!admin_ssh_console_feed_input(t,(const uint8_t *)"ignored",7,&consumed) && !consumed);
|
||||
++actions;
|
||||
if (close_in_action) {
|
||||
admin_ssh_console_close(t);
|
||||
admin_ssh_console_token_t replacement=*t; ++replacement.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&replacement,&principal,&owner)==ESP_ERR_INVALID_STATE);
|
||||
}
|
||||
return action_result;
|
||||
}
|
||||
static void pump(void (*task)(void *)) {
|
||||
current_task=task==control_task ? s_control_task : s_task;
|
||||
if (!setjmp(loop_done)) task(NULL);
|
||||
}
|
||||
static void clear_output(void) {
|
||||
uint8_t data[4096]; size_t n;
|
||||
assert(admin_ssh_console_read_output(&token,data,sizeof(data),&n)==ESP_OK);
|
||||
}
|
||||
static void reopen_certificate_session(void) {
|
||||
admin_ssh_console_close(&token); ++token.slot_generation;
|
||||
live=principal_current=owner_drained=true; validations=invalidate_at=0;
|
||||
close_in_action=false; action_result=ESP_OK; ticks=0;
|
||||
assert(admin_ssh_console_open_owned(&token,&principal,&owner)==ESP_OK);
|
||||
clear_output();
|
||||
}
|
||||
static esp_err_t schedule(void) {
|
||||
current_task=s_task; s_dispatch_remote=true; s_dispatch_token=token;
|
||||
s_dispatch_principal=principal;
|
||||
s_sessions[0].executing=s_sessions[0].command_pending=true;
|
||||
esp_err_t result=admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,0);
|
||||
s_sessions[0].executing=s_sessions[0].command_pending=false;
|
||||
s_dispatch_remote=false;
|
||||
return result;
|
||||
}
|
||||
static void revoke_delay(void) { if (ticks>=200) live=false; }
|
||||
static void reuse_delay(void) {
|
||||
if (ticks>=200) { delay_hook=NULL; reopen_certificate_session(); }
|
||||
}
|
||||
static void assert_pending(void) {
|
||||
admin_ssh_console_session_snapshot_t snapshot;
|
||||
assert(admin_ssh_console_get_session_snapshot(&token,&snapshot)==ESP_OK);
|
||||
assert(snapshot.deferred_action_pending && !admin_ssh_console_accepts_input(&token));
|
||||
}
|
||||
static void uart_observes_pending(void) {
|
||||
assert(current_task==s_task && actions==0); assert_pending();
|
||||
}
|
||||
int main(void) {
|
||||
assert(admin_ssh_console_init()==ESP_OK);
|
||||
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
|
||||
s_task=(void *)1; s_control_task=(void *)2;
|
||||
/* Union overlay preserves the old queue item allocation on this ABI. */
|
||||
struct old_request { admin_request_origin_t origin; admin_ssh_console_token_t token;
|
||||
user_principal_t principal; TaskHandle_t completion_task;
|
||||
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY+1U]; };
|
||||
assert(sizeof(admin_request_t)==sizeof(struct old_request));
|
||||
assert(s_request_queue->capacity==4 && s_control_queue->capacity==2);
|
||||
reopen_certificate_session(); queue_full=true;
|
||||
assert(schedule()==ESP_ERR_TIMEOUT && !s_sessions[0].deferred_action_pending && !actions);
|
||||
queue_full=false;
|
||||
assert(schedule()==ESP_OK); assert_pending();
|
||||
/* Drain waits for acknowledgement, then times out without enqueue/mutation. */
|
||||
s_sessions[0].output_length=1; pump(control_task);
|
||||
assert(ticks==10000 && !actions && !s_request_queue->count && !s_sessions[0].deferred_action_pending);
|
||||
clear_output(); ticks=0;
|
||||
assert(schedule()==ESP_OK);
|
||||
admin_request_t uart={.origin=ADMIN_REQUEST_UART0, .line="memory"};
|
||||
for (unsigned i=0;i<4;++i) assert(xQueueSend(s_request_queue,&uart,0));
|
||||
pump(control_task);
|
||||
assert(!actions && !s_sessions[0].deferred_action_pending && s_request_queue->count==4);
|
||||
assert(s_sessions[0].output_length); pump(worker_task); clear_output();
|
||||
puts("PASS: unchanged queue item/depths, admission and handoff queue failure before mutation, ack drain cancellation");
|
||||
|
||||
assert(schedule()==ESP_OK);
|
||||
assert(xQueueSend(s_request_queue,&uart,0));
|
||||
pump(control_task); assert_pending(); assert(!actions && s_request_queue->count==2);
|
||||
command_hook=uart_observes_pending; pump(worker_task); command_hook=NULL;
|
||||
assert(actions==1 && !s_sessions[0].deferred_action_pending && !s_sessions[0].executing);
|
||||
assert(runs==5); /* Typed work never calls esp_console_run. */
|
||||
puts("PASS: control only hands off, queued UART first, crypto callback exclusively serialized on dispatcher, input gated through callback");
|
||||
|
||||
for (unsigned cancellation=0;cancellation<7;++cancellation) {
|
||||
reopen_certificate_session(); assert(schedule()==ESP_OK);
|
||||
if (cancellation==0) delay_hook=revoke_delay;
|
||||
if (cancellation==1) delay_hook=reuse_delay;
|
||||
pump(control_task); delay_hook=NULL;
|
||||
if (cancellation==2) live=false;
|
||||
if (cancellation==3) principal_current=false;
|
||||
if (cancellation==4) reopen_certificate_session();
|
||||
if (cancellation==5) admin_ssh_console_close(&token);
|
||||
if (cancellation==6) invalidate_at=2; /* Last check after executing reservation. */
|
||||
pump(worker_task);
|
||||
assert(actions==1 && !s_sessions[0].executing);
|
||||
if (cancellation==1 || cancellation==4) {
|
||||
assert(s_sessions[0].active && !s_sessions[0].deferred_action_pending && !s_sessions[0].output_length);
|
||||
}
|
||||
}
|
||||
puts("PASS: delay/queued revoke, account revoke, close/reuse, final execution check; no output into replacements");
|
||||
|
||||
reopen_certificate_session(); action_result=ESP_ERR_NO_MEM; assert(schedule()==ESP_OK);
|
||||
pump(control_task); pump(worker_task);
|
||||
assert(actions==2 && !s_sessions[0].deferred_action_pending && admin_ssh_console_accepts_input(&token));
|
||||
uint8_t out[512]={0}; size_t n;
|
||||
assert(admin_ssh_console_read_output(&token,out,sizeof(out)-1,&n)==ESP_OK);
|
||||
assert(strstr((char *)out,"Deferred remote action failed: fake"));
|
||||
reopen_certificate_session(); close_in_action=true; assert(schedule()==ESP_OK);
|
||||
pump(control_task); pump(worker_task);
|
||||
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)) && actions==3);
|
||||
puts("PASS: action error reaches deferred result, input resumes on failure, self-detach reserves slot until return and wipes state");
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
#include <assert.h>
|
||||
#include <errno.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <setjmp.h>
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE,
|
||||
ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND };
|
||||
enum { USER_ROLE_USER, USER_ROLE_ADMIN };
|
||||
#define USER_DATABASE_USERNAME_CAPACITY 16U
|
||||
typedef struct {
|
||||
uint32_t user_id, auth_generation;
|
||||
int role, method;
|
||||
size_t username_length;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
||||
} user_principal_t;
|
||||
typedef unsigned TickType_t;
|
||||
typedef void *TaskHandle_t;
|
||||
typedef int portMUX_TYPE;
|
||||
typedef struct { size_t size; unsigned count, capacity; unsigned char bytes[2048]; } StaticQueue_t;
|
||||
typedef StaticQueue_t *QueueHandle_t;
|
||||
typedef int StaticSemaphore_t;
|
||||
typedef int *SemaphoreHandle_t;
|
||||
#define portMUX_INITIALIZER_UNLOCKED 0
|
||||
#define pdTRUE 1
|
||||
#define pdPASS 1
|
||||
#define portMAX_DELAY UINT32_MAX
|
||||
#define pdMS_TO_TICKS(x) (x)
|
||||
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
|
||||
static unsigned lock_depth, ticks, runs, actions;
|
||||
static uint32_t serial_settings_executed, account_settings_executed, network_settings_executed;
|
||||
static void web_network_settings_execute(uint32_t id) { assert(!lock_depth); network_settings_executed = id; }
|
||||
static void web_account_settings_execute(uint32_t id) { assert(!lock_depth); account_settings_executed = id; }
|
||||
static unsigned serial_settings_preceding_runs, queue_send_wait;
|
||||
static void web_serial_settings_execute(uint32_t id) {
|
||||
assert(!lock_depth);
|
||||
serial_settings_executed = id;
|
||||
serial_settings_preceding_runs = runs;
|
||||
}
|
||||
static bool principal_current = true, queue_full, owner_drained = true;
|
||||
static TaskHandle_t current_task = (void *)1;
|
||||
static jmp_buf loop_done;
|
||||
static void (*delay_hook)(void), (*prompt_hook)(void), (*completion_hook)(void);
|
||||
static void (*command_hook)(void);
|
||||
#define taskENTER_CRITICAL(p) ((void)(p), ++lock_depth)
|
||||
#define taskEXIT_CRITICAL(p) ((void)(p), --lock_depth)
|
||||
static void secure_wipe(void *p, size_t n) { memset(p, 0, n); }
|
||||
static size_t strlcpy(char *d, const char *s, size_t n) {
|
||||
size_t len = strlen(s); if (n) { size_t k = len < n-1 ? len : n-1;
|
||||
memcpy(d, s, k); d[k] = 0; } return len;
|
||||
}
|
||||
static esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *c)
|
||||
{ (void)p; assert(!lock_depth); *c = principal_current; return ESP_OK; }
|
||||
static const char *esp_err_to_name(int e) { (void)e; return "fake"; }
|
||||
static TaskHandle_t xTaskGetCurrentTaskHandle(void) { return current_task; }
|
||||
static unsigned xTaskGetTickCount(void) { return ticks; }
|
||||
static void vTaskDelay(unsigned n) { assert(!lock_depth); ticks += n; if (delay_hook) delay_hook(); }
|
||||
static int xTaskCreate(void (*f)(void *), const char *n, unsigned s, void *c,
|
||||
unsigned p, TaskHandle_t *t)
|
||||
{ (void)f; (void)n; (void)s; (void)c; (void)p; *t = (void *)1; return pdPASS; }
|
||||
static void vTaskDelete(TaskHandle_t t) { (void)t; }
|
||||
static void xTaskNotifyGive(TaskHandle_t t) { (void)t; }
|
||||
static unsigned ulTaskNotifyTake(int b, unsigned t) { (void)b; (void)t; return 1; }
|
||||
static QueueHandle_t xQueueCreateStatic(unsigned n, size_t s, uint8_t *b, StaticQueue_t *q)
|
||||
{ (void)b; q->size = s; q->capacity = n; assert(n*s <= sizeof(q->bytes)); return q; }
|
||||
static int xQueueSend(QueueHandle_t q, const void *p, unsigned t)
|
||||
{ queue_send_wait=t; if (queue_full || q->count==q->capacity) return 0;
|
||||
memcpy(q->bytes+q->count*q->size,p,q->size); ++q->count; return 1; }
|
||||
static int xQueueReceive(QueueHandle_t q, void *p, unsigned t)
|
||||
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size);
|
||||
--q->count; memmove(q->bytes,q->bytes+q->size,q->count*q->size); return 1; }
|
||||
static SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *s) { return s; }
|
||||
static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t)
|
||||
{ assert(!lock_depth); if (t && !*s) { ticks+=t; if (prompt_hook) prompt_hook(); }
|
||||
int r=*s; *s=0; return r; }
|
||||
static int xSemaphoreGive(SemaphoreHandle_t s) { *s=1; return 1; }
|
||||
static void linenoiseSetMaxLineLen(unsigned n) { (void)n; }
|
||||
static char *linenoise(const char *p) { (void)p; return NULL; }
|
||||
static int linenoiseHistoryAdd(const char *p) { (void)p; return 1; }
|
||||
static void linenoiseFree(char *p) { (void)p; }
|
||||
static bool console_completion_expand(const char *s, char *d, size_t n)
|
||||
{ (void)s; (void)d; (void)n; if (completion_hook) completion_hook(); return false; }
|
||||
static bool console_completion_format_matches(const char *s, char *d, size_t n, size_t *len)
|
||||
{ (void)s; *len=strlcpy(d,"help\r\n",n); return true; }
|
||||
size_t esp_console_split_argv(char *s, char **v, size_t n);
|
||||
static esp_err_t esp_console_run(const char *s, int *r)
|
||||
{ (void)s; ++runs; if (command_hook) command_hook(); *r=0; return ESP_OK; }
|
||||
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) { (void)c; return 0; }
|
||||
static FILE *funopen(void *c, void *r, int (*w)(void *,const char *,int), void *s, void *f)
|
||||
{ (void)c; (void)r; (void)w; (void)s; (void)f; return tmpfile(); }
|
||||
@@ -1,115 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Actual canonical stop/reboot handlers with deterministic side-effect doubles."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
def function(path, name):
|
||||
source = path.read_text()
|
||||
start = source.index('static int ' + name + '(')
|
||||
return source[start:source.index('\n}', start) + 2]
|
||||
|
||||
header = '\n'.join(line for line in (ROOT / 'src/admin_ssh_console.h').read_text().splitlines()
|
||||
if not line.startswith(('#include', '#pragma once')))
|
||||
prelude = r'''
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK=0, ESP_FAIL=-1, ESP_ERR_TIMEOUT=7 };
|
||||
typedef struct { int unused; } user_principal_t;
|
||||
'''
|
||||
fakes = r'''
|
||||
static bool remote, web;
|
||||
static unsigned stops, reboots, scheduled, waits, rotations, usages;
|
||||
static esp_err_t schedule_result, stop_result;
|
||||
static admin_ssh_deferred_action_type_t last_action;
|
||||
bool admin_ssh_console_dispatch_is_remote(void) { return remote; }
|
||||
bool admin_ssh_console_dispatch_is_web(void) { return remote && web; }
|
||||
esp_err_t admin_ssh_console_dispatch_defer(admin_ssh_deferred_action_type_t action, uint32_t argument) {
|
||||
assert(remote && !argument); ++scheduled; last_action=action; return schedule_result;
|
||||
}
|
||||
static const char *esp_err_to_name(esp_err_t error) { (void)error; return "fake"; }
|
||||
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 void esp_restart(void) { ++reboots; }
|
||||
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
|
||||
#define pdMS_TO_TICKS(ms) (ms)
|
||||
static void print_usage(void) { ++usages; }
|
||||
static int web_diagnostics_command(const char *action) { assert(!strcmp(action, "show")); return 0; }
|
||||
static int show_status(void) { assert(false); return 1; }
|
||||
static int show_counters(void) { assert(false); return 1; }
|
||||
static int show_certificate(void) { assert(false); return 1; }
|
||||
static int rotate_certificate(void) { ++rotations; return 0; }
|
||||
static int reset_material(void) { assert(false); return 1; }
|
||||
static bool force_is_present(int argc, char **argv, int expected) {
|
||||
return argc == expected && !strcmp(argv[expected - 1], "--force");
|
||||
}
|
||||
'''
|
||||
tests = r'''
|
||||
int main(void) {
|
||||
char *removed[]={"web", "credentials", "show", "--force"};
|
||||
for (unsigned origin=0; origin<3; ++origin) {
|
||||
remote=origin!=0; web=origin==2;
|
||||
removed[2]="show";
|
||||
assert(command_web(2,removed)==1);
|
||||
assert(command_web(3,removed)==1);
|
||||
removed[2]="rotate";
|
||||
assert(command_web(3,removed)==1);
|
||||
assert(command_web(4,removed)==1);
|
||||
}
|
||||
assert(usages==12 && !stops && !scheduled && !rotations);
|
||||
remote=web=false;
|
||||
char *diagnostics[]={"web", "diagnostics", "show"};
|
||||
assert(command_web(3, diagnostics)==0 && !stops && !scheduled);
|
||||
char *stop[]={"web", "stop"};
|
||||
remote=web=true;
|
||||
assert(command_web(2,stop)==0 && scheduled==1 && !stops && last_action==ADMIN_CONSOLE_DEFER_WEB_STOP);
|
||||
schedule_result=ESP_ERR_TIMEOUT;
|
||||
assert(command_web(2,stop)==1 && scheduled==2 && !stops);
|
||||
schedule_result=ESP_OK;
|
||||
web=false; /* SSH preserves its synchronous HTTPS path. */
|
||||
assert(command_web(2,stop)==0 && stops==1 && scheduled==2);
|
||||
remote=false;
|
||||
assert(command_web(2,stop)==0 && stops==2 && scheduled==2);
|
||||
stop_result=ESP_FAIL;
|
||||
assert(command_web(2,stop)==1 && stops==3);
|
||||
remote=true;
|
||||
assert(command_reboot(1,NULL)==0 && scheduled==3 && !reboots && last_action==ADMIN_SSH_DEFER_REBOOT);
|
||||
web=true;
|
||||
assert(command_reboot(1,NULL)==0 && scheduled==4 && !reboots && last_action==ADMIN_SSH_DEFER_REBOOT);
|
||||
schedule_result=ESP_FAIL;
|
||||
assert(command_reboot(1,NULL)==1 && scheduled==5 && !reboots);
|
||||
assert(command_reboot(2,NULL)==1 && scheduled==5 && !reboots);
|
||||
remote=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;
|
||||
assert(command_web(4,rotate)==0 && scheduled==6 && !rotations &&
|
||||
last_action==ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE);
|
||||
schedule_result=ESP_ERR_TIMEOUT;
|
||||
assert(command_web(4,rotate)==1 && scheduled==7 && !rotations);
|
||||
assert(command_web(3,rotate)==1 && scheduled==7 && !rotations);
|
||||
assert(command_web(5,rotate)==1 && scheduled==7 && !rotations);
|
||||
web=false;
|
||||
assert(command_web(4,rotate)==0 && rotations==1 && scheduled==7);
|
||||
remote=false;
|
||||
assert(command_web(4,rotate)==0 && rotations==2 && scheduled==7);
|
||||
puts("PASS: canonical WEB stop/certificate deferred, exact force required, SSH/UART unchanged, reboot and queue failure isolation");
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix='console-lifecycle-') as directory:
|
||||
tmp = Path(directory)
|
||||
(tmp / 'test.c').write_text(prelude + header + fakes +
|
||||
function(ROOT / 'src/web_console.c', 'command_web') +
|
||||
function(ROOT / 'src/system_console.c', 'command_reboot') + tests)
|
||||
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror',
|
||||
str(tmp / 'test.c'), '-o', str(tmp / 'test')], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / 'test')], check=True, timeout=10)
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile actual console implementation with deterministic host RTOS/IO fakes.
|
||||
|
||||
No target scheduler, socket library, or hardware execution is claimed.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
|
||||
parser = str(IDF / "components/console/split_argv.c")
|
||||
source = (ROOT / "src/admin_ssh_console.c").read_text()
|
||||
header = (ROOT / "src/admin_ssh_console.h").read_text()
|
||||
def strip_includes(text):
|
||||
return "\n".join(line for line in text.splitlines()
|
||||
if not line.startswith(("#include", "#pragma once")))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="admin-console-boundary-") as directory:
|
||||
path = Path(directory)
|
||||
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
|
||||
+ strip_includes(header) + "\n" + strip_includes(source)
|
||||
+ (ROOT / "tests/admin_console_boundary/test.c").read_text())
|
||||
(path / "test.c").write_text(unit)
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
"-g", str(path / "test.c"), parser,
|
||||
"-o", str(path / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(path / "test")], check=True, timeout=10)
|
||||
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
|
||||
+ strip_includes(header) + "\n" + strip_includes(source)
|
||||
+ (ROOT / "tests/admin_console_boundary/certificate.c").read_text())
|
||||
(path / "certificate.c").write_text(unit)
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
"-g", str(path / "certificate.c"), parser,
|
||||
"-o", str(path / "certificate")], check=True, timeout=30)
|
||||
subprocess.run([str(path / "certificate")], check=True, timeout=10)
|
||||
ssh = (ROOT / "src/ssh_transport.c").read_text()
|
||||
adapter = ssh[ssh.index("static admin_ssh_console_token_t admin_console_token("):
|
||||
ssh.index("static void *ssh_malloc(")]
|
||||
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
|
||||
+ strip_includes(header) + "\n" + strip_includes(source)
|
||||
+ (ROOT / "tests/admin_console_boundary/adapter.c").read_text()
|
||||
+ adapter + "\nint main(void) { test_adapter(); }\n")
|
||||
(path / "adapter.c").write_text(unit)
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
"-Wno-unused-variable", str(path / "adapter.c"), parser,
|
||||
"-o", str(path / "adapter")], check=True, timeout=30)
|
||||
subprocess.run([str(path / "adapter")], check=True, timeout=10)
|
||||
@@ -1,368 +0,0 @@
|
||||
|
||||
static admin_ssh_console_token_t a = { .slot_index=0, .session_id=7, .slot_generation=1 };
|
||||
static admin_ssh_console_token_t b = { .slot_index=1, .session_id=7, .slot_generation=1, .transport=1 };
|
||||
static user_principal_t admin = { .role=USER_ROLE_ADMIN };
|
||||
static bool live[2] = {true, true};
|
||||
static void (*current_hook)(void);
|
||||
static bool is_current(const admin_ssh_console_token_t *t, const user_principal_t *p)
|
||||
{
|
||||
assert(!lock_depth && p->role==USER_ROLE_ADMIN);
|
||||
if (current_hook) current_hook();
|
||||
return live[t->slot_index];
|
||||
}
|
||||
static bool drained(const admin_ssh_console_token_t *t)
|
||||
{ assert(!lock_depth); assert(t->session_id==7); return owner_drained; }
|
||||
static esp_err_t perform(const admin_ssh_console_token_t *t,
|
||||
admin_ssh_deferred_action_type_t action, uint32_t arg)
|
||||
{ (void)t; (void)arg; assert(!lock_depth); assert(action==ADMIN_CONSOLE_DEFER_SELF_CLOSE); ++actions; return ESP_OK; }
|
||||
static const admin_console_owner_t owner = {
|
||||
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE, .drained=drained, .perform=perform,
|
||||
.is_current=is_current,
|
||||
};
|
||||
static void pump(void (*task)(void *)) { if (!setjmp(loop_done)) task(NULL); }
|
||||
static void feed(const admin_ssh_console_token_t *t, const char *s)
|
||||
{ size_t n=0; assert(admin_ssh_console_feed_input(t,(const uint8_t *)s,strlen(s),&n)); assert(n==strlen(s)); }
|
||||
static void clear_output(const admin_ssh_console_token_t *t)
|
||||
{ uint8_t out[4096]; size_t n; assert(admin_ssh_console_read_output(t,out,sizeof(out),&n)==ESP_OK); }
|
||||
static void competing_completion(void)
|
||||
{
|
||||
size_t n=99;
|
||||
assert(!admin_ssh_console_feed_input(&b,(const uint8_t *)"\t",1,&n));
|
||||
assert(n==0 && s_completion_busy);
|
||||
}
|
||||
static void reopen_during_completion(void)
|
||||
{
|
||||
assert(!lock_depth && s_completion_busy);
|
||||
admin_ssh_console_close(&a);
|
||||
++a.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
clear_output(&a);
|
||||
}
|
||||
static void hidden_reply(void) { feed(&a,"secret\r"); }
|
||||
static void cancel_reply(void) { feed(&a,"secret\x03"); }
|
||||
static void close_prompt(void) { admin_ssh_console_close(&a); }
|
||||
static void close_during_delay(void) { if (ticks>=200) admin_ssh_console_close(&a); }
|
||||
static void close_during_command(void)
|
||||
{
|
||||
assert(s_sessions[0].executing);
|
||||
admin_ssh_console_close(&a);
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
}
|
||||
static void setup_dispatch(void)
|
||||
{ s_dispatch_remote=true; s_dispatch_token=a; s_dispatch_principal=admin;
|
||||
s_sessions[0].executing=true; s_sessions[0].command_pending=true; }
|
||||
static void reopen_during_current(void)
|
||||
{
|
||||
current_hook=NULL;
|
||||
admin_ssh_console_close(&a);
|
||||
++a.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
live[0]=false; /* Failed old validation must not close the replacement. */
|
||||
}
|
||||
static void revoked_reply(void) { hidden_reply(); live[0]=false; }
|
||||
static unsigned checks;
|
||||
static void stale_at_execution(void) { if (++checks==2) live[0]=false; }
|
||||
static void account_revoked_reply(void) { hidden_reply(); principal_current=false; }
|
||||
static void closed_reply(void) { hidden_reply(); close_prompt(); }
|
||||
static unsigned waits;
|
||||
static void unanswered(void)
|
||||
{
|
||||
++waits;
|
||||
if (waits==1) xSemaphoreGive(s_prompt_done); /* Stale wake while still waiting. */
|
||||
if (waits==3) live[0]=false; /* No close notification. */
|
||||
}
|
||||
static void prompt_command(void)
|
||||
{
|
||||
uint8_t answer[32]; size_t n=99;
|
||||
assert(admin_ssh_console_dispatch_read_input("Password: ",answer,sizeof(answer),true,&n)==ESP_ERR_NOT_FOUND);
|
||||
assert(n==0);
|
||||
for (size_t i=0;i<sizeof(answer);++i) assert(!answer[i]);
|
||||
assert(!s_sessions[0].active && !s_sessions[0].prompt_length);
|
||||
for (size_t i=0;i<sizeof(s_sessions[0].prompt_input);++i) assert(!s_sessions[0].prompt_input[i]);
|
||||
}
|
||||
static void test_currentness(void)
|
||||
{
|
||||
++a.slot_generation;
|
||||
admin_console_owner_t missing=owner; missing.is_current=NULL;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&missing)==ESP_ERR_INVALID_ARG);
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
unsigned before=runs;
|
||||
feed(&a,"owner stale\r"); live[0]=false; pump(worker_task);
|
||||
assert(runs==before && !s_sessions[0].active && principal_current);
|
||||
feed(&b,"isolated\r"); pump(worker_task); assert(runs==++before);
|
||||
live[0]=true; ++a.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
feed(&a,"reuse\r"); current_hook=reopen_during_current; pump(worker_task);
|
||||
assert(runs==before && token_matches(&s_sessions[0],&a));
|
||||
live[0]=true;
|
||||
feed(&a,"last check\r"); checks=0; current_hook=stale_at_execution;
|
||||
pump(worker_task); current_hook=NULL;
|
||||
assert(checks==2 && runs==before && !s_sessions[0].active);
|
||||
live[0]=true; ++a.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
void (*hooks[])(void)={revoked_reply,account_revoked_reply,closed_reply,unanswered};
|
||||
for (size_t i=0;i<sizeof(hooks)/sizeof(hooks[0]);++i) {
|
||||
clear_output(&a); ticks=0; waits=0;
|
||||
feed(&a,"prompt\r"); prompt_hook=hooks[i]; command_hook=prompt_command;
|
||||
pump(worker_task); prompt_hook=NULL; command_hook=NULL;
|
||||
assert(runs==++before);
|
||||
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
|
||||
if (i==3) assert(waits==3 && ticks==750);
|
||||
/* Dispatcher recovered, so trusted UART0 work still runs. */
|
||||
admin_request_t uart={.origin=ADMIN_REQUEST_UART0};
|
||||
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==++before);
|
||||
live[0]=true; principal_current=true; ++a.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
}
|
||||
setup_dispatch(); clear_output(&a); live[0]=false;
|
||||
uint8_t answer[32]; size_t n=99;
|
||||
assert(admin_ssh_console_dispatch_read_input("Not published",answer,sizeof(answer),true,&n)==ESP_ERR_NOT_FOUND);
|
||||
assert(!n && !s_sessions[0].output_length);
|
||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); live[0]=true;
|
||||
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
clear_output(&a);
|
||||
s_sessions[0].output_start=4094;
|
||||
assert(worker_write(&a,"abcdef"));
|
||||
uint8_t out[8];
|
||||
assert(admin_ssh_console_read_output(&a,out,3,&n)==ESP_OK && n==3 && !memcmp(out,"abc",3));
|
||||
assert(!s_sessions[0].output[4094] && !s_sessions[0].output[4095] && !s_sessions[0].output[0]);
|
||||
assert(!memcmp(s_sessions[0].output+1,"def",3));
|
||||
assert(admin_ssh_console_read_output(&a,out,sizeof(out),&n)==ESP_OK && n==3 && !memcmp(out,"def",3));
|
||||
for (size_t i=0;i<sizeof(s_sessions[0].output);++i) assert(!s_sessions[0].output[i]);
|
||||
admin_ssh_console_close(&a);
|
||||
puts("PASS: owner stale/account current isolation, callback close/reuse, revoked submitted prompts, periodic unanswered invalidation/stale wake, UART recovery, consumed output wiping");
|
||||
}
|
||||
static void test_dispatch_currentness(void)
|
||||
{
|
||||
++a.slot_generation; live[0]=true; principal_current=true;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
setup_dispatch();
|
||||
assert(admin_ssh_console_dispatch_is_current());
|
||||
current_task=(void *)2;
|
||||
assert(!admin_ssh_console_dispatch_is_current());
|
||||
current_task=s_task;
|
||||
live[0]=false;
|
||||
assert(!admin_ssh_console_dispatch_is_current());
|
||||
assert(!s_sessions[0].active);
|
||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0]));
|
||||
++a.slot_generation; live[0]=true;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
setup_dispatch(); principal_current=false;
|
||||
assert(!admin_ssh_console_dispatch_is_current());
|
||||
assert(!s_sessions[0].active);
|
||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); principal_current=true;
|
||||
++a.slot_generation;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
setup_dispatch(); s_dispatch_token.slot_generation--;
|
||||
assert(!admin_ssh_console_dispatch_is_current());
|
||||
assert(s_sessions[0].active); /* Stale dispatch cannot close replacement. */
|
||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0]));
|
||||
s_dispatch_remote=false;
|
||||
assert(admin_ssh_console_dispatch_is_current()); /* Trusted UART0. */
|
||||
puts("PASS: handler currentness API rejects wrong task, stale owner/account/token; preserves replacement and UART0");
|
||||
}
|
||||
|
||||
static void test_shared_admission(void)
|
||||
{
|
||||
admin_ssh_console_token_t web={.slot_index=255, .session_id=7,
|
||||
.slot_generation=42, .transport=ADMIN_CONSOLE_TRANSPORT_WEB};
|
||||
admin_ssh_console_token_t ssh=web; ssh.transport=ADMIN_CONSOLE_TRANSPORT_SSH;
|
||||
static const admin_console_owner_t second_owner={
|
||||
.supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE,
|
||||
.drained=drained, .perform=perform, .is_current=is_current,
|
||||
};
|
||||
assert(admin_ssh_console_open_available(&web,&admin,&owner)==ESP_OK);
|
||||
assert(web.slot_index==0 && web.session_id==7 && web.slot_generation==42 &&
|
||||
web.transport==ADMIN_CONSOLE_TRANSPORT_WEB);
|
||||
assert(admin_ssh_console_open_available(&ssh,&admin,&second_owner)==ESP_OK);
|
||||
assert(ssh.slot_index==1 && ssh.session_id==7 && ssh.slot_generation==42 && !ssh.transport);
|
||||
assert(s_sessions[0].owner==&owner && s_sessions[1].owner==&second_owner);
|
||||
unsigned before=runs;
|
||||
clear_output(&web);
|
||||
feed(&web,"\"web\" \"reset\" --force\r"); pump(worker_task);
|
||||
assert(runs==before && !s_control_queue->count);
|
||||
uint8_t diagnostic[512]={0}; size_t received=0;
|
||||
assert(admin_ssh_console_read_output(&web,diagnostic,sizeof(diagnostic)-1,&received)==ESP_OK);
|
||||
assert(strstr((char *)diagnostic,"unavailable from the web console"));
|
||||
feed(&web,"\"user\" \"password\" admin --generate\r"); pump(worker_task);
|
||||
assert(runs==before && !s_control_queue->count);
|
||||
feed(&web,"\"web\" \"status\"\r"); pump(worker_task); assert(runs==before+1);
|
||||
/* UART0 bypasses remote policy and remains the recovery path. */
|
||||
admin_request_t uart={.origin=ADMIN_REQUEST_UART0, .line="user recover --force"};
|
||||
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==before+2);
|
||||
runs=before;
|
||||
admin_ssh_console_token_t full=web; full.slot_index=99;
|
||||
assert(admin_ssh_console_open_available(&full,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
assert(full.slot_index==99);
|
||||
admin_ssh_console_token_t stale=web;
|
||||
s_sessions[0].executing=true;
|
||||
admin_ssh_console_close(&web);
|
||||
assert(admin_ssh_console_open_available(&full,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
assert(full.slot_index==99); /* Inactive executing slots still consume capacity. */
|
||||
s_sessions[0].executing=false;
|
||||
++web.slot_generation;
|
||||
assert(admin_ssh_console_open_available(&web,&admin,&owner)==ESP_OK);
|
||||
admin_ssh_console_close(&stale);
|
||||
assert(!admin_ssh_console_accepts_input(&stale) && admin_ssh_console_accepts_input(&web));
|
||||
assert(admin_ssh_console_accepts_input(&ssh));
|
||||
admin_ssh_console_close(&web); admin_ssh_console_close(&ssh);
|
||||
puts("PASS: two-owner shared admission, colliding preferred indices/IDs, full capacity, executing reservation and stale tokens");
|
||||
}
|
||||
int main(void)
|
||||
{
|
||||
assert(admin_ssh_console_init()==ESP_OK);
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
|
||||
test_shared_admission();
|
||||
principal_current=false;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
principal_current=true;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
assert(admin_ssh_console_open_owned(&b,&admin,&owner)==ESP_OK);
|
||||
admin_ssh_console_token_t other=a; other.transport=1;
|
||||
assert(admin_ssh_console_open_owned(&other,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
admin_ssh_console_close(&other);
|
||||
assert(!admin_ssh_console_accepts_input(&other));
|
||||
other=a; ++other.slot_generation; admin_ssh_console_close(&other);
|
||||
assert(admin_ssh_console_accepts_input(&a));
|
||||
clear_output(&a); clear_output(&b);
|
||||
completion_hook=competing_completion; feed(&a,"\t"); completion_hook=NULL;
|
||||
assert(!s_completion_busy && s_sessions[0].output_length && !s_sessions[1].output_length);
|
||||
/* Keep the in-flight token unchanged; only the reopened session advances. */
|
||||
admin_ssh_console_token_t completing=a;
|
||||
completion_hook=reopen_during_completion; feed(&completing,"\t"); completion_hook=NULL;
|
||||
assert(a.slot_generation==completing.slot_generation+1);
|
||||
assert(!s_completion_busy && !s_sessions[0].output_length && !s_sessions[0].input_length);
|
||||
assert(!admin_ssh_console_accepts_input(&completing));
|
||||
feed(&a,"\t"); assert(s_sessions[0].output_length); clear_output(&a);
|
||||
feed(&a,"help\r"); assert(runs==0); pump(worker_task); assert(runs==1);
|
||||
feed(&a,"\x1b[A"); assert(!strcmp((char *)s_sessions[0].input,"help"));
|
||||
feed(&a,"\x03");
|
||||
feed(&a,"stale\r"); admin_ssh_console_close(&a);
|
||||
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
pump(worker_task); assert(runs==1);
|
||||
feed(&a,"revoked\r"); principal_current=false; pump(worker_task); assert(runs==1); principal_current=true;
|
||||
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
admin_request_t uart={ .origin=ADMIN_REQUEST_UART0 };
|
||||
assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==2);
|
||||
feed(&a,"close\r"); command_hook=close_during_command; pump(worker_task); command_hook=NULL;
|
||||
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)));
|
||||
++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
setup_dispatch(); clear_output(&a); prompt_hook=hidden_reply;
|
||||
uint8_t secret[32]; size_t n;
|
||||
uint8_t history_before[sizeof(s_sessions[0].history)];
|
||||
memcpy(history_before,s_sessions[0].history,sizeof(history_before));
|
||||
assert(admin_ssh_console_dispatch_is_current());
|
||||
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_OK);
|
||||
assert(!memcmp(history_before,s_sessions[0].history,sizeof(history_before)));
|
||||
for (size_t i=0;i<sizeof(s_sessions[0].prompt_input);++i) assert(!s_sessions[0].prompt_input[i]);
|
||||
assert(n==6 && !memcmp(secret,"secret",6));
|
||||
assert(s_sessions[0].output_length==strlen("Password: \r\n"));
|
||||
clear_output(&a);
|
||||
assert(admin_ssh_console_dispatch_read_input("Visible: ",secret,sizeof(secret),false,&n)==ESP_OK);
|
||||
assert(s_sessions[0].output_length==strlen("Visible: secret\r\n"));
|
||||
prompt_hook=cancel_reply;
|
||||
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_ERR_INVALID_STATE);
|
||||
assert(n==0 && secret[0]==0 && s_sessions[0].prompt_input[0]==0);
|
||||
prompt_hook=close_prompt;
|
||||
assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_ERR_NOT_FOUND);
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE);
|
||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); prompt_hook=NULL;
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
setup_dispatch(); clear_output(&a);
|
||||
assert(!admin_ssh_console_dispatch_is_web());
|
||||
s_dispatch_token.transport = ADMIN_CONSOLE_TRANSPORT_WEB;
|
||||
assert(admin_ssh_console_dispatch_is_web());
|
||||
s_dispatch_remote = false; assert(!admin_ssh_console_dispatch_is_web());
|
||||
s_dispatch_remote = true; s_dispatch_token = a;
|
||||
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_STOP,0)==ESP_ERR_NOT_SUPPORTED);
|
||||
assert(admin_ssh_console_dispatch_defer((admin_ssh_deferred_action_type_t)32,0)==ESP_ERR_NOT_SUPPORTED);
|
||||
assert(admin_ssh_console_dispatch_defer(ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_SUPPORTED);
|
||||
assert(!s_sessions[0].deferred_action_pending);
|
||||
queue_full=true;
|
||||
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_ERR_TIMEOUT);
|
||||
assert(!s_sessions[0].deferred_action_pending); queue_full=false;
|
||||
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
|
||||
admin_ssh_console_session_snapshot_t pending;
|
||||
assert(admin_ssh_console_get_session_snapshot(&a, &pending)==ESP_OK && pending.deferred_action_pending);
|
||||
assert(!admin_ssh_console_feed_input(&a,(const uint8_t *)"x",1,&n) && n==0);
|
||||
s_sessions[0].command_pending=false; owner_drained=false; ticks=0;
|
||||
pump(control_task); assert(ticks==10000 && actions==0);
|
||||
clear_output(&a); owner_drained=true;
|
||||
/* The fake esp_console_run does not invoke registered command callbacks. */
|
||||
assert(command_exit(1,NULL)==0);
|
||||
assert(s_control_queue->count==1);
|
||||
admin_control_request_t exit_request;
|
||||
memcpy(&exit_request,s_control_queue->bytes,sizeof(exit_request));
|
||||
assert(exit_request.action==ADMIN_CONSOLE_DEFER_SELF_CLOSE);
|
||||
assert(token_matches(&s_sessions[0],&exit_request.token));
|
||||
assert(exit_request.owner==&owner && exit_request.argument==a.session_id);
|
||||
ticks=0; pump(control_task); assert(ticks==200 && actions==1);
|
||||
clear_output(&a);
|
||||
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
|
||||
ticks=0; delay_hook=close_during_delay; pump(control_task); delay_hook=NULL; assert(actions==1);
|
||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0]));
|
||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||
s_sessions[0].output_length=4096; ticks=0;
|
||||
assert(ssh_output_write(&a,"x",1)==-1 && errno==EAGAIN && ticks==5000);
|
||||
clear_output(&a); s_dispatch_output_previous_cr=false;
|
||||
assert(ssh_output_write(&a,"a\nb\r\n",6)==6);
|
||||
assert(s_sessions[0].output_length==7);
|
||||
admin_ssh_console_close(&a);
|
||||
assert(ssh_output_write(&a,"x",1)==-1 && errno==EPIPE);
|
||||
assert(!lock_depth);
|
||||
test_currentness();
|
||||
test_dispatch_currentness();
|
||||
unsigned before_serial = runs;
|
||||
assert(admin_ssh_console_submit_serial_settings(0) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = false;
|
||||
assert(admin_ssh_console_submit_serial_settings(1) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = true; queue_full = true;
|
||||
assert(admin_ssh_console_submit_serial_settings(1) == ESP_ERR_TIMEOUT);
|
||||
queue_full = false;
|
||||
admin_request_t preceding_uart = {.origin = ADMIN_REQUEST_UART0};
|
||||
assert(xQueueSend(s_request_queue, &preceding_uart, 0));
|
||||
queue_send_wait = portMAX_DELAY;
|
||||
assert(admin_ssh_console_submit_serial_settings(17) == ESP_OK && !serial_settings_executed);
|
||||
assert(queue_send_wait == 0);
|
||||
admin_request_t typed;
|
||||
memcpy(&typed, s_request_queue->bytes + sizeof(typed), sizeof(typed));
|
||||
assert(typed.origin == ADMIN_REQUEST_SERIAL_SETTINGS && typed.serial_settings_id == 17);
|
||||
assert(s_request_queue->capacity == 4 && sizeof(typed.line) == 257);
|
||||
pump(worker_task);
|
||||
assert(serial_settings_executed == 17 && runs == before_serial + 1 && !s_request_queue->count);
|
||||
assert(serial_settings_preceding_runs == before_serial + 1);
|
||||
for (unsigned i = 0; i < s_request_queue->capacity; ++i)
|
||||
assert(xQueueSend(s_request_queue, &preceding_uart, 0));
|
||||
queue_send_wait = portMAX_DELAY;
|
||||
assert(admin_ssh_console_submit_serial_settings(18) == ESP_ERR_TIMEOUT);
|
||||
assert(queue_send_wait == 0 && s_request_queue->count == 4 && serial_settings_executed == 17);
|
||||
pump(worker_task);
|
||||
assert(runs == before_serial + 5 && serial_settings_executed == 17 && !s_request_queue->count);
|
||||
puts("PASS: typed Serial admission uses zero wait on success/full queue, preserves all four queued UART requests and FIFO execution, no command-string dispatch");
|
||||
assert(admin_ssh_console_submit_account_settings(0) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = false;
|
||||
assert(admin_ssh_console_submit_account_settings(1) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = true; queue_full = true;
|
||||
assert(admin_ssh_console_submit_account_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
|
||||
queue_full = false;
|
||||
assert(admin_ssh_console_submit_serial_settings(21) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_account_settings(22) == ESP_OK && queue_send_wait == 0);
|
||||
pump(worker_task);
|
||||
assert(serial_settings_executed == 21 && account_settings_executed == 22 && runs == before_serial + 5);
|
||||
puts("PASS: typed Accounts uses same bounded queue with nonblocking admission and isolated dispatcher routing");
|
||||
assert(admin_ssh_console_submit_network_settings(0) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = false;
|
||||
assert(admin_ssh_console_submit_network_settings(1) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = true; queue_full = true;
|
||||
assert(admin_ssh_console_submit_network_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
|
||||
queue_full = false;
|
||||
assert(admin_ssh_console_submit_serial_settings(31) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_network_settings(32) == ESP_OK && queue_send_wait == 0);
|
||||
assert(admin_ssh_console_submit_account_settings(33) == ESP_OK);
|
||||
pump(worker_task);
|
||||
assert(serial_settings_executed == 31 && network_settings_executed == 32 && account_settings_executed == 33);
|
||||
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
|
||||
puts("PASS: typed Network queues only an ID, shares unchanged queue, executes outside lock without command runner");
|
||||
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Focused policy test: actual project helper plus installed IDF argv parser.
|
||||
|
||||
Requires Python 3, cc and IDF_PATH (defaults to PlatformIO's installed SDK).
|
||||
Does not run FreeRTOS dispatch, SSH I/O or target hardware.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IDF = Path(os.environ.get("IDF_PATH", str(Path.home() / ".platformio/packages/framework-espidf")))
|
||||
source = (ROOT / "src/admin_ssh_console.c").read_text()
|
||||
start = source.index("bool admin_ssh_console_web_user_command_allowed(")
|
||||
policy = source[start:source.index("\n}", start) + 2]
|
||||
start = source.index("static bool remote_command_allowed(")
|
||||
helper = source[start:source.index("\n}", start) + 2]
|
||||
prelude = r'''
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||
#define ADMIN_SSH_CONSOLE_MAX_ARGUMENTS 10U
|
||||
#define ADMIN_CONSOLE_TRANSPORT_WEB 1U
|
||||
#define USER_DATABASE_USERNAME_CAPACITY 16U
|
||||
#define USER_ROLE_ADMIN 2
|
||||
typedef struct { int role; size_t username_length; char username[17]; } user_principal_t;
|
||||
typedef struct {
|
||||
user_principal_t principal;
|
||||
struct { uint8_t transport; } token;
|
||||
char line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
} admin_request_t;
|
||||
size_t esp_console_split_argv(char *, char **, size_t);
|
||||
static void secure_wipe(void *p, size_t n) {
|
||||
volatile unsigned char *bytes = p;
|
||||
while (n--) *bytes++ = 0;
|
||||
}
|
||||
'''
|
||||
cases = r'''
|
||||
int main(void) {
|
||||
const struct { const char *line; bool allowed; } cases[] = {
|
||||
{"", true}, {" ", true}, {" ", true},
|
||||
{"memory", true}, {"user", true}, {"user list", true},
|
||||
{"user show bootstrap", true}, {"exit", true},
|
||||
/* Removed verbs reach the canonical handler, not a bootstrap policy. */
|
||||
{"user bootstrap", true}, {"user bootstrap extra", true},
|
||||
{"user recover", false}, {"user recover --force", false},
|
||||
{" user recover --force ", false},
|
||||
{"\"user\" \"bootstrap\"", true},
|
||||
{"\"user\" \"recover\" --force", false},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); ++i) {
|
||||
admin_request_t request = {0};
|
||||
strcpy(request.line, cases[i].line);
|
||||
assert(remote_command_allowed(&request) == cases[i].allowed);
|
||||
assert(!strcmp(request.line, cases[i].line));
|
||||
}
|
||||
const char *web_allowed[] = {
|
||||
"", " ", "help", "memory", "exit", "user", "user status", "user list",
|
||||
"user show admin", "\"user\" \"show\" \"bootstrap\"",
|
||||
"web status", "web stop", "reboot", "\"reboot\"", "\"web\" \"stop\"",
|
||||
"wifi status", "mdns status", "\"web\" \"status\"",
|
||||
"user add other user", "user add other admin", "user password other",
|
||||
"user delete other --force", "user role other user --force",
|
||||
"user role other admin --force", "\"user\" \"password\" \"other\"",
|
||||
"web certificate rotate --force",
|
||||
" \"web\" \"certificate\" \"rotate\" \"--force\" ",
|
||||
"ssh status", "ssh sessions", "ssh counters", "ssh host-key info", "ssh start",
|
||||
};
|
||||
const char *web_denied[] = {
|
||||
"web", "web help", "web start", "web stop extra", "web counters", "web clear-counters",
|
||||
"web diagnostics enable", "web diagnostics disable", "web diagnostics show", "web diagnostics clear",
|
||||
"web credentials show", "web credentials rotate --force", "web certificate info",
|
||||
"web certificate rotate", "web certificate rotate --force extra",
|
||||
"web certificate rotate --force --force", "web certificate rotate --Force",
|
||||
"web certificate rotate --forcex", "web certificate --force rotate",
|
||||
"\"web\" \"certificate\" \"rotate\" \"--force extra\"",
|
||||
"web reset --force", "web status extra",
|
||||
"wifi", "wifi profiles", "wifi scan", "wifi start", "wifi stop", "wifi save",
|
||||
"wifi load", "wifi defaults", "wifi reset", "wifi ping example.org",
|
||||
"mdns", "mdns suffix test", "mdns save", "mdns load", "mdns defaults", "mdns reset",
|
||||
"reboot --force", "user bootstrap", "user recover --force",
|
||||
"user add other admin --generate", "user delete other",
|
||||
"user role other user", "user password admin --generate",
|
||||
"user password admin", "user password other --generate",
|
||||
"user delete admin --force", "user role admin admin --force",
|
||||
"user role admin user --force", "user add admin admin",
|
||||
"\"user\" \"password\" \"admin\"", "user password other extra",
|
||||
"user add other invalid", "user add other user extra",
|
||||
"user delete other --force extra", "user role other admin --force extra",
|
||||
"user key add admin", "user key clear admin --force",
|
||||
"user key delete admin 0 --force", "user list extra", "user show admin extra",
|
||||
"ssh stop", "ssh disconnect 7", "ssh host-key rotate --force", "ssh reset --force",
|
||||
" \"user\" \"password\" \"admin\" \"--generate\"",
|
||||
"\"web\" \"credentials\" \"show\"", "\"wifi\" \"stop\"",
|
||||
"\"mdns\" \"reset\"", "\"reboot\" extra", "\"ssh\" \"stop\"",
|
||||
"\"ssh\" \"host-key\" \"rotate\" --force", "\"user\" \"recover\" --force",
|
||||
};
|
||||
for (size_t i=0; i<sizeof(web_allowed)/sizeof(web_allowed[0]); ++i) {
|
||||
admin_request_t request={.token.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
|
||||
request.principal = (user_principal_t){.role=USER_ROLE_ADMIN,
|
||||
.username_length=5, .username="admin"};
|
||||
strcpy(request.line,web_allowed[i]);
|
||||
assert(remote_command_allowed(&request));
|
||||
assert(!strcmp(request.line,web_allowed[i]));
|
||||
}
|
||||
for (size_t i=0; i<sizeof(web_denied)/sizeof(web_denied[0]); ++i) {
|
||||
admin_request_t request={.token.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
|
||||
request.principal = (user_principal_t){.role=USER_ROLE_ADMIN,
|
||||
.username_length=5, .username="admin"};
|
||||
strcpy(request.line,web_denied[i]);
|
||||
if (remote_command_allowed(&request)) fprintf(stderr,"Unexpected allow: %s\n",request.line);
|
||||
assert(!remote_command_allowed(&request));
|
||||
assert(!strcmp(request.line,web_denied[i]));
|
||||
request.token.transport=0;
|
||||
/* SSH retains only the global recovery dispatcher restriction. */
|
||||
assert(remote_command_allowed(&request) ==
|
||||
(strstr(request.line,"recover")==NULL));
|
||||
}
|
||||
puts("PASS: UART0-only recovery, removed bootstrap policy, web bounded account forms, restrictions/lifecycle and quoted forms checked with actual IDF parser");
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix="admin-ssh-policy-") as directory:
|
||||
path = Path(directory)
|
||||
(path / "test.c").write_text(prelude + policy + helper + cases)
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
str(path / "test.c"), str(IDF / "components/console/split_argv.c"),
|
||||
"-o", str(path / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(path / "test")], check=True, timeout=10)
|
||||
|
||||
# Compile the actual composition-root security initialization and start gates.
|
||||
# Other subsystem setup is excluded; deterministic errors model its results.
|
||||
main = (ROOT / "src/main.c").read_text()
|
||||
initialization = main[main.index(" web_security_load_result_t web_security_source"):
|
||||
main.index(" esp_err_t web_runtime_error")]
|
||||
gates = main[main.index(" if (wifi_error == ESP_OK && web_security_error"):
|
||||
main.index(" if (local_ui_error == ESP_OK)")]
|
||||
web_header = "\n".join(line for line in (ROOT / "src/web_security.h").read_text().splitlines()
|
||||
if not line.startswith(("#include", "#pragma once")))
|
||||
user_header = (ROOT / "src/user_database.h").read_text()
|
||||
user_state = re.search(r"typedef enum \{[^{}]*\} user_database_load_result_t;", user_header).group()
|
||||
startup = r'''
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
typedef int esp_err_t;
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL -1
|
||||
#define SSH_TRANSPORT_PORT 22
|
||||
#define TAG "test"
|
||||
#define ESP_LOGI(tag, ...) snprintf(last_log, sizeof(last_log), __VA_ARGS__)
|
||||
#define ESP_LOGE(tag, ...) snprintf(last_error, sizeof(last_error), __VA_ARGS__)
|
||||
static char last_log[256], last_error[256], tls_log[256];
|
||||
static esp_err_t tls_error, db_error;
|
||||
static unsigned tls_calls, db_calls, web_starts, ssh_starts;
|
||||
static const char *esp_err_to_name(esp_err_t e) { (void)e; return "error"; }
|
||||
static esp_err_t web_server_start(void) { ++web_starts; return ESP_OK; }
|
||||
static esp_err_t ssh_transport_start(void) { ++ssh_starts; return ESP_OK; }
|
||||
'''
|
||||
startup += web_header + "\n" + user_state + r'''
|
||||
static web_security_load_result_t tls_source;
|
||||
static user_database_load_result_t db_source;
|
||||
esp_err_t web_security_init(web_security_load_result_t *out) {
|
||||
++tls_calls; *out=tls_source; return tls_error;
|
||||
}
|
||||
esp_err_t user_database_init(user_database_load_result_t *out) {
|
||||
++db_calls; strcpy(tls_log,last_log); *out=db_source; return db_error;
|
||||
}
|
||||
static void boot(esp_err_t random_error, esp_err_t wifi_error,
|
||||
esp_err_t web_runtime_error, esp_err_t ssh_security_error,
|
||||
esp_err_t ssh_runtime_error) {
|
||||
'''
|
||||
startup += initialization + gates + r'''
|
||||
}
|
||||
int main(void) {
|
||||
const web_security_load_result_t sources[]={WEB_SECURITY_LOAD_STORED,
|
||||
WEB_SECURITY_LOAD_GENERATED_MISSING, WEB_SECURITY_LOAD_MIGRATED_V1};
|
||||
const char *labels[]={"Using stored HTTPS identity", "Using newly generated HTTPS identity",
|
||||
"Using migrated v1 HTTPS identity"};
|
||||
for (unsigned i=0;i<3;++i) {
|
||||
tls_source=sources[i];
|
||||
for (unsigned empty=0;empty<2;++empty) {
|
||||
db_source=empty ? USER_DATABASE_LOAD_EMPTY : USER_DATABASE_LOAD_STORED;
|
||||
boot(ESP_OK,ESP_FAIL,ESP_OK,ESP_OK,ESP_OK);
|
||||
assert(!strcmp(tls_log,labels[i]));
|
||||
assert(!strcmp(last_log,empty ? "Using new empty user database" : "Using stored user database"));
|
||||
}
|
||||
}
|
||||
for (unsigned failures=0;failures<64;++failures) {
|
||||
tls_error=(failures&1) ? ESP_FAIL : ESP_OK;
|
||||
db_error=(failures&2) ? ESP_FAIL : ESP_OK;
|
||||
esp_err_t wifi=(failures&4) ? ESP_FAIL : ESP_OK;
|
||||
esp_err_t web_runtime=(failures&8) ? ESP_FAIL : ESP_OK;
|
||||
esp_err_t ssh_security=(failures&16) ? ESP_FAIL : ESP_OK;
|
||||
esp_err_t ssh_runtime=(failures&32) ? ESP_FAIL : ESP_OK;
|
||||
tls_calls=db_calls=web_starts=ssh_starts=0;
|
||||
boot(ESP_OK,wifi,web_runtime,ssh_security,ssh_runtime);
|
||||
assert(tls_calls==1 && db_calls==1);
|
||||
assert(web_starts==(!wifi && !tls_error && !web_runtime));
|
||||
assert(ssh_starts==(!wifi && !ssh_security && !ssh_runtime));
|
||||
if (db_error) assert(strstr(last_error,"user recover --force"));
|
||||
}
|
||||
tls_calls=db_calls=web_starts=ssh_starts=0;
|
||||
boot(ESP_FAIL,ESP_OK,ESP_OK,ESP_FAIL,ESP_OK);
|
||||
assert(!tls_calls && db_calls==1 && !web_starts && !ssh_starts);
|
||||
puts("PASS: startup init signatures/states, exact TLS source logs, 64 independent service-gate cases and RNG failure");
|
||||
}
|
||||
'''
|
||||
(path / "startup.c").write_text(startup)
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||
str(path / "startup.c"), "-o", str(path / "startup")], check=True, timeout=30)
|
||||
subprocess.run([str(path / "startup")], check=True, timeout=10)
|
||||
|
||||
completion = (ROOT / "src/console_completion.c").read_text()
|
||||
candidates = re.findall(r'^\s*"([^"\n]+)",?$', completion, re.MULTILINE)
|
||||
assert not any(c.startswith(("user bootstrap", "web credentials")) for c in candidates)
|
||||
for retained in ("user recover --force", "user add", "user password", "user key add",
|
||||
"web certificate info", "web certificate rotate --force", "web reset --force"):
|
||||
assert retained in candidates
|
||||
assert "Bootstrap/recovery" not in source
|
||||
assert "legacy" not in initialization
|
||||
print("PASS: removed completion entries, retained account/TLS/recovery commands and no startup credential copy")
|
||||
@@ -1,87 +0,0 @@
|
||||
# Admin ticket store host checks
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```sh
|
||||
python3 tests/web_admin_tickets/run.py
|
||||
python3 tests/web_admin_tickets/run.py --sanitize
|
||||
```
|
||||
|
||||
Requires a C11 `cc`, Python 3, OpenSSL development headers/libcrypto, and (for
|
||||
`--sanitize`) ASan/UBSan runtimes. No firmware build, network, generated assets,
|
||||
or persistent build output. The runner reuses the session-store harness's tiny
|
||||
platform header fakes. `test.c` includes the **unmodified production C**, using
|
||||
real project principal/session declarations and OpenSSL SHA-256. Inclusion gives
|
||||
white-box access for wipe, saturation and exhaustion assertions without adding
|
||||
production test hooks. RNG, time and session validation are deterministic fakes;
|
||||
all external calls assert that the ticket critical section is not held.
|
||||
|
||||
## Exact test groups
|
||||
|
||||
1. Stopped/start/idempotent-start lifecycle; 64 hex output; SHA-256 digest-only
|
||||
storage; success, replay denial and full record wipe.
|
||||
2. Two-ticket capacity and no live eviction; exact counters; nested competing
|
||||
issuance takes the last slot and the losing output is wiped.
|
||||
3. Issue rejects user role, public-key method, mismatched generation, zero ID,
|
||||
NULL principal/output, stale sessions and session-check errors.
|
||||
4. Consume burns matches before denying wrong session, user role, public-key
|
||||
method, generation, stale/check-error, zero ID or NULL principal; also rejects
|
||||
a different session with the *same* account principal.
|
||||
5. Empty/NULL/short/long/nonhex input; uppercase hex consumes the same secret.
|
||||
6. Success one microsecond before expiry; rejection at expiry; stale reclaim on
|
||||
issue/snapshot; snapshot expiry cleanup; signed deadline overflow rejection.
|
||||
7. Revocation ID precedence, exact username length/name and global scope;
|
||||
revocation never invalidates the fake sessions.
|
||||
8. RNG/SHA failures, failed output wipe, consume SHA failure leaves the
|
||||
unidentifiable ticket intact, duplicate live RNG/digest rejection.
|
||||
9. Issuance RNG/SHA hooks exercise stop/restart, global and nonmatching revoke,
|
||||
and session invalidation; currentness hook exercises stop/restart.
|
||||
10. Consume SHA/postcheck hooks exercise stop/restart, global/nonmatching revoke,
|
||||
stale sessions and expiry; nested competing consumes admit exactly once.
|
||||
11. Prune check races replacement with the same ID, digest and deadline; the
|
||||
non-reused record generation protects the replacement from stale cleanup.
|
||||
12. Nonwrapping epoch and record generation exhaustion, permanent lifecycle
|
||||
failure at exhaustion, saturated counters, NULL/count-only snapshots and
|
||||
host structure sizes.
|
||||
|
||||
## Contract and limits
|
||||
|
||||
The public API is in `src/web_admin_tickets.h`. This module is inert until wired
|
||||
by a later integration increment. It adds no routes, session invalidation,
|
||||
transport, task, socket, queue, timer or heap allocation. Callers must authorize
|
||||
HTTP cookie/Origin/CSRF, invalidate the authoritative session store **before**
|
||||
calling revoke, wipe successful token outputs and recheck currentness at later
|
||||
sensitive boundaries. A successful consume is not an authorization lease.
|
||||
|
||||
Two tickets, 32 RNG bytes each, 64 hex characters plus NUL, absolute 30-second
|
||||
lifetime. Only SHA-256 of decoded secret bytes is retained with copied principal,
|
||||
session ID, deadline and unique generation. Both hex cases are accepted. Live
|
||||
digest collisions fail rather than creating ambiguous tickets. No retry loop
|
||||
or live eviction. Pruning checks at most two copied records per invocation.
|
||||
Every revoke advances the epoch even if no record matches, conservatively
|
||||
cancelling unrelated in-flight issue/consume work. Start is idempotent while
|
||||
ready. Stop/start never resets counters, epoch or record generation.
|
||||
|
||||
`issued` counts published tickets, `consumed` counts burned matches (including
|
||||
subsequently denied admissions), `rejected` counts failed issue/consume calls;
|
||||
`capacity_rejections` is a subset of rejected. All counters saturate at UINT32_MAX.
|
||||
Snapshot prunes expired/stale records and exports counts, readiness and storage
|
||||
size only. A capacity failure is ESP_ERR_NO_MEM; malformed input INVALID_ARG;
|
||||
unauthorized/stale/lifecycle-raced work INVALID_STATE; no live consume match
|
||||
NOT_FOUND; SHA failure ESP_FAIL; RNG errors propagate. Failed issue wipes all 65
|
||||
output bytes when output is non-NULL. Output must not alias inputs.
|
||||
|
||||
Host measured sizes: ticket 104 B, two-ticket state 248 B, fake lock 4 B, snapshot
|
||||
40 B; snapshot `storage_bytes` = 252 B. Estimated 32-bit target sizes: ticket
|
||||
96 B, state 232 B, plus the target portMUX (typically 8 B), roughly **240 B static
|
||||
RAM**. These are estimates, not target linker measurements. Issue plus nested
|
||||
prune has 240 B of explicit ticket/random local payload on this host (about
|
||||
224 B on a 32-bit target), excluding scalar/compiler frames and session/RNG/SHA
|
||||
call stacks; caller also owns a 65 B token. No measured target stack/flash delta.
|
||||
|
||||
Hooks test deterministic interleavings, not true multicore scheduling or IDF
|
||||
portMUX semantics. They do not validate the real DRBG, mbedTLS, session database,
|
||||
HTTP admission, hardware, or full Phase 8D.5 integration. Post-check account
|
||||
changes without notification are subject to the same no-lease boundary as the
|
||||
session API. Combined hardware validation remains pending; no firmware build
|
||||
or device operation is part of this increment.
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile production ticket C with deterministic boundary fakes; no firmware build."""
|
||||
import os
|
||||
import pathlib
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
os.environ["CCACHE_DISABLE"] = "1"
|
||||
HERE = pathlib.Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
HEADERS = runpy.run_path(str(HERE.parent / "web_session_store/run.py"))["HEADERS"]
|
||||
with tempfile.TemporaryDirectory(prefix="web-admin-tickets-") as directory:
|
||||
tmp = pathlib.Path(directory)
|
||||
for name, text in HEADERS.items():
|
||||
path = tmp / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
sanitize = ["-fsanitize=address,undefined", "-fno-omit-frame-pointer"] if "--sanitize" in sys.argv else []
|
||||
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-g",
|
||||
*sanitize, "-I" + str(tmp), "-I" + str(ROOT / "src"),
|
||||
str(HERE / "test.c"), "-lcrypto", "-o", str(tmp / "test")],
|
||||
check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user