Author SHA1 Message Date
Commander1024 9e17df0ac5 Phase 8D - massive refactor and admin functions / admin shell in
webinterface. Memory and cross-origin problems.
2026-09-03 16:44:52 +02:00
Commander1024 31d3561d47 Document integrated web administration scope 2026-09-01 23:55:48 +02:00
Commander1024 6318f0c770 Unify browser connection controls (Connect / Disconnect) 2026-09-01 22:34:02 +02:00
Commander1024 10b6d65fa9 Mark Phase 8C hardware validation complete 2026-09-01 22:08:48 +02:00
Commander1024 4449131079 Refine Wi-Fi, mDNS, and terminal lifecycles
- Stage disabled station profile edits without restarting the radio
- Make mDNS initialization failure-isolated and reannounce in place
- Document deferred admin actions and explicit browser disconnect
  behavior
2026-08-31 04:29:54 +02:00
Commander1024 3feb3b5916 Add configurable Wi-Fi station mDNS hostname support. Mini-Feature #1 2026-08-31 04:14:38 +02:00
47 changed files with 10708 additions and 664 deletions
+7 -7
View File
@@ -14,14 +14,14 @@ 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 are complete. Phase 8B's role-aware HTTPS passwords, SSH passwords/public keys, and per-account session revocation are target-hardware validated. Phase 8C adds a bounded administrator SSH shell backed by the same serialized command registry as UART0 and awaits target-hardware validation. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D 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 migration and administration, HTTPS/SSH authentication, and session-revocation validation.
- [Command reference](docs/command_reference.md): UART0 administration, serial, broker, USB, Wi-Fi, web, SSH, and diagnostic commands.
- [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
@@ -29,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, local-display, role-based user, legacy recovery credential, 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 |
@@ -73,12 +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 configuration and Wi-Fi edits remain in RAM until explicitly saved with `serial save` or `wifi save`. Authenticated admin SSH sessions expose the shared operational administration registry, including interactive secrets, recovery-material management, network diagnostics, and deferred reboot/SSH lifecycle commands. Initial administrator bootstrap and explicit recovery of an unavailable user database remain UART0-only. 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. `web credentials show` exposes only the legacy migration/recovery credential, not an active Phase 8B network login.
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 role-aware HTTP Basic authentication over TLS; 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.
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 `wolfssl/wolfssl` `5.8.2~1` and `wolfssl/wolfssh` `1.4.20`; review upstream security releases before production use.
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.
+12 -1
View File
@@ -29,6 +29,16 @@ dependencies:
registry_url: https://components.espressif.com/
type: service
version: 3.0.3
espressif/mdns:
component_hash: 3ba256ac95e07c274be53cbd73f06cb846c403b61e8fbdf1be57bdb79db7a63e
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.12.0
espressif/tinyusb:
component_hash: a72b7d67472914ab76309340fd50d578b31e310963d45ad0f81144bde3314752
dependencies:
@@ -66,9 +76,10 @@ dependencies:
direct_dependencies:
- espressif/esp_tinyusb
- espressif/led_strip
- espressif/mdns
- idf
- wolfssl/wolfssh
- wolfssl/wolfssl
manifest_hash: 34e93560f67a22e2a43647a4663b450e07ecd9fa3fdc2ac38343c1a76b513f5d
manifest_hash: 4b6fa5a7e06122f9194adfc282a4baf4247d8a114b905e65f6397503e59f0898
target: esp32s3
version: 2.0.0
+31 -22
View File
@@ -34,12 +34,12 @@ SSH role=admin ------> shared administration dispatcher <------ UART0
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 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, persist generated first-boot defaults when appropriate, initialize its manager, and start it when configured for boot.
8. Load Wi-Fi configuration and the independent mDNS hostname configuration, persist generated first-boot Wi-Fi defaults when appropriate, initialize the nonfatal mDNS configuration service and Wi-Fi manager, and start Wi-Fi when configured for boot. The Wi-Fi manager owns subsequent mDNS announcement transitions.
9. Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. Both gates also require HTTPS security readiness, and SSH additionally requires its own security/runtime readiness. The HTTPS-security gate on SSH is an implemented dependency even though SSH has a separate host key.
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
@@ -96,15 +96,19 @@ 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.
HTTP Basic authentication uses `user_database`. Before administrator bootstrap, the migrated role-`user` account is synchronized from the legacy credential, so that username/password can authenticate through the database; after bootstrap, the legacy blob is independent recovery material and is no longer consulted for authentication or synchronized into role-based accounts. Both `user` and `admin` roles currently receive the same web status/terminal experience; web administration is not implemented.
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.
The boot-local Basic-authentication cache has four RAM entries and a five-minute sliding lifetime. It stores a keyed digest of the complete `Authorization` header rather than the raw header, and every hit revalidates principal currentness. Its current lack of locking relies on the single-HTTPD-owner execution model.
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.
A WebSocket connection requires a one-time, principal-bound ticket with a maximum 30-second lifetime. Only four tickets can be outstanding; minting another evicts the live entry with the earliest expiry. Ticket issuance and upgrade also validate a supplied `Origin` against `https://<Host>`; absence of `Origin` is accepted 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.
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.
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.
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.
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.
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_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.
@@ -129,42 +133,47 @@ 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 is trusted for initial administrator bootstrap and explicit unavailable-database recovery. Authenticated admin SSH can run the operational registry but is denied those two recovery operations; 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.
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.
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.
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.
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.
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.
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, and next-profile requests. 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:
- `serial/config`;
- `wifi_app/config`;
- `mdns_cfg/config`;
- `local_ui/config`;
- `web_sec/material`;
- `user_db/database`;
@@ -178,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.
@@ -186,9 +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.
+31 -28
View File
@@ -60,15 +60,15 @@ This is a semantic map, not a complete file inventory. Start here, then read the
**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}`
- 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 Basic auth -> ticket -> 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-auth cache hits still revalidate principal currentness; browser Disconnect closes the WebSocket and pauses automatic reconnect until Reconnect is selected. Changes to the authored inline loader must update its hard-coded CSP hash in the same change.
- 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.
## SSH
@@ -87,47 +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: init/migration/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 exclusively owns initial administrator bootstrap and unavailable-database recovery; current admins may use admin SSH for other commands unless handler policy denies them. HTTPS currently treats both roles alike.
- 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.
- 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.
- 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/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
- Dependencies: secure random for default AP password, NVS, ESP-NETIF/Wi-Fi/events, lwIP diagnostics
- 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; 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.
@@ -155,8 +155,11 @@ 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` |
| Change embedded browser assets | `web_assets/SOURCES.md`, generator, then generated data only as an explicit regeneration task |
+17 -18
View File
@@ -4,12 +4,12 @@ This file is working memory. Update it during active work and before handoff; do
## Development state
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 and uses the shared `esp_console` registry. Target-hardware validation is explicitly pending.
- Phase 8D web user administration and Phase 8E browser login/session integration are planned, not implemented.
- Phase 8C admin SSH is implemented in source, uses the shared `esp_console` registry, and has passed target-hardware validation.
- 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.
@@ -20,15 +20,15 @@ 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.
- 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 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.
- 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 matrix remains pending. It includes route separation, shared command serialization, history/completion, prompts, output backpressure, revocation during queued work, deferred SSH lifecycle/reboot actions, and full concurrent transport operation.
- Current HTTPS has no web-based user administration and gives both roles the same status/terminal routes.
- Browser authentication still uses HTTP Basic; integrated login/logout sessions are planned.
- NVS encryption, secure boot/flash encryption review, authentication rate limiting, production certificate/provisioning policy, and OTA are not implemented.
- 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.
- 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
@@ -40,19 +40,18 @@ These observations should be checked when touching the relevant area; they are n
## Items to verify in future work
- Complete the documented Phase 8C target-hardware validation before marking it complete.
- Confirm task-local Newlib standard-stream behavior if ESP-IDF/Newlib configuration changes; admin SSH command output relies on dispatcher-task stream redirection.
- If HTTPD concurrency configuration changes, add locking around the boot-local Basic-authentication cache.
- 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.
## Active Task
- **Objective:** Keep UART1 configuration independent of USB CDC host line coding, keep Phase 0 RS-232 status under `debug`, avoid disconnecting Wi-Fi for edits to disabled profiles, and add explicit web/admin-SSH session exit controls.
- **Relevant files:** `src/usb_cdc_transport.{c,h}`, `src/usb_console.c`, `src/rs232_hw_test.c`, `src/console_completion.c`, `src/wifi_manager.{c,h}`, `src/web_ui.c`, `src/admin_ssh_console.{c,h}`, `src/main.c`, related documentation.
- **Findings:** The USB transport converted cached CDC line coding into `serial_config_t` and called `serial_service_apply_config()` after writer acquisition. Root `status` was a Phase 0 diagnostic that needed to claim the RS-232 port and therefore failed while the production serial service owned it. Every Wi-Fi working-configuration update queued a radio restart, including edits to profiles disabled in both the old and new configurations.
- **Decision:** Treat CDC line coding as diagnostic metadata only. UART1 configuration remains exclusively controlled by explicit `serial` commands and their NVS persistence. Keep all Phase 0 RS-232 diagnostics, including signal status, under `debug`. Treat disabled Wi-Fi profiles as staged configuration: their edits do not restart the radio; enable/disable transitions and changes to enabled station/AP policy retain controlled asynchronous restart behavior. Browser Disconnect pauses reconnect until Reconnect is selected. `exit`/empty-line Ctrl+D are admin-SSH-only deferred self-disconnect actions; role-`user` SSH remains binary-transparent.
- **Changes completed:** Removed the pending line-coding apply path and its counters; retained the latest host setting for `usb status`. Replaced root `status` with `debug status`, updated completion and documentation. Added effective Wi-Fi policy comparison before queueing `MESSAGE_COMMAND_APPLY`. Added browser Disconnect and admin SSH `exit`/Ctrl+D session closing through the existing bounded drain path.
- **Remaining work:** Target-hardware verification: acquire/release USB writer ownership after changing a host terminal's line coding and confirm UART1 remains at the configured framing. Verify `debug status` works after `serial stop` and root `status` is unknown. While connected through Wi-Fi, edit a disabled profile and its secret without a reconnect; then enable it and verify the expected reconnect. Verify browser Disconnect pauses automatic reconnect until Reconnect is selected, and admin SSH `exit`/empty Ctrl+D close cleanly.
- **Risks / things to remember:** Host terminal line-coding selectors no longer configure the physical RS-232 port; use `serial set`/`serial save` instead. `debug status` remains ownership-protected and requires UART1 to be stopped. Enabling a profile may disconnect an SSH administrative session. `pio run` passed after all changes.
- **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
+79 -9
View File
@@ -34,13 +34,13 @@ 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.
**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
@@ -74,7 +74,7 @@ Only constraints supported by implementation or current project documentation be
## Selected self-affecting admin SSH actions use bounded deferred control
**Decision:** Remote reboot, SSH stop/disconnect, and host-key rotate/reset are deferred until command state and administration/transport application buffers appear drained, with a ten-second limit and short final delay.
**Decision:** Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset are deferred until command state and administration/transport application buffers appear drained, with a ten-second limit and short final delay.
**Rationale/evidence:** `admin_ssh_console` has a separate bounded control task and pending-action state. The check is a best-effort application-buffer heuristic, not peer-delivery confirmation. User account mutations and their immediate revocation calls do not use this path.
@@ -92,15 +92,85 @@ Only constraints supported by implementation or current project documentation be
**Relevant files:** `src/user_database.{h,c}`, `src/user_console.c`, `src/web_server.c`, `src/web_serial_transport.c`, `src/ssh_transport.c`
## Browser authentication uses bounded explicit sessions
**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.
**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.
**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.
**Relevant files:** `src/web_session.{h,c}`, `src/web_server.c`, `src/web_serial_transport.c`, `src/web_admin_transport.c`
## Browser admin shell is separate from browser serial
**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.
**Rationale/evidence:** This preserves a browser-held writer lease while giving full canonical administrative command parity without a generic HTTP command endpoint.
**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.
**Relevant files:** `src/web_admin_transport.{h,c}`, `src/admin_ssh_console.{h,c}`, `src/web_ui.c`, `src/web_server.c`
## Browser writer transfer is atomic and generation-safe
**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.
**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.
**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.
**Relevant files:** `src/session_broker.{h,c}`, `src/web_server.c`, `src/web_ui.c`
## 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
**Decision:** Application settings, users, and identities use separate fixed/versioned NVS blobs. Serial, Wi-Fi, and local-UI working edits are RAM-only until explicitly saved. User mutations and HTTPS/SSH identity changes commit directly as part of the operation. Invalid ordinary configuration generally selects RAM defaults without erasing storage; malformed security material fails closed and needs explicit reset.
**Decision:** Application settings, users, and identities use separate fixed/versioned NVS blobs. Serial, Wi-Fi, mDNS-hostname, and local-UI working edits are RAM-only until explicitly saved. User mutations and HTTPS/SSH identity changes commit directly as part of the operation. Invalid ordinary configuration generally selects RAM defaults without erasing storage; malformed security material fails closed and needs explicit reset.
**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. 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/local_ui_config.c`, `src/web_security.c`, `src/user_database.c`, `src/ssh_security.c`
**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
@@ -114,13 +184,13 @@ Only constraints supported by implementation or current project documentation be
## Wi-Fi callbacks enqueue; the manager owns policy
**Decision:** ESP event callbacks copy bounded event data into the Wi-Fi manager queue. A permanent manager task performs driver operations, profile/AP policy, deadlines, and reconciliation.
**Decision:** ESP event callbacks copy bounded event data into the Wi-Fi manager queue. A permanent manager task performs driver operations, profile/AP policy, deadlines, reconciliation, and station mDNS announcement transitions. mDNS initializes at most once, remains allocated across transient disconnects while its component handlers withdraw/re-enable the STA interface, and treats failure as nonfatal.
**Rationale/evidence:** Callback paths avoid blocking, NVS, and policy work. Manager deadlines consult authoritative driver/netif state so dropped events are recoverable.
**Consequence for future changes:** Keep callbacks short and nonblocking. Add state transitions to the manager rather than directly invoking Wi-Fi policy from consoles, UI, or callbacks. Preserve queue-drop observability.
**Relevant files:** `src/wifi_manager.{h,c}`, `src/wifi_config.{h,c}`
**Relevant files:** `src/wifi_manager.{h,c}`, `src/wifi_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_config.{h,c}`
## Optional local UI cannot become a core dependency
+29 -8
View File
@@ -1,6 +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, 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. 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.
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
@@ -36,6 +36,8 @@ On the first Phase 8A boot, the old shared `admin` credential is imported as a r
`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
| Command | Description |
@@ -46,7 +48,7 @@ On the first Phase 8A boot, the old shared `admin` credential is imported as a r
| `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,23 +108,42 @@ 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
| Command | Description |
|---|---|
| `mdns status` | Show the configured `sak-<suffix>.local` hostname and announcement state. |
| `mdns suffix <value>` | Set a 155-character lowercase hostname suffix in RAM. |
| `mdns save` / `mdns load` | Save the working suffix to its independent NVS record or load it. |
| `mdns defaults` / `mdns reset` | Restore the MAC-derived suffix in RAM, or restore and persist it. |
When the Wi-Fi station receives an IPv4 address, the Wi-Fi manager announces `sak-<suffix>.local`. The default suffix is the lower-case hexadecimal STA MAC address. Suffixes may contain lowercase ASCII letters, digits, and internal hyphens only. Changing a suffix while online causes a best-effort reannouncement; mDNS failures do not stop Wi-Fi, UART0, UART1, or native USB access.
## HTTPS web terminal
| 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 authenticated admin SSH; it is not a role-based network login. |
| `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. |
| `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. 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
@@ -140,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 are deferred until the command acknowledgement has left both the administration output ring and transport TX buffer. 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.
`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
+30 -19
View File
@@ -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 (8A8B complete; 8C validation pending; 8D8E planned)** |
| 8 | Role-based users and administrative access | **In progress (8A8C 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** |
@@ -109,6 +109,11 @@ Implemented and hardware-validated:
- Root and `wifi` aliases for `ping`, `nslookup`, and `traceroute`.
- Wi-Fi/lwIP payload allocation configured to prefer PSRAM with capacities pinned in `sdkconfig.defaults`.
Post-validation enhancements implemented; regression validation pending:
- Edits to disabled station profiles are staged without restarting the active radio; enable-state changes and edits affecting enabled radio policy apply asynchronously.
- Configurable STA-only `sak-<suffix>.local` mDNS hostname with an independent versioned NVS record, live hostname updates, automatic withdrawal/restoration across STA IPv4 transitions, and nonfatal failure isolation. The responder initializes at most once and keeps its task stack internal while general metadata prefers PSRAM.
A WPA3-required profile correctly rejects an AP that does not advertise the required Protected Management Frame capability; mixed mode is the compatible choice for such an AP.
### Phase 5A — Authenticated HTTPS foundation
@@ -136,6 +141,8 @@ Implemented and hardware-validated:
- Viewport-constrained terminal fitting without recursive page growth.
- Validation with multiple clients, ANSI colors, advanced escape sequences, and full-screen terminal software.
Post-validation enhancement implemented; browser regression validation pending: the combined Connect/Disconnect control explicitly closes the terminal WebSocket and pauses automatic reconnect until Connect is selected.
### Phase 6 — Authenticated SSH serial transport
Implemented and target-hardware validated:
@@ -164,7 +171,7 @@ Implemented and target-hardware validated:
## Current and planned phases
Phase 8A and 8B are complete; Phase 8C is implemented and awaiting target-hardware validation. 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
@@ -182,29 +189,33 @@ Implementation sequence:
- One-time principal-bound WebSocket tickets and ongoing principal-currentness checks prevent stale admission or input.
- Account mutations request targeted WebSocket/SSH revocation; authentication generations provide fail-safe invalidation while unrelated accounts remain connected.
- Legacy credentials remain migration/recovery-only after bootstrap. Password/key login, ticket behavior, targeted revocation, recovery, and concurrent transport operation are target-hardware validated.
3. **Phase 8C — SSH administrative shell — Implemented; validation pending**
3. **Phase 8C — SSH administrative shell — Complete**
- Authenticated `admin` SSH shell sessions route to a bounded administration worker and never create a broker client or acquire a serial writer lease. Normal `user` sessions retain the existing broker-backed serial stream.
- UART0 and admin SSH now submit complete lines to one fixed-length request queue. A single dispatcher task is the sole caller of ESP-IDF's non-reentrant `esp_console_run()` and therefore executes the same registered command handlers for both entry routes. The former separately implemented reduced SSH command dispatcher has been removed.
- The worker uses fixed per-session command/input and output buffers. Queue records contain copied secret-free principals and generation-tagged session tokens; late work is discarded after disconnect, slot reuse, role change, password/key mutation, or deletion. Task-local standard streams route canonical handler output into the applicable bounded SSH ring, and only the SSH owner task calls wolfSSH APIs.
- Transport-neutral bounded prompts now support interactive user passwords/keys and Wi-Fi secrets over admin SSH without exposing hidden input or allowing another command while a prompt is active. Ping callbacks enqueue typed bounded events and the dispatcher alone formats their output. Four-entry per-session history and whole-line Tab completion are RAM-only and wiped on disconnect.
- Authenticated administrators receive the operational registry, including recovery-secret display, HTTPS material rotation/reset, reboot, ping, and SSH lifecycle/session/host-key mutation. Self-terminating reboot and SSH actions are deferred until acknowledgement output drains, block further shell input, and execute through existing synchronous owner APIs from a separate bounded control task. Initial `user bootstrap` and explicit `user recover --force` remain physical-UART0 operations; admin SSH also rejects generating a replacement password for its own account.
- `ssh sessions` and `ssh counters` identify broker versus admin-console routes, worker command state, queued admin output, admission failures, and input backpressure. Admin sessions are checked for a current `admin` principal before command execution and during the active-session reconciliation.
- `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.
- Pending target-hardware validation: route separation from the broker, 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.
4. **Phase 8D — Web user administration — Planned**
- Add an admin-only user-management interface and typed, bounded APIs for account CRUD, roles, password generation/change, SSH-key management, and revocation. Never expose a generic HTTP endpoint that executes arbitrary CLI text.
- Hide administrative navigation and controls for normal users, and enforce every authorization decision server-side so hidden UI is not treated as a security boundary.
- Preserve the existing normal terminal interface. Use strict origin/CSRF protections for mutations, secret-safe JSON encoding, one-time display of generated passwords, and no-store responses for administrative material.
5. **Phase 8E — Integrated web authentication and sessions — Planned**
- Replace the browser-facing HTTP Basic authentication flow with a same-origin HTTPS login page and an explicit logout action. This avoids relying on browser credential caches, private windows, or a clean browser session to switch accounts after login.
- Authenticate login submissions against the common user database over TLS, then create a bounded, opaque, random server-side session. Store only a digest of the session token together with a copied secret-free principal, expiry, and authentication-generation binding; never retain passwords, Basic headers, raw session tokens, verifiers, or SSH key blobs in snapshots or logs.
- Send the opaque token only in a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain` attribute, and an explicit lifetime. Reject missing, malformed, expired, replayed, or stale sessions. Avoid local-storage tokens and URL/query-string credentials.
- Make logout invalidate the current server-side session, expire its cookie, terminate its WebSocket terminal session, and redirect to the login page. Account password/role/key mutations, deletion, recreation, and explicit revocation must invalidate that account's web sessions and tickets; unrelated accounts remain connected.
- Require a per-session CSRF token and same-origin checks for every state-changing web request, including logout and the future Phase 8D administration APIs. Continue `Cache-Control: no-store` for login, session, and administration responses; define CSP and redirect behavior so the login page cannot become an open redirect or token-reflection surface.
- Preserve a bounded physical-UART0 recovery path when the database or web-session service is unavailable. Do not retain a legacy network-authentication fallback after bootstrap. Decide and document whether compatibility HTTP Basic is removed entirely or is an explicitly temporary, separately rate-limited recovery mechanism; it must not silently bypass session logout or revocation.
- Add secret-free web-session counters and snapshots for capacity, expiry, login failures, logout, invalidation, and WebSocket association. Rate-limit login attempts and retain only bounded failure/accounting state.
- 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 — 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 migration and power-loss recovery tests, final-admin protection, malformed credential/key rejection, password and key login for both roles, targeted session revocation, concurrent normal/admin SSH operation, hidden and server-rejected user-level web administration, explicit web login/logout and stale-session handling, 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
@@ -265,7 +276,7 @@ BLE remains subordinate to stable Wi-Fi and serial operation; it should be omitt
These features are candidates, not current commitments:
- mDNS advertisement for the certificates device-specific `.local` name and selected services.
- Additional DNS-SD service advertisement and any certificate-name integration beyond the implemented configurable STA hostname.
- Enterprise Wi-Fi support, subject to credential-storage and certificate-validation design.
- IPv6 behavior and diagnostics beyond the current basic support.
- WireGuard feasibility evaluation, including RAM, CPU, licensing, key storage, routing, and recovery impact.
+60 -1
View File
@@ -1,6 +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.
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
@@ -125,6 +125,8 @@ With USB CDC, two role-based network users, one WebSocket terminal, one SSH term
## Phase 8C SSH administrative shell
**Validation status: passed on target hardware.** The full matrix below passed, including concurrent USB/WebSocket/user-SSH/admin-SSH operation. At 460800 baud with SSH and WebSocket clients in parallel, substantial packet drops and slower display controls were observed under load, but memory exhaustion could not be provoked and no lower baud-rate cap is planned.
Use one disposable `admin` and one disposable `user`. Keep UART0 attached throughout. The SSH server still accepts only shell sessions: `exec`, subsystem/SFTP/SCP, forwarding, and unauthenticated connections must remain rejected.
### 1. Route separation and normal shells
@@ -163,3 +165,60 @@ Exercise `ssh disconnect` for another session and the current session. For the o
While an administrative command is queued or running, use UART0 to change that admin's role/password/key or delete it. Confirm no second remote administrative command runs after the mutation, the SSH session is revoked promptly, and queued output is not delivered to a reused SSH slot. Repeat with a different account mutation and confirm the administrator remains connected.
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.
## Phase 8D integrated web administration
**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
Authenticate as both roles through the same-origin login page, explicitly log out, and switch accounts without clearing browser state or relying on an HTTP Basic credential cache. Verify bounded session capacity, expiry, login throttling, malformed cookies, CSRF rejection, Origin rejection, password/role/key invalidation, deletion/recreation, and unrelated-account isolation. A normal user must be unable to discover through navigation or invoke directly any admin ticket, admin WebSocket, typed mutation API, quick setting, detailed client list, or writer-transfer operation.
### 2. Serial/admin terminal switching and lease preservation
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. 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
For an administrator, open the **Serial** and **Wi-Fi** card popovers by hover, keyboard focus, and click/tap. Confirm each path exposes the same controls, opening alone performs no mutation, Escape/outside click closes cleanly, current values refresh safely, common edits have clear apply-versus-save behavior, and links reach the full Settings section. Saved Wi-Fi secrets must not appear by default or enter routine DOM text, logs, URLs, or status responses.
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. Guided users, roles, passwords, keys, and revocation
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. Guided Wi-Fi configuration and secret handling
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.
+14
View File
@@ -49,6 +49,20 @@ CONFIG_ESP_ENABLE_WOLFSSH=y
# The managed component emits a generic RSA stack warning although this target disables RSA.
CONFIG_ESP_WOLFSSL_NO_STACK_SIZE_BUILD_WARNING=y
# Keep configurable hostname discovery STA-only and bounded. The responder task
# remains internal; general mDNS metadata prefers PSRAM to protect internal heap.
CONFIG_MDNS_MAX_INTERFACES=1
CONFIG_MDNS_MAX_SERVICES=1
CONFIG_MDNS_PREDEF_NETIF_STA=y
# CONFIG_MDNS_PREDEF_NETIF_AP is not set
# CONFIG_MDNS_PREDEF_NETIF_ETH is not set
# CONFIG_MDNS_ENABLE_CONSOLE_CLI is not set
# CONFIG_MDNS_ENABLE_BROWSE is not set
# CONFIG_MDNS_MULTIPLE_INSTANCE is not set
CONFIG_MDNS_TASK_CREATE_FROM_INTERNAL=y
CONFIG_MDNS_MEMORY_ALLOC_SPIRAM=y
# CONFIG_MDNS_MEMORY_ALLOC_INTERNAL is not set
# Support WPA3-SAE for station profiles and the WPA2/WPA3 fallback AP.
CONFIG_ESP_WIFI_ENABLE_WPA3_SAE=y
CONFIG_ESP_WIFI_ENABLE_SAE_H2E=y
+7
View File
@@ -28,9 +28,12 @@ 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_admin_transport.c"
"web_assets_data.c"
"web_ui.c"
"web_server.c"
@@ -38,6 +41,9 @@ idf_component_register(
"wifi_config.c"
"wifi_manager.c"
"wifi_console.c"
"mdns_config.c"
"mdns_service.c"
"mdns_console.c"
INCLUDE_DIRS "."
REQUIRES
bootloader_support
@@ -58,6 +64,7 @@ idf_component_register(
led_strip
lwip
mbedtls
mdns
nvs_flash
wolfssl__wolfssh
wolfssl__wolfssl
+161 -60
View File
@@ -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"
@@ -18,8 +18,8 @@
#include "secure_random.h"
#include "ssh_transport.h"
#include "user_database.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
@@ -29,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,6 +49,7 @@ typedef struct {
bool deferred_action_pending;
admin_ssh_console_token_t token;
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];
@@ -61,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;
@@ -72,7 +75,7 @@ typedef struct {
} admin_session_t;
typedef enum {
ADMIN_REQUEST_SSH = 0,
ADMIN_REQUEST_REMOTE = 0,
ADMIN_REQUEST_UART0,
} admin_request_origin_t;
@@ -80,6 +83,7 @@ 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;
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
} admin_request_t;
@@ -87,12 +91,12 @@ typedef struct {
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];
/* admin_ssh_console_feed_input() is called only by the sole SSH owner task. */
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;
@@ -105,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;
@@ -115,13 +121,27 @@ static bool s_dispatch_remote;
static bool s_dispatch_output_previous_cr;
static admin_ssh_console_token_t s_dispatch_token;
static user_principal_t s_dispatch_principal;
static ssh_transport_snapshot_t s_control_ssh_snapshot;
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;
}
admin_ssh_console_frontend_t admin_ssh_console_dispatch_frontend(void)
{
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)
{
return admin_ssh_console_dispatch_is_remote() ? &s_dispatch_principal : NULL;
@@ -129,14 +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 &&
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;
}
@@ -357,12 +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;
if (valid) {
session->deferred_action_pending = true;
frontend_ops = session->frontend_ops;
}
taskEXIT_CRITICAL(&s_lock);
if (!valid) {
@@ -371,6 +408,7 @@ esp_err_t admin_ssh_console_dispatch_defer(
admin_control_request_t request = {
.action = action,
.token = s_dispatch_token,
.frontend_ops = frontend_ops,
.argument = argument,
};
if (xQueueSend(s_control_queue, &request, 0U) == pdTRUE) {
@@ -385,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) {
@@ -468,17 +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_SSH_DEFER_DISCONNECT, s_dispatch_token.session_id);
ADMIN_SSH_DEFER_FRONTEND_DISCONNECT, s_dispatch_token.session_id);
if (error != ESP_OK) {
printf("Could not schedule SSH session close: %s\n", esp_err_to_name(error));
printf("Could not schedule administrative session close: %s\n",
esp_err_to_name(error));
return 1;
}
printf("SSH session close scheduled after output drains.\n");
printf("Administrative session close scheduled after output drains.\n");
return 0;
}
@@ -487,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;
@@ -538,8 +577,16 @@ static void worker_task(void *context)
continue;
}
bool current = false;
esp_err_t auth_error = user_database_principal_is_current(&request.principal, &current);
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];
@@ -549,16 +596,18 @@ static void worker_task(void *context)
session->executing = true;
}
taskEXIT_CRITICAL(&s_lock);
bool authorized = active && auth_error == ESP_OK && current &&
request.principal.role == USER_ROLE_ADMIN &&
remote_command_allowed(&request);
if (authorized) {
if (authorized && active) {
dispatch_registered_command(&request);
} else if (active) {
(void)worker_write(&request.token,
auth_error == ESP_OK && current
? "Command is restricted to physical UART0.\r\n"
: "Administrative authorization is no longer current; closing session.\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);
}
bool prompt = false;
taskENTER_CRITICAL(&s_lock);
@@ -566,7 +615,7 @@ static void worker_task(void *context)
if (token_matches(session, &request.token)) {
session->executing = false;
session->command_pending = false;
prompt = auth_error == ESP_OK && 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 &&
@@ -588,13 +637,14 @@ static void finish_deferred_request(const admin_control_request_t *request,
char message[160];
if (cancelled) {
snprintf(message, sizeof(message),
"Deferred action cancelled before SSH output drained.\r\nadmin@serial-tool> ");
"Deferred action cancelled before administrative output drained.\r\n"
"admin@serial-tool> ");
} else if (result == ESP_OK) {
snprintf(message, sizeof(message),
"Deferred SSH action completed.\r\nadmin@serial-tool> ");
"Deferred administrative action completed.\r\nadmin@serial-tool> ");
} else {
snprintf(message, sizeof(message),
"Deferred SSH action failed: %s\r\nadmin@serial-tool> ",
"Deferred administrative action failed: %s\r\nadmin@serial-tool> ",
esp_err_to_name(result));
}
taskENTER_CRITICAL(&s_lock);
@@ -615,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);
@@ -624,27 +677,18 @@ static void control_task(void *context)
session->output_length == 0U;
taskEXIT_CRITICAL(&s_lock);
if (!current) {
drained = must_execute;
break;
}
bool transport_drained = false;
if (console_drained &&
ssh_transport_get_snapshot(&s_control_ssh_snapshot) == ESP_OK) {
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
const ssh_transport_session_snapshot_t *slot =
&s_control_ssh_snapshot.sessions[index];
if (slot->active && slot->session_id == request.token.session_id) {
transport_drained = !slot->tx_pending;
break;
}
}
}
bool transport_drained = console_drained &&
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;
@@ -658,7 +702,11 @@ static void control_task(void *context)
case ADMIN_SSH_DEFER_STOP:
result = ssh_transport_stop();
break;
case ADMIN_SSH_DEFER_DISCONNECT:
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:
@@ -667,6 +715,15 @@ static void control_task(void *context)
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;
@@ -725,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,
@@ -750,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 SSH session",
.help = "Close the current remote administrative session",
.hint = NULL,
.func = &command_exit,
.argtable = NULL,
@@ -782,16 +841,16 @@ esp_err_t admin_ssh_console_start_uart_frontend(void)
return ESP_OK;
}
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
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_valid(token) || principal == NULL || principal->role != USER_ROLE_ADMIN) {
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;
@@ -809,8 +868,9 @@ esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
session->history_position = -1;
session->token = *token;
session->principal = *principal;
session->frontend_ops = *frontend_ops;
static const char banner[] =
"ESP32 Serial Swiss Army Knife administrative SSH shell\r\n";
"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, sizeof(banner) - 1U);
@@ -861,7 +921,7 @@ bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token)
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)
{
@@ -938,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 == '[') {
@@ -1046,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;
@@ -1080,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,
@@ -1117,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)
+35 -6
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Bounded, transport-neutral administrative command worker for SSH sessions. */
/* Bounded, transport-neutral administrative command worker for remote sessions. */
#pragma once
@@ -17,19 +17,42 @@ extern "C" {
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
/* 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;
} 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_SSH_DEFER_WEB_STOP,
ADMIN_SSH_DEFER_WEB_TLS_REFRESH_STOPPED,
ADMIN_SSH_DEFER_WEB_TLS_REFRESH_RUNNING,
} admin_ssh_deferred_action_type_t;
typedef struct {
@@ -47,9 +70,11 @@ 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);
admin_ssh_console_frontend_t admin_ssh_console_dispatch_frontend(void);
const user_principal_t *admin_ssh_console_dispatch_principal(void);
esp_err_t admin_ssh_console_dispatch_read_input(
const char *prompt, uint8_t *output, size_t capacity,
@@ -57,18 +82,22 @@ esp_err_t admin_ssh_console_dispatch_read_input(
esp_err_t admin_ssh_console_dispatch_defer(
admin_ssh_deferred_action_type_t action, uint32_t argument);
/* The token and principal are copied; no SSH or socket objects cross this boundary. */
/*
* 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 only by the SSH owner task. 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 only by the SSH owner task; 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);
+9 -1
View File
@@ -12,7 +12,7 @@
static const char *const s_root_candidates[] = {
"help", "exit", "debug", "display", "serial", "broker", "usb", "user",
"wifi", "web", "ssh", "ping", "nslookup", "traceroute", "reboot", "memory",
"wifi", "mdns", "web", "ssh", "ping", "nslookup", "traceroute", "reboot", "memory",
};
/* Keep full-line candidate strings grouped by their registered root command. */
@@ -169,6 +169,14 @@ static const char *const s_completion_candidates[] = {
"wifi nslookup",
"wifi traceroute",
/* Station mDNS hostname configuration. */
"mdns status",
"mdns suffix",
"mdns save",
"mdns load",
"mdns defaults",
"mdns reset",
/* Authenticated HTTPS lifecycle and physical-admin recovery operations. */
"web help",
"web status",
+1
View File
@@ -4,6 +4,7 @@ dependencies:
idf: ">=5.3.0"
espressif/led_strip: "^3.0.3"
espressif/esp_tinyusb: "^2.2.1"
espressif/mdns: "^1.8.2"
# Exact official registry versions form the reviewed Phase 6 integration baseline.
wolfssl/wolfssl: "5.8.2~1"
wolfssl/wolfssh: "1.4.20"
+15 -1
View File
@@ -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 = {
+23
View File
@@ -14,6 +14,9 @@
#include "local_ui_config.h"
#include "local_ui_console.h"
#include "local_ui_hw_test.h"
#include "mdns_config.h"
#include "mdns_console.h"
#include "mdns_service.h"
#include "rs232_hw_test.h"
#include "rs232_port_owner.h"
#include "secure_random.h"
@@ -215,6 +218,23 @@ void app_main(void)
"Stored Wi-Fi configuration is incompatible; using RAM defaults without overwriting it");
}
mdns_config_t mdns_config;
bool used_stored_mdns_config = false;
esp_err_t mdns_config_error = mdns_config_load(&mdns_config, &used_stored_mdns_config);
if (mdns_config_error != ESP_OK) {
mdns_config_defaults(&mdns_config);
ESP_LOGW(TAG, "NVS mDNS configuration unavailable (%s); using RAM defaults",
esp_err_to_name(mdns_config_error));
}
esp_err_t mdns_service_error = mdns_service_init(&mdns_config);
if (mdns_service_error != ESP_OK) {
ESP_LOGW(TAG, "mDNS configuration service unavailable: %s; Wi-Fi will continue",
esp_err_to_name(mdns_service_error));
} else {
ESP_LOGI(TAG, "Using %s mDNS suffix sak-%s.local",
used_stored_mdns_config ? "stored" : "default", mdns_config.suffix);
}
esp_err_t wifi_error = wifi_config_error;
if (wifi_config_error == ESP_OK) {
wifi_error = wifi_manager_init(&wifi_config);
@@ -301,6 +321,9 @@ void app_main(void)
ESP_ERROR_CHECK(usb_console_register_commands());
ESP_ERROR_CHECK(user_console_register_commands());
ESP_ERROR_CHECK(wifi_console_register_commands());
if (mdns_service_error == ESP_OK) {
ESP_ERROR_CHECK(mdns_console_register_commands());
}
ESP_ERROR_CHECK(web_console_register_commands());
ESP_ERROR_CHECK(ssh_console_register_commands());
ESP_ERROR_CHECK(network_console_register_root_commands());
+145
View File
@@ -0,0 +1,145 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Versioned persistent configuration for the station mDNS hostname. */
#include "mdns_config.h"
#include <stddef.h>
#include <string.h>
#include "esp_mac.h"
#include "nvs.h"
#include "nvs_flash.h"
_Static_assert(sizeof(mdns_config_t) == MDNS_CONFIG_BLOB_SIZE,
"mDNS config schema size changed");
static bool suffix_character_is_valid(char character)
{
return (character >= 'a' && character <= 'z') ||
(character >= '0' && character <= '9') || character == '-';
}
void mdns_config_defaults(mdns_config_t *config)
{
static const char hex[] = "0123456789abcdef";
uint8_t mac[6] = {0};
if (config == NULL) {
return;
}
memset(config, 0, sizeof(*config));
config->schema_version = MDNS_CONFIG_SCHEMA_VERSION;
config->blob_size = MDNS_CONFIG_BLOB_SIZE;
if (esp_read_mac(mac, ESP_MAC_WIFI_STA) != ESP_OK) {
return;
}
for (size_t index = 0U; index < sizeof(mac); ++index) {
config->suffix[index * 2U] = hex[mac[index] >> 4U];
config->suffix[index * 2U + 1U] = hex[mac[index] & 0x0fU];
}
config->suffix_len = sizeof(mac) * 2U;
}
esp_err_t mdns_config_validate(const mdns_config_t *config)
{
if (config == NULL || config->schema_version != MDNS_CONFIG_SCHEMA_VERSION ||
config->blob_size != MDNS_CONFIG_BLOB_SIZE || config->suffix_len == 0U ||
config->suffix_len > MDNS_CONFIG_SUFFIX_MAX_LEN || config->reserved != 0U ||
config->suffix[config->suffix_len] != '\0') {
return ESP_ERR_INVALID_ARG;
}
if (config->suffix[0] == '-' || config->suffix[config->suffix_len - 1U] == '-') {
return ESP_ERR_INVALID_ARG;
}
for (size_t index = 0U; index < MDNS_CONFIG_SUFFIX_MAX_LEN; ++index) {
if (index < config->suffix_len) {
if (!suffix_character_is_valid(config->suffix[index])) {
return ESP_ERR_INVALID_ARG;
}
} else if (config->suffix[index] != '\0') {
return ESP_ERR_INVALID_ARG;
}
}
return ESP_OK;
}
esp_err_t mdns_config_load(mdns_config_t *config, bool *used_stored_config)
{
if (config == NULL || used_stored_config == NULL) {
return ESP_ERR_INVALID_ARG;
}
mdns_config_defaults(config);
*used_stored_config = false;
if (mdns_config_validate(config) != ESP_OK) {
return ESP_FAIL;
}
esp_err_t error = nvs_flash_init();
if (error != ESP_OK) {
return error;
}
nvs_handle_t handle;
error = nvs_open(MDNS_CONFIG_NVS_NAMESPACE, NVS_READONLY, &handle);
if (error == ESP_ERR_NVS_NOT_FOUND) {
return ESP_OK;
}
if (error != ESP_OK) {
return error;
}
size_t size = 0U;
error = nvs_get_blob(handle, MDNS_CONFIG_NVS_BLOB_KEY, NULL, &size);
if (error == ESP_ERR_NVS_NOT_FOUND || error == ESP_ERR_NVS_TYPE_MISMATCH ||
(error == ESP_OK && size != sizeof(*config))) {
nvs_close(handle);
return ESP_OK;
}
if (error != ESP_OK) {
nvs_close(handle);
return error;
}
mdns_config_t stored = {0};
error = nvs_get_blob(handle, MDNS_CONFIG_NVS_BLOB_KEY, &stored, &size);
nvs_close(handle);
if (error == ESP_ERR_NVS_INVALID_LENGTH) {
return ESP_OK;
}
if (error != ESP_OK) {
return error;
}
if (size == sizeof(stored) && mdns_config_validate(&stored) == ESP_OK) {
*config = stored;
*used_stored_config = true;
}
return ESP_OK;
}
esp_err_t mdns_config_save(const mdns_config_t *config)
{
esp_err_t error = mdns_config_validate(config);
if (error != ESP_OK) {
return error;
}
error = nvs_flash_init();
if (error != ESP_OK) {
return error;
}
nvs_handle_t handle;
error = nvs_open(MDNS_CONFIG_NVS_NAMESPACE, NVS_READWRITE, &handle);
if (error != ESP_OK) {
return error;
}
error = nvs_set_blob(handle, MDNS_CONFIG_NVS_BLOB_KEY, config, sizeof(*config));
if (error == ESP_OK) {
error = nvs_commit(handle);
}
nvs_close(handle);
return error;
}
esp_err_t mdns_config_reset_storage(void)
{
mdns_config_t config;
mdns_config_defaults(&config);
return mdns_config_save(&config);
}
+29
View File
@@ -0,0 +1,29 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Versioned persistent configuration for the station mDNS hostname. */
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "esp_err.h"
#define MDNS_CONFIG_SCHEMA_VERSION 1U
#define MDNS_CONFIG_BLOB_SIZE 64U
#define MDNS_CONFIG_SUFFIX_MAX_LEN 55U
#define MDNS_CONFIG_NVS_NAMESPACE "mdns_cfg"
#define MDNS_CONFIG_NVS_BLOB_KEY "config"
typedef struct {
uint32_t schema_version;
uint16_t blob_size;
uint8_t suffix_len;
uint8_t reserved;
char suffix[MDNS_CONFIG_SUFFIX_MAX_LEN + 1U];
} mdns_config_t;
void mdns_config_defaults(mdns_config_t *config);
esp_err_t mdns_config_validate(const mdns_config_t *config);
esp_err_t mdns_config_load(mdns_config_t *config, bool *used_stored_config);
esp_err_t mdns_config_save(const mdns_config_t *config);
esp_err_t mdns_config_reset_storage(void);
+149
View File
@@ -0,0 +1,149 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Administration console for the station mDNS hostname. */
#include "mdns_console.h"
#include <stdio.h>
#include <string.h>
#include "esp_console.h"
#include "mdns_config.h"
#include "mdns_service.h"
#include "wifi_manager.h"
static void print_usage(void)
{
printf("Usage: mdns status|suffix <lowercase-suffix>|save|load|defaults|reset\n");
}
static int request_reannounce(void)
{
esp_err_t error = wifi_manager_mdns_reannounce();
if (error != ESP_OK) {
printf("mDNS configuration updated; it will be used when Wi-Fi STA receives an IP (%s).\n",
esp_err_to_name(error));
}
return 0;
}
static int show_status(void)
{
mdns_service_snapshot_t snapshot;
esp_err_t error = mdns_service_get_snapshot(&snapshot);
if (error != ESP_OK) {
printf("mDNS unavailable: %s\n", esp_err_to_name(error));
return 1;
}
printf("mDNS: initialized=%s announced=%s hostname=%s.local suffix=%s last-error=%s\n",
snapshot.initialized ? "yes" : "no", snapshot.announced ? "yes" : "no",
snapshot.hostname, snapshot.suffix, esp_err_to_name(snapshot.last_error));
return 0;
}
static int set_suffix(const char *suffix)
{
size_t length = strlen(suffix);
mdns_config_t config;
esp_err_t error = mdns_service_get_config(&config);
if (error == ESP_OK) {
memset(config.suffix, 0, sizeof(config.suffix));
if (length <= MDNS_CONFIG_SUFFIX_MAX_LEN) {
memcpy(config.suffix, suffix, length);
config.suffix_len = (uint8_t)length;
error = mdns_service_set_config(&config);
} else {
error = ESP_ERR_INVALID_ARG;
}
}
if (error != ESP_OK) {
printf("Suffix must be 1..%u lowercase letters, digits, or hyphens, and cannot begin or end with a hyphen.\n",
MDNS_CONFIG_SUFFIX_MAX_LEN);
return 1;
}
printf("mDNS suffix updated in RAM; hostname is sak-%s.local; use 'mdns save' to persist it.\n",
suffix);
return request_reannounce();
}
static int save_config(void)
{
mdns_config_t config;
esp_err_t error = mdns_service_get_config(&config);
if (error == ESP_OK) {
error = mdns_config_save(&config);
}
if (error != ESP_OK) {
printf("Could not save mDNS configuration: %s\n", esp_err_to_name(error));
return 1;
}
printf("mDNS configuration saved to NVS.\n");
return 0;
}
static int load_config(void)
{
mdns_config_t config;
bool stored = false;
esp_err_t error = mdns_config_load(&config, &stored);
if (error == ESP_OK) {
error = mdns_service_set_config(&config);
}
if (error != ESP_OK) {
printf("Could not load mDNS configuration: %s\n", esp_err_to_name(error));
return 1;
}
printf("Loaded %s mDNS configuration into RAM.\n", stored ? "stored" : "default");
return request_reannounce();
}
static int apply_defaults(bool persist)
{
mdns_config_t config;
mdns_config_defaults(&config);
esp_err_t error = mdns_service_set_config(&config);
if (error == ESP_OK && persist) {
error = mdns_config_save(&config);
}
if (error != ESP_OK) {
printf("Could not apply mDNS defaults: %s\n", esp_err_to_name(error));
return 1;
}
printf("MAC-derived mDNS defaults applied%s.\n", persist ? " and saved" : " in RAM");
return request_reannounce();
}
static int command_mdns(int argc, char **argv)
{
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0)) {
return show_status();
}
if (argc == 3 && strcmp(argv[1], "suffix") == 0) {
return set_suffix(argv[2]);
}
if (argc == 2 && strcmp(argv[1], "save") == 0) {
return save_config();
}
if (argc == 2 && strcmp(argv[1], "load") == 0) {
return load_config();
}
if (argc == 2 && strcmp(argv[1], "defaults") == 0) {
return apply_defaults(false);
}
if (argc == 2 && strcmp(argv[1], "reset") == 0) {
return apply_defaults(true);
}
print_usage();
return 1;
}
esp_err_t mdns_console_register_commands(void)
{
const esp_console_cmd_t command = {
.command = "mdns",
.help = "Configure the STA mDNS hostname; use 'mdns' for status",
.hint = NULL,
.func = &command_mdns,
.argtable = NULL,
};
return esp_console_cmd_register(&command);
}
+6
View File
@@ -0,0 +1,6 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_err.h"
esp_err_t mdns_console_register_commands(void);
+163
View File
@@ -0,0 +1,163 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* mDNS runtime service; its lifecycle is owned by wifi_manager. */
#include "mdns_service.h"
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "mdns.h"
static SemaphoreHandle_t s_mutex;
static mdns_config_t s_config;
static bool s_component_initialized;
static bool s_initialization_failed;
static bool s_announced;
static esp_err_t s_last_error;
static void lock_service(void)
{
(void)xSemaphoreTake(s_mutex, portMAX_DELAY);
}
static void unlock_service(void)
{
(void)xSemaphoreGive(s_mutex);
}
static void make_hostname(const mdns_config_t *config, char *hostname, size_t size)
{
(void)snprintf(hostname, size, "sak-%s", config->suffix);
}
esp_err_t mdns_service_init(const mdns_config_t *config)
{
if (mdns_config_validate(config) != ESP_OK) {
return ESP_ERR_INVALID_ARG;
}
if (s_mutex != NULL) {
return ESP_ERR_INVALID_STATE;
}
s_mutex = xSemaphoreCreateMutex();
if (s_mutex == NULL) {
return ESP_ERR_NO_MEM;
}
s_config = *config;
s_last_error = ESP_OK;
return ESP_OK;
}
esp_err_t mdns_service_get_config(mdns_config_t *config)
{
if (config == NULL || s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
lock_service();
*config = s_config;
unlock_service();
return ESP_OK;
}
esp_err_t mdns_service_set_config(const mdns_config_t *config)
{
if (s_mutex == NULL || mdns_config_validate(config) != ESP_OK) {
return ESP_ERR_INVALID_ARG;
}
lock_service();
s_config = *config;
unlock_service();
return ESP_OK;
}
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->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;
unlock_service();
return ESP_OK;
}
esp_err_t mdns_service_start(void)
{
if (s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
lock_service();
if (s_component_initialized) {
s_announced = true;
unlock_service();
return ESP_OK;
}
if (s_initialization_failed) {
esp_err_t error = s_last_error;
unlock_service();
return error;
}
mdns_config_t config = s_config;
unlock_service();
esp_err_t error = mdns_init();
if (error == ESP_OK) {
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
make_hostname(&config, hostname, sizeof(hostname));
error = mdns_hostname_set(hostname);
if (error == ESP_OK) {
error = mdns_instance_name_set("ESP32 Serial Swiss Army Knife");
}
if (error != ESP_OK) {
mdns_free();
}
}
lock_service();
s_component_initialized = error == ESP_OK;
s_initialization_failed = error != ESP_OK;
s_announced = error == ESP_OK;
s_last_error = error;
unlock_service();
return error;
}
void mdns_service_stop(void)
{
if (s_mutex == NULL) {
return;
}
lock_service();
s_announced = false;
unlock_service();
}
esp_err_t mdns_service_reannounce(void)
{
if (s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
lock_service();
if (!s_component_initialized) {
esp_err_t error = s_initialization_failed ? s_last_error : ESP_ERR_INVALID_STATE;
unlock_service();
return error;
}
mdns_config_t config = s_config;
unlock_service();
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
make_hostname(&config, hostname, sizeof(hostname));
esp_err_t error = mdns_hostname_set(hostname);
lock_service();
s_last_error = error;
unlock_service();
return error;
}
+27
View File
@@ -0,0 +1,27 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* mDNS runtime service; its lifecycle is owned by wifi_manager. */
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#include "mdns_config.h"
typedef struct {
bool initialized;
bool announced;
char suffix[MDNS_CONFIG_SUFFIX_MAX_LEN + 1U];
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U];
esp_err_t last_error;
} mdns_service_snapshot_t;
esp_err_t mdns_service_init(const mdns_config_t *config);
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);
/* Only wifi_manager may call these lifecycle operations. */
esp_err_t mdns_service_start(void);
void mdns_service_stop(void);
esp_err_t mdns_service_reannounce(void);
+35 -1
View File
@@ -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 = {
+54
View File
@@ -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)
{
+8
View File
@@ -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
View File
@@ -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);
+70 -1
View File
@@ -49,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 {
@@ -132,12 +135,77 @@ static admin_ssh_console_token_t admin_console_token(const ssh_slot_t *slot,
size_t slot_index)
{
return (admin_ssh_console_token_t){
.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 = {
@@ -924,7 +992,8 @@ 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(&token, &slot->principal);
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);
+431
View File
@@ -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);
}
+127
View File
@@ -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
+58 -33
View File
@@ -14,9 +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_security.h"
#include "web_serial_transport.h"
#include "web_server.h"
#define USER_CONSOLE_KEY_LINE_CAPACITY 256U
@@ -40,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) {
@@ -56,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};
@@ -228,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);
}
@@ -240,8 +257,9 @@ static int add_user(const char *username, const char *role_text, bool generated)
size_t password_length = 0U;
error = read_password(password, &password_length);
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));
}
@@ -249,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;
}
@@ -257,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);
}
@@ -269,9 +288,9 @@ static int change_password(const char *username, bool generated)
size_t password_length = 0U;
error = read_password(password, &password_length);
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));
}
@@ -279,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;
}
@@ -317,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) {
@@ -332,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;
@@ -425,13 +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 = 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 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;
}
@@ -443,14 +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 = 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;
}
@@ -484,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;
}
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Authenticated, bounded WebSocket frontend for the canonical admin console. */
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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 {
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;
/*
* 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);
/* 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
+162 -29
View File
@@ -1,5 +1,5 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* UART0 HTTPS lifecycle, legacy recovery credential, and certificate commands. */
/* Shared HTTPS lifecycle, legacy recovery credential, and certificate commands. */
#include "web_console.h"
@@ -7,12 +7,15 @@
#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"
static void print_usage(void)
@@ -52,13 +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: HTTP Basic over TLS 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("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);
@@ -122,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);
@@ -212,20 +323,29 @@ 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;
}
esp_err_t error = web_server_stop();
if (error != ESP_OK) {
printf("Material changed, but the old TLS server could not stop: %s\n",
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));
return 1;
}
error = web_server_start();
esp_err_t error = web_server_refresh_tls(
before->lifecycle_generation, start_if_unchanged);
if (error != ESP_OK) {
printf("Material changed, but HTTPS could not restart: %s\n",
printf("Security material changed, but HTTPS could not apply it: %s\n",
esp_err_to_name(error));
return 1;
}
@@ -249,7 +369,7 @@ static void synchronize_migrated_user(
return;
}
if (synchronized) {
(void)web_serial_transport_revoke_user(
(void)web_server_revoke_user(
(const uint8_t *)credentials->username,
credentials->username_length);
(void)ssh_transport_revoke_user(
@@ -300,15 +420,19 @@ 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_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;
esp_err_t error = web_security_reset_all(&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;
@@ -320,18 +444,7 @@ static int reset_material(void)
(int)credentials.username_length, credentials.username,
(int)credentials.password_length, credentials.password);
secure_wipe(&credentials, sizeof(credentials));
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;
return refresh_tls_after_material_change(&snapshot, true);
}
static int command_web(int argc, char **argv)
@@ -353,6 +466,23 @@ static int command_web(int argc, char **argv)
return 0;
}
if (argc == 2 && strcmp(argv[1], "stop") == 0) {
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));
return 1;
}
printf("HTTPS stop scheduled after administrative output drains.\n");
return 0;
}
esp_err_t error = web_server_stop();
if (error != ESP_OK) {
printf("Could not stop HTTPS: %s\n", esp_err_to_name(error));
@@ -369,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;
+226 -60
View File
@@ -49,6 +49,7 @@ typedef struct {
uint8_t digest[WEB_SERIAL_SHA256_BYTES];
int64_t expires_at_us;
user_principal_t principal;
web_session_ref_t session_reference;
bool active;
} web_serial_ticket_t;
@@ -68,6 +69,7 @@ typedef struct web_serial_slot {
uint32_t generation;
session_broker_client_id_t broker_client_id;
user_principal_t principal;
web_session_ref_t session_reference;
int64_t next_currentness_check_us;
bool writer;
bool hello_pending;
@@ -145,6 +147,8 @@ 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->active = false;
}
@@ -177,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])
{
@@ -257,9 +295,6 @@ static esp_err_t validate_origin(httpd_req_t *request)
esp_err_t result = httpd_req_get_hdr_value_str(
request, "Origin", origin, sizeof(origin));
if (result == ESP_ERR_NOT_FOUND) {
return ESP_OK;
}
if (result != ESP_OK) {
return ESP_ERR_INVALID_ARG;
}
@@ -280,14 +315,18 @@ static esp_err_t validate_origin(httpd_req_t *request)
return matches ? ESP_OK : ESP_ERR_INVALID_ARG;
}
static esp_err_t consume_ticket(const char *ticket,
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) {
@@ -317,6 +356,7 @@ static esp_err_t consume_ticket(const char *ticket,
web_serial_ticket_t *entry = &s_tickets[matching_index];
if (entry->active && entry->expires_at_us > now_us) {
candidate = entry->principal;
candidate_reference = entry->session_reference;
clear_ticket_locked(entry);
ticket_found = true;
}
@@ -336,9 +376,11 @@ static esp_err_t consume_ticket(const char *ticket,
if (ticket_found) {
bool current = false;
result = user_database_principal_is_current(&candidate, &current);
result = authentication_binding_is_current(
&candidate, &candidate_reference, &current);
if (result == ESP_OK && current) {
*principal = candidate;
*session_reference = candidate_reference;
*consumed = true;
add_counter(&s_counters.tickets_consumed, 1U);
}
@@ -348,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;
}
@@ -371,6 +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));
secure_wipe(&slot->session_reference,
sizeof(slot->session_reference));
slot->next_currentness_check_us = 0;
slot->writer = false;
slot->hello_pending = false;
@@ -396,6 +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));
secure_wipe(&slot->session_reference, sizeof(slot->session_reference));
slot->next_currentness_check_us = 0;
slot->writer = false;
slot->hello_pending = false;
@@ -431,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;
}
}
@@ -480,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;
@@ -518,6 +569,7 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
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;
@@ -535,21 +587,22 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
goto cleanup;
}
result = consume_ticket(ticket, &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->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;
@@ -570,9 +623,10 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
}
}
bool principal_current = false;
result = user_database_principal_is_current(&principal, &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;
@@ -601,9 +655,10 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
goto cleanup;
}
principal_current = false;
result = user_database_principal_is_current(&principal, &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;
@@ -624,9 +679,34 @@ static esp_err_t connect_websocket(httpd_req_t *request, int socket_fd)
goto cleanup;
}
principal_current = false;
result = user_database_principal_is_current(&principal, &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;
@@ -642,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;
@@ -665,14 +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)
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);
@@ -692,6 +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;
*session_reference = slot->session_reference;
}
taskEXIT_CRITICAL(&s_lock);
return valid;
@@ -803,8 +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_ref_t session_reference = {0};
if (!capture_active_session(request, &slot, &generation, &client_id,
&principal)) {
&principal, &session_reference)) {
add_counter(&s_counters.protocol_errors, 1U);
return ESP_FAIL;
}
@@ -813,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);
}
@@ -820,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 ||
@@ -827,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);
}
@@ -834,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 =
user_database_principal_is_current(&principal, &current);
secure_wipe(&principal, sizeof(principal));
esp_err_t currentness_result = authentication_binding_is_current(
&principal, &session_reference, &current);
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;
}
@@ -852,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;
}
@@ -1198,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;
@@ -1250,9 +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_ref_t session_reference = {0};
uint32_t generation = 0U;
bool check = false;
int64_t now_us = monotonic_time_us();
@@ -1262,6 +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;
session_reference = slot->session_reference;
slot->next_currentness_check_us =
now_us + WEB_SERIAL_CURRENTNESS_INTERVAL_US;
check = true;
@@ -1272,18 +1370,20 @@ static void process_principal_currentness(web_serial_slot_t *slot)
}
bool current = false;
esp_err_t result = user_database_principal_is_current(&principal, &current);
esp_err_t result = authentication_binding_is_current(
&principal, &session_reference, &current);
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)
@@ -1343,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);
@@ -1456,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;
@@ -1495,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;
@@ -1509,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();
@@ -1523,17 +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,
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';
bool current = false;
esp_err_t result = user_database_principal_is_current(principal, &current);
esp_err_t result = authentication_binding_is_current(
principal, session_reference, &current);
if (result != ESP_OK) {
return result;
}
@@ -1553,25 +1660,41 @@ esp_err_t web_serial_transport_mint_ticket(const user_principal_t *principal,
goto cleanup;
}
current = false;
result = authentication_binding_is_current(
principal, session_reference, &current);
if (result != ESP_OK || !current) {
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) {
purge_tickets_locked(now_us);
size_t selected = WEB_SERIAL_TRANSPORT_MAX_TICKETS;
int64_t oldest_expiry = INT64_MAX;
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->expires_at_us < oldest_expiry) {
oldest_expiry = entry->expires_at_us;
selected = index;
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];
clear_ticket_locked(entry);
@@ -1580,6 +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->session_reference = *session_reference;
entry->active = true;
++s_counters.tickets_issued;
stored = true;
@@ -1599,9 +1723,11 @@ cleanup:
}
esp_err_t web_serial_transport_handle_authenticated_ticket_request(
httpd_req_t *request, const user_principal_t *principal)
httpd_req_t *request, const user_principal_t *principal,
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 ||
@@ -1620,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, ticket, sizeof(ticket));
principal, session_reference, ticket, sizeof(ticket));
if (result != ESP_OK) {
secure_wipe(ticket, sizeof(ticket));
return result;
@@ -1651,6 +1777,7 @@ esp_err_t web_serial_transport_handle_authenticated_ticket_request(
return result;
}
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request)
{
if (request == NULL || request->handle == NULL) {
@@ -1762,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)
{
@@ -1810,7 +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) {
if (slot->state == WEB_SERIAL_SLOT_RESERVED ||
slot->state == WEB_SERIAL_SLOT_ACTIVE) {
slot->close_requested = true;
}
}
+18 -8
View File
@@ -11,6 +11,7 @@
#include "esp_http_server.h"
#include "session_broker.h"
#include "user_database.h"
#include "web_session.h"
#ifdef __cplusplus
extern "C" {
@@ -98,20 +99,25 @@ 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. 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,
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
* Basic authentication. Register it 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);
httpd_req_t *request, const user_principal_t *principal,
const web_session_ref_t *session_reference);
/*
* Handler for /ws/serial. Register as HTTP_GET with is_websocket=true and
@@ -126,6 +132,10 @@ 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 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);
+3471 -199
View File
File diff suppressed because it is too large Load Diff
+40 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+156
View File
@@ -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
+1181 -68
View File
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -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);
-13
View File
@@ -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;
}
-2
View File
@@ -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
View File
@@ -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(
&current, &expected_generation);
wifi_config_secure_wipe(&current, 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));
}
}
}
+167 -19
View File
@@ -17,6 +17,7 @@
#include "freertos/queue.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
#include "mdns_service.h"
#define WIFI_MANAGER_QUEUE_LENGTH 16U
#define WIFI_MANAGER_TASK_STACK_SIZE 6144U
@@ -35,6 +36,7 @@ typedef enum {
MESSAGE_COMMAND_APPLY,
MESSAGE_COMMAND_RECONNECT,
MESSAGE_COMMAND_NEXT_PROFILE,
MESSAGE_COMMAND_MDNS_REANNOUNCE,
MESSAGE_STA_CONNECTED,
MESSAGE_STA_DISCONNECTED,
MESSAGE_STA_GOT_IP,
@@ -92,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;
@@ -109,6 +113,15 @@ static void manager_task(void *context);
static void start_profile_cycle(manager_runtime_t *runtime);
static void start_next_profile(manager_runtime_t *runtime);
static void start_mdns_announcement(void)
{
esp_err_t error = mdns_service_start();
if (error != ESP_OK) {
/* Name discovery is optional; never make network or serial recovery depend on it. */
ESP_LOGW(TAG, "mDNS announcement unavailable: %s", esp_err_to_name(error));
}
}
static void lock_shared(void)
{
(void)xSemaphoreTake(s_mutex, portMAX_DELAY);
@@ -119,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;
@@ -410,6 +433,7 @@ static void mark_intentional_disconnect(manager_runtime_t *runtime)
static void stop_radio(manager_runtime_t *runtime)
{
mdns_service_stop();
if (!runtime->radio_started) {
return;
}
@@ -744,6 +768,7 @@ static void handle_got_ip(manager_runtime_t *runtime,
unlock_shared();
wifi_config_secure_wipe(&config, sizeof(config));
start_mdns_announcement();
}
static void handle_sta_disconnected(manager_runtime_t *runtime,
@@ -764,6 +789,7 @@ static void handle_sta_disconnected(manager_runtime_t *runtime,
bool had_attempt = runtime->attempt_deadline != 0;
bool was_online = runtime->online;
mdns_service_stop();
runtime->associated = false;
runtime->online = false;
runtime->attempt_deadline = 0;
@@ -868,6 +894,16 @@ static void handle_message(manager_runtime_t *runtime,
}
break;
case MESSAGE_COMMAND_MDNS_REANNOUNCE:
if (runtime->online) {
esp_err_t error = mdns_service_reannounce();
if (error != ESP_OK) {
ESP_LOGW(TAG, "mDNS reannouncement unavailable: %s",
esp_err_to_name(error));
}
}
break;
case MESSAGE_STA_CONNECTED:
if (!manager_is_started() ||
!connected_event_matches_active_profile(message)) {
@@ -900,6 +936,7 @@ static void handle_message(manager_runtime_t *runtime,
/* Ignore a delayed loss event after a newer DHCP lease. */
break;
}
mdns_service_stop();
runtime->online = false;
runtime->stable_deadline = 0;
runtime->attempt_deadline = esp_timer_get_time() + WIFI_MANAGER_ATTEMPT_US;
@@ -1252,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;
}
@@ -1265,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;
}
@@ -1273,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);
@@ -1376,30 +1423,51 @@ 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;
}
esp_err_t wifi_manager_apply_working_config(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;
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_mutex == NULL) {
if (s_shared.snapshot.config_generation == UINT32_MAX) {
unlock_shared();
return ESP_ERR_INVALID_STATE;
}
lock_shared();
bool restart_radio = config_requires_radio_restart(&s_shared.config, config);
if (restart_radio) {
manager_message_t message = {.type = MESSAGE_COMMAND_APPLY};
@@ -1408,40 +1476,115 @@ esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
return ESP_ERR_TIMEOUT;
}
}
s_shared.config = *config;
++s_shared.snapshot.config_generation;
if (s_shared.snapshot.config_generation == 0U) {
s_shared.snapshot.config_generation = 1U;
}
s_shared.snapshot.ap_policy = config->ap_policy;
++s_shared.snapshot.counters.applies;
if (resulting_generation != NULL) {
*resulting_generation = s_shared.snapshot.config_generation;
}
unlock_shared();
return ESP_OK;
}
esp_err_t wifi_manager_compare_exchange_working_config(
const wifi_app_config_t *config, uint32_t expected_generation,
uint32_t *resulting_generation)
{
if (resulting_generation != NULL) {
*resulting_generation = 0U;
}
if (expected_generation == 0U) {
return ESP_ERR_INVALID_ARG;
}
esp_err_t error = wifi_config_validate(config);
if (error != ESP_OK) {
return error;
}
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_working_config_if_generation(
uint32_t expected_generation)
{
if (expected_generation == 0U) {
return ESP_ERR_INVALID_ARG;
}
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();
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;
if (s_shared.snapshot.config_generation == 0U) {
s_shared.snapshot.config_generation = 1U;
}
}
unlock_shared();
if (writes_config) {
unlock_config_writer();
}
return ESP_OK;
}
@@ -1465,6 +1608,11 @@ esp_err_t wifi_manager_next_profile(void)
return enqueue_lifecycle_command(MESSAGE_COMMAND_NEXT_PROFILE, -1);
}
esp_err_t wifi_manager_mdns_reannounce(void)
{
return enqueue_lifecycle_command(MESSAGE_COMMAND_MDNS_REANNOUNCE, -1);
}
esp_err_t wifi_manager_get_snapshot(wifi_manager_snapshot_t *snapshot)
{
if (snapshot == NULL) {
+36 -5
View File
@@ -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,15 +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);
/*
* 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);
@@ -95,6 +124,8 @@ esp_err_t wifi_manager_stop(void);
esp_err_t wifi_manager_reconnect(void);
/* Advance to the next enabled station profile in priority order, wrapping safely. */
esp_err_t wifi_manager_next_profile(void);
/* Reannounce the configured hostname when the manager currently has a STA IP. */
esp_err_t wifi_manager_mdns_reannounce(void);
/* Snapshot data never contains station or AP passwords. */
esp_err_t wifi_manager_get_snapshot(wifi_manager_snapshot_t *snapshot);