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
Commander1024 06bf33b3cf Add Explicit Session Exit Controls Mini-Features ä2+3 2026-08-31 04:02:19 +02:00
Commander1024 c37fab67db Avoid Wi-Fi restarts for disabled profiles Fix #3 2026-08-31 03:51:24 +02:00
Commander1024 d9bd86bb84 Move RS-232 status under debug. Fix #2 from list. 2026-08-31 03:45:38 +02:00
Commander1024 2aafff25be Keep USB line coding diagnostic only. Fix cdc connect reconfiguring UART
1
2026-08-31 03:40:55 +02:00
Commander1024 6ad6c00d68 Harden cleanup and reduce internal RAM use. Phase 8C nearly validated
and somewhat stable.
2026-08-31 03:33:43 +02:00
Commander1024 868e9ebc23 Remove outdated startup and SSH banners 2026-08-30 23:20:52 +02:00
Commander1024 26e8bbe905 Revert "Remove SSH console startup instructions"
This reverts commit ec56d20b3e.
2026-08-30 23:14:18 +02:00
Commander1024 ec56d20b3e Remove SSH console startup instructions 2026-08-30 23:11:39 +02:00
Commander1024 5ecd88e19f Align UART completion with SSH behavior 2026-08-30 23:07:59 +02:00
Commander1024 35a6f32e8b Display SSH completion candidates for ambiguous prefixes 2026-08-30 23:00:01 +02:00
Commander1024 21d1b12f31 Share completion and add SSH line editing 2026-08-30 22:45:58 +02:00
Commander1024 2f383cd283 Refresh agent documentation for current behavior 2026-08-30 22:45:33 +02:00
Commander1024 7b87375980 Align Documentation With Current Firmware Behavior 2026-08-30 19:11:39 +02:00
56 changed files with 11346 additions and 1128 deletions
+1 -1
View File
@@ -39,6 +39,6 @@ pio run --target upload
pio device monitor -b 115200
```
A first migration to the custom partition table may require `pio run --target erase`, but erasing destroys persisted configuration and credentials. Never run it without explicit user approval.
The first migration from the former default/factory layout to the custom partition table requires `pio run --target erase`. Erasing destroys persisted configuration and credentials; never run it without explicit user approval.
No automated host test command is defined in the repository. Hardware validation procedures live in `docs/electrical_tests.md` and `docs/user_administration_tests.md`; do not claim they passed unless actually performed.
+8 -8
View File
@@ -10,18 +10,18 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
- ESP32-S3-WROOM-1-N16R8 module
- 16 MB flash
- 8 MB octal PSRAM
- [Adafruit MAX3243 full-pinout male RS-232 breakout](https://www.adafruit.com/product/6253)
- Adafruit MAX3243 full-pinout RS-232 breakout; the male connector version is preferred (see [Hardware wiring](docs/wiring.md) for supported connector variants)
## 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, role-based users, legacy network 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. Only initial administrator bootstrap and explicit recovery of an unavailable user database remain UART0-only. `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
+50 -37
View File
@@ -27,19 +27,19 @@ SSH role=admin ------> shared administration dispatcher <------ UART0
`app_main()` in `src/main.c` is the composition root. The implemented order matters:
1. Report PSRAM and initialize the sole secure DRBG before Wi-Fi or other radio use.
2. Initialize boot-critical RGB LED state, RS-232 ownership/static-safe hardware, diagnostics, and the shared administration dispatcher resources.
1. Report PSRAM and initialize the sole project-owned application DRBG before Wi-Fi or other radio use.
2. Initialize boot-critical RGB LED state, RS-232 ownership/static-safe hardware, diagnostics, and the shared administration dispatcher resources. RGB LED initialization is currently guarded by `ESP_ERROR_CHECK` and is therefore boot-fatal.
3. Attempt optional OLED initialization and a bounded boot animation. Display failure is nonfatal; a working display can delay later recovery services by about five seconds.
4. Initialize button diagnostics and load local-UI and serial configurations, falling back to RAM defaults on load failure.
5. Initialize the serial service, session broker, and permanent USB transport task. UART1 is not started automatically here.
6. Load/generate HTTPS material, then initialize the 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.
9. Start HTTPS and SSH only when their startup gates pass. Current code requires Wi-Fi and HTTPS security readiness for both; 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.
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.
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
@@ -56,9 +56,9 @@ Several core initializers use `ESP_ERROR_CHECK`; optional display and network/se
- `SERVICE`: serial service owns it;
- `FAULT`: cleanup could not establish a safe state; reboot is required.
Diagnostics and the production service must claim this owner before manipulating UART/GPIO state. Unsafe cleanup keeps the transceiver disabled and marks a fault rather than attempting continued operation.
The owner is cooperative rather than an interceptor for UART/GPIO APIs. Active diagnostic commands claim `PHASE0`, and the running service claims `SERVICE`; boot-time static-safe GPIO initialization and service-owned restoration of that static mode are explicit exceptions. Unsafe cleanup keeps the transceiver disabled and marks a fault rather than attempting continued operation.
Serial configuration is a working RAM value. Applying it while running performs a stop/restart and attempts rollback on failure; queued data may be discarded. Persistence is explicit through save/reset commands.
Serial configuration is a working RAM value. Applying it while running performs a stop/restart and attempts rollback on failure. Stop/reconfiguration discards and accounts serial-service RX/TX streams and task-local pending TX, but does not disconnect broker clients or clear their writer lease, events, or already-fanned output. An open USB session retries service start after a stop; existing WebSocket and role-`user` SSH sessions do not independently restart it. Persistence is explicit through save/reset commands.
## Session broker and data flow
@@ -84,29 +84,37 @@ Only the generation-safe client ID holding the current writer lease may enqueue
Broker events are advisory bounded notifications. Transports reconcile against authoritative snapshots because an event queue can overflow. `DTR_ON_CONNECT` follows whether any broker client is connected, not writer ownership.
The broker currently enters nonblocking serial read/write APIs while holding its mutex and takes the serial-service state mutex during first-connect/last-disconnect DTR changes. Keep this ordering acyclic: serial-service code must not call broker APIs while holding its state mutex.
## Transport architecture
### USB CDC
`usb_cdc_transport` has a permanent transport task and TinyUSB callbacks. Attached plus host DTR asserted is treated as open. Opening starts the serial service if necessary, creates the `usb-cdc` broker client, and opportunistically requests writer ownership; otherwise USB observes.
TinyUSB callbacks enqueue/copy data and state; the transport task owns broker lifecycle and forwarding. Supported host line coding can update the RAM serial configuration only while USB is writer, the serial service is running, and TX is empty. It is not automatically persisted.
TinyUSB callbacks enqueue/copy data and state; the transport task owns broker lifecycle and forwarding. The line-coding callback records the latest host setting for diagnostics only. It never reconfigures UART1: physical framing and speed remain controlled by the explicit serial configuration, regardless of USB writer ownership.
### 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`, not the legacy recovery credential. 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.
A WebSocket connection requires a one-time, 30-second, principal-bound ticket. 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.
The serial WebSocket requires a one-time exact-browser-session-bound ticket with a maximum 30-second lifetime. Tickets are stored as digests, consumed before currentness validation, and never persisted. Admission and active input revalidate both the copied principal and exact browser-session reference. An admitted serial connection starts the service if necessary, creates a broker client, and opportunistically requests writer ownership. The serial transport has two fixed slots and four ticket slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the permanent transport task mediates broker work through bounded scheduling.
Web 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.
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_ui.c` contains authored index/application strings. `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.
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.
### SSH
`ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. One owner task pinned to core 1 is the only project task that calls wolfSSH APIs. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
`ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`.
@@ -115,7 +123,7 @@ Routing follows the authenticated role:
- `user`: start the serial service if necessary, then create a broker-backed binary-transparent serial stream and opportunistically request writer ownership;
- `admin`: bounded administration console, with no broker client or writer lease.
There is no `exec`, SFTP, SCP, subsystem, agent forwarding, or TCP forwarding support.
A shell request is required, but project code does not explicitly require a PTY. Exec and subsystem requests are rejected, and there is no project SFTP, SCP, agent-forwarding, or TCP-forwarding route.
## Authentication, authorization, and revocation
@@ -125,42 +133,47 @@ Network code holds copied, secret-free principals rather than pointers into data
Revocation has two layers:
1. command-layer account mutations explicitly request targeted WebSocket/SSH revocation;
2. transports periodically and at sensitive boundaries recheck principal currentness, providing fail-safe closure if notification fails.
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. The legacy `web_sec` username/password remains migration/recovery material after bootstrap and no longer authenticates HTTPS or SSH.
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. 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. 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.
Self-affecting remote actions such as reboot, stopping SSH, disconnecting sessions, or replacing the host key are deferred until acknowledgement output drains. UART0 invokes these synchronously. Completion candidates are manually maintained and can drift from command registration.
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.
`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`;
@@ -170,21 +183,21 @@ Configuration modules generally choose RAM defaults without erasing incompatible
## Local UI and hardware boundaries
`board_pins.h` centralizes GPIO/UART/I2C assignments. `local_display` solely owns I2C0, the SSD1315-compatible OLED, its static framebuffer, and display mutex. Display frames belong to the initiating task. Dirty-page commits and I2C transactions are bounded.
`board_pins.h` centralizes project-assigned RS-232, diagnostic, RGB LED, and local-UI hardware resources; UART0 GPIOs remain local to `main.c`, and native USB uses platform wiring. `local_display` solely owns I2C0, the SSD1315-compatible OLED, its static framebuffer, and display mutex. Display frames belong to the initiating task. Dirty-page commits and I2C transactions are bounded.
`local_status_ui` is a permanent 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.
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.
A missing or failed OLED is nonfatal. A fresh button press can request one bounded reprobe. 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.
## Concurrency and lifecycle constraints
- Broker, USB, web-transport, Wi-Fi, local-UI, and SSH owner tasks are firmware-lifetime tasks; stopping a service generally stops its runtime/listener, not the owner task.
- 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.
- Library/hardware ownership is centralized: serial task owns UART1 while running, display service owns I2C/framebuffer, SSH owner task owns wolfSSH 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.
- Avoid holding service/database/broker locks across I2C, network sends, or other potentially long operations unless the existing contract explicitly requires it.
- Broker and selected cryptographic allocations prefer PSRAM but can fall back to internal RAM. FreeRTOS control structures and task stacks intentionally remain internal where flash/cache-disable safety matters.
- 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. 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.
+45 -41
View File
@@ -14,12 +14,12 @@ This is a semantic map, not a complete file inventory. Start here, then read the
## Secure randomness
**Responsibility:** provide the sole mutex-serialized device DRBG, seeded before Wi-Fi/radio use.
**Responsibility:** provide the sole project-owned, mutex-serialized application DRBG, seeded before Wi-Fi/radio use.
- Files: `src/secure_random.{h,c}`
- Interfaces: `secure_random_init()`, random-byte helpers, `secure_wipe()`
- Called by: HTTPS material, SSH keys, users, Wi-Fi defaults, tickets and authentication cache
- Constraint: initialization order is security-significant; do not add independent weak RNGs or radio-dependent early entropy paths.
- Called by: HTTPS material, SSH keys, users, Wi-Fi defaults, tickets, the HTTPS authentication cache, and the wolfCrypt seed callback
- Constraint: initialization order is security-significant; the DRBG deliberately avoids post-radio reseeding and fails closed at its generation limit. Do not add independent weak RNGs or radio-dependent early entropy paths.
## Physical RS-232 and serial service
@@ -29,8 +29,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Interfaces: owner claim/release/fault; serial init/start/stop/read/write/configuration/snapshots; versioned NVS load/save
- Normal data caller: `session_broker`; USB, WebSocket, role-`user` SSH, console, and local UI also call serial lifecycle/configuration APIs as appropriate
- Dependencies: ESP-IDF UART driver, `board_pins.h`, NVS
- Ownership: diagnostics use `PHASE0`; service uses `SERVICE`; unsafe cleanup marks `FAULT` until reboot.
- Lifecycle: runtime reconfiguration stops/restarts UART and may discard bounded queued data.
- Ownership: the cooperative owner arbitrates active diagnostics (`PHASE0`) against the service (`SERVICE`); boot-time static-safe GPIO initialization and service-owned static-mode restoration are explicit exceptions. Unsafe cleanup marks `FAULT` until reboot.
- Lifecycle: stop/reconfiguration discards serial-service RX/TX and task-local pending bytes, but leaves broker clients, writer ownership, events, and already-fanned output intact. The 16 KiB RX and 8 KiB TX payloads prefer PSRAM; their FreeRTOS controls and UART driver storage remain internal.
## Session broker
@@ -54,20 +54,21 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Dependencies: TinyUSB, broker, serial service
- Flow: `USB host <-> USB task <-> broker`
- Lifecycle: permanent owner task; broker client exists only while attached with host DTR asserted.
- Constraint: host line coding is accepted only while USB is writer and is RAM-only.
- Constraint: callbacks retain the latest host line coding only for diagnostics; it never reconfigures UART1. UART1 framing and speed remain controlled by the serial configuration and its explicit persistence commands.
## Web and WebSocket serial
**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, Wi-Fi reachability, mbedTLS/HTTPS server
- 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.
- Dependencies: user database, secure random, broker, successful Wi-Fi manager initialization at boot, mbedTLS/HTTPS server; actual network reachability is an operational prerequisite, not an initializer invariant
- Flow: `browser -> HTTPS login 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
@@ -79,55 +80,55 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Called by: startup, network clients, user revocation, console/local UI
- Dependencies: user database, broker, admin SSH console, secure random, wolfSSH/wolfSSL; current boot start gate also depends on `web_security` readiness
- Flow: role `user` -> broker; role `admin` -> `admin_ssh_console`
- Ownership: one task pinned to core 1 is the sole wolfSSH caller; two fixed generation-tagged slots.
- Security constraint: shell/PTY only; no exec, file transfer, forwarding, or subsystems.
- Ownership: after caller-side library initialization, one task pinned to core 1 owns runtime wolfSSH contexts/sessions; two fixed generation-tagged slots.
- Security constraint: an interactive shell request is required; exec and subsystems are rejected, and no project file-transfer or forwarding route exists. PTY is not explicitly required.
## Users, authentication, and authorization
**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}`
- Interfaces: init/migration/recovery, authenticate, principal-currentness, account/password/role/key mutations, snapshots
- Called by: web and SSH authentication; console administration; transport revocation checks
- Dependencies: NVS, secure random, mbedTLS cryptography, web/SSH revocation hooks at command layer
- Ownership: database mutex protects live records; password authentication runs PBKDF2 outside it and revalidates afterward, while mutation locking must be checked per operation.
- Authorization: UART0 owns bootstrap/recovery; current admins may use admin SSH for operational commands; 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()`
- Ownership: dispatcher is sole `esp_console_run()` caller; SSH owner remains sole wolfSSH caller.
- Lifecycle: remote session tokens include slot generation; output/history/prompt buffers are fixed and wiped on close.
- Constraint: one slow command or prompt serializes all administration. Remote self-affecting actions use deferred control after output drain.
- 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 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.
- 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 read-only status plus constrained button actions.
**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.
- Lifecycle: low-priority permanent UI task; optional OLED failures are nonfatal and recover through a bounded reprobe.
- 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.
## Hardware and diagnostics
@@ -136,10 +137,10 @@ This is a semantic map, not a complete file inventory. Start here, then read the
- Files: `src/board_pins.h`, `src/rs232_hw_test.{h,c}`, `src/local_ui_hw_test.{h,c}`, `src/status_led.{h,c}`
- Documentation: `docs/wiring.md`, `docs/electrical_tests.md`
- Called by: startup and `debug`/`status` commands
- Called by: startup and `debug` commands
- Dependencies: physical RS-232 owner, serial/display services, ESP-IDF GPIO/UART/I2C/LED drivers
- Ownership: RS-232 diagnostics refuse to run while the service owns the port; display diagnostics reuse `local_display`.
- Constraint: wiring and voltage assumptions are safety-relevant; verify target hardware before running diagnostics.
- Constraint: wiring and voltage assumptions are safety-relevant; verify target hardware before running diagnostics. RGB LED initialization is currently boot-fatal, and its colors report diagnostic state rather than aggregate firmware health.
## Where should I look?
@@ -154,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 |
+24 -39
View File
@@ -4,69 +4,54 @@ This file is working memory. Update it during active work and before handoff; do
## Development state
Based on current 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.
The normal build is `pio run`. No automated host/unit-test command is defined in the repository; important validation is hardware-oriented.
## Recent memory audit
## Implemented capability summary
- One UART1/MAX3243 RS-232 service with RAM working configuration, explicit persistence commands, and explicit start/stop.
- Generation-safe broker with up to eight clients, one writer, multiple observers, bounded per-client output/events, and drop accounting.
- Native USB CDC-ACM, two browser WebSocket sessions over HTTPS, and two SSH slots.
- SSH role routing: users receive serial; administrators receive the shared bounded administration shell.
- Four-profile station Wi-Fi plus off/fallback/always AP policies and network diagnostics.
- Eight-user role database, three Ed25519/P-256 keys per user, PBKDF2 password verifiers, copied principals, and targeted revocation.
- Self-signed HTTPS identity, separate SSH host key, one-time WebSocket tickets, and fail-closed authentication when user storage is unavailable.
- UART0/admin SSH serialized command registry with transport-aware prompts, bounded remote output/history, and deferred self-affecting SSH actions.
- Optional SSD1315-compatible OLED status/control interface with persisted inactivity settings and bounded failure recovery.
- Hardware diagnostics for MAX3243/UART flow control and OLED/buttons.
- Fixed failed-initialization ownership leaks for wolfSSH, partial HTTPS startup, and TinyUSB teardown. Failed teardown now retains ownership and blocks unsafe duplicate initialization.
- Serial-service RX/TX stream payloads (16 KiB and 8 KiB effective capacity) now prefer PSRAM with internal fallback; FreeRTOS controls and UART driver buffers remain internal.
- 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.
- 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.
## TODO/FIXME survey
No authored `src/*.{c,h}` `TODO`, `FIXME`, `XXX`, or `HACK` markers were found during the initial architecture analysis. A TODO inside vendored `web_assets/xterm.css` is upstream asset content and not project work.
- 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
These observations should be checked when touching the relevant area; they are not automatically bugs requiring unrelated cleanup.
- `src/main.c` logs a Phase 7E startup banner although the implementation/roadmap is at Phase 8C.
- `docs/command_reference.md` calls `web credentials show` physical-console-only in one row, while current source and surrounding text allow it to authenticated admin SSH.
- The same reference describes interactive `user key add <username>` as a physical UART0 prompt, but transport-neutral prompt code allows it over admin SSH.
- Historical Phase 7 electrical-test steps expect Select to remain read-only and describe a powered state before automatic OLED initialization; current firmware has local controls and initializes the display during boot.
- `README.md`'s NVS partition-purpose list omits the `local_ui/config` blob.
- `README.md` links Adafruit product 6253, while `src/board_pins.h` and `docs/wiring.md` identify product 5988. **Needs verification:** whether this is an intentional male/female breakout distinction.
- `docs/user_administration_tests.md` contains historical Phase 8A/8B behavior that differs from current Phase 8C admin SSH routing. Treat phase-specific sections as historical procedures.
- Manual completion candidates omit implemented `wifi next-profile`.
- Some source comments still call shared commands UART0-only or call the current local status/control task read-only.
- `USER_DATABASE_LOAD_EMPTY` and the corresponding `main.c` log branch appear reserved or vestigial; the current missing-storage success path migrates valid legacy credentials. **Needs verification** before removing or repurposing.
- `USER_DATABASE_LOAD_EMPTY` is only an initialization/failure sentinel at the checked-in revision: every successful `user_database_init()` path returns `STORED` or `MIGRATED_LEGACY`, so `main.c`'s successful "new empty" log branch is unreachable.
- SSH startup is currently gated on successful `web_security` initialization even though SSH uses separate host-key material. **Needs verification:** whether this coupling is intentional recovery policy or an accidental startup dependency.
## Items to verify in future work
- 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.
- Revalidate software-crypto/watchdog behavior before changing crypto acceleration or PSRAM placement.
- Verify the Adafruit breakout product identity and reconcile hardware documentation.
- If HTTPD concurrency configuration changes, verify whether the boot-local Basic-authentication cache needs explicit locking.
- Treat serial-service exclusivity as an architectural contract: its public read/write APIs do not themselves prove that only the broker calls them.
- 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
No active task recorded.
- **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
+92 -22
View File
@@ -8,7 +8,7 @@ Only constraints supported by implementation or current project documentation be
**Rationale/evidence:** The broker is initialized after the serial service and all transport implementations connect broker clients. It is the normal serial RX consumer and TX gate. Project documentation requires one writer and multiple observers.
**Consequence for future changes:** New serial transports must become broker clients. Do not bypass writer checks or consume `serial_service` RX directly. Preserve binary transparency and avoid in-band ownership control.
**Consequence for future changes:** New serial transports must become broker clients. Do not bypass writer checks or consume `serial_service` RX directly. `serial_service_start()` is not idempotent, so admission code must reconcile check/start races as the existing transports do. Broker paths enter serial-service APIs while holding the broker mutex; preserve that lock order and do not call back into the broker while holding the serial state mutex. Preserve binary transparency and avoid in-band ownership control.
**Relevant files:** `src/session_broker.{h,c}`, `src/serial_service.{h,c}`, `src/usb_cdc_transport.c`, `src/web_serial_transport.c`, `src/ssh_transport.c`
@@ -28,19 +28,19 @@ Only constraints supported by implementation or current project documentation be
**Rationale/evidence:** The code has explicit `NONE`, `PHASE0`, `SERVICE`, and `FAULT` hardware states plus broker client/writer IDs.
**Consequence for future changes:** A writer lease never authorizes direct UART/GPIO access. Hardware tests must claim `PHASE0`; production service must claim `SERVICE`. Ambiguous cleanup must keep the transceiver safe and require reboot rather than clearing fault casually.
**Consequence for future changes:** A writer lease never authorizes direct UART/GPIO access. Active hardware tests must claim `PHASE0`; the production service must claim `SERVICE`. Boot-time static-safe GPIO setup and service-owned static-mode restoration are explicit exceptions to this cooperative gate. Ambiguous cleanup must keep the transceiver safe and require reboot rather than clearing fault casually.
**Relevant files:** `src/rs232_port_owner.{h,c}`, `src/rs232_hw_test.c`, `src/serial_service.c`, `src/session_broker.c`
## 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
@@ -72,35 +72,105 @@ Only constraints supported by implementation or current project documentation be
**Relevant files:** `src/admin_ssh_console.c`, `src/main.c`, `src/console_input.c`, all `src/*_console.c`
## Self-affecting admin SSH actions drain output before execution
## Selected self-affecting admin SSH actions use bounded deferred control
**Decision:** Remote reboot, SSH stop/disconnect, and host-key replacement are deferred until acknowledgement output leaves the administration and transport buffers.
**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. Immediate execution would sever the session before confirmation is delivered.
**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.
**Consequence for future changes:** Commands that invalidate their own transport/session must integrate with deferred control rather than acting synchronously from the dispatcher. Prevent new input while the action is pending.
**Consequence for future changes:** Actions that would invalidate their own SSH transport should integrate with deferred control when acknowledgement preservation matters. Prevent new input while an action is pending, keep the wait bounded, and do not describe it as guaranteed delivery.
**Relevant files:** `src/admin_ssh_console.c`, `src/system_console.c`, `src/ssh_console.c`, `src/ssh_transport.c`
## Authentication uses copied principals and fail-safe currentness checks
**Decision:** Network sessions retain secret-free copied principals. Account mutations invalidate generations/IDs, explicitly request targeted transport revocation at the command layer, and rely on ongoing currentness checks as the fail-safe.
**Decision:** Network sessions retain secret-free copied principals. Account mutations invalidate generations/IDs; after commit, the command layer requests best-effort targeted transport revocation, while ongoing currentness checks are authoritative.
**Rationale/evidence:** `user_database` issues principals without secrets; web/SSH check currentness during admission and active sessions. Mutating console paths call transport revocation hooks.
**Consequence for future changes:** Do not retain pointers to database records or treat login as permanently authoritative. New authenticated sessions/transports must revalidate at admission, before sensitive input, and periodically or on relevant events. Database mutation APIs alone do not perform transport notification.
**Consequence for future changes:** Do not retain pointers to database records or treat login as permanently authoritative. New authenticated sessions/transports must revalidate at admission, before sensitive input, and periodically or on relevant events. Database mutation APIs alone do not perform transport notification, and notification failure must not roll back an already committed mutation.
**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.
**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, atomic commit-or-fail for user and identity mutation.
**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
@@ -134,11 +204,11 @@ Only constraints supported by implementation or current project documentation be
## Hardware and library access has designated owners
**Decision:** The serial task owns UART1 while active, `local_display` owns I2C/framebuffer access, the SSH owner task alone calls wolfSSH, and the console dispatcher alone runs registered commands.
**Decision:** The serial task owns UART1 while active, `local_display` owns I2C/framebuffer access, the SSH owner task owns runtime wolfSSH contexts/calls after caller-side library initialization, and the console dispatcher alone runs registered commands.
**Rationale/evidence:** These constraints are enforced by module structure, mutex/task assertions, and transport indirection. Original rationale varies; the observable effect is serialized library/hardware access.
**Consequence for future changes:** Cross-task requests should use existing queues/public APIs. Do not call wolfSSH, mutate display frames, or run console handlers from arbitrary tasks.
**Consequence for future changes:** Cross-task requests should use existing queues/public APIs. Do not make post-initialization wolfSSH calls, mutate display frames, or run console handlers from arbitrary tasks.
**Relevant files:** `src/serial_service.c`, `src/local_display.c`, `src/ssh_transport.c`, `src/admin_ssh_console.c`
@@ -146,7 +216,7 @@ Only constraints supported by implementation or current project documentation be
**Decision:** wolfSSL ESP32 AES/SHA acceleration is disabled, and HTTPS uses software AES for PSRAM-backed TLS records. Internal task stacks are retained where cache-disable safety matters.
**Rationale/evidence:** Root `CMakeLists.txt` disables wolfSSL hardware crypto. The roadmap records a reproduced watchdog stall in mbedTLS external-RAM hardware-AES DMA and uncoordinated mbedTLS/wolfSSL hardware locks; the software-crypto build passed the documented concurrency retest.
**Rationale/evidence:** Root `CMakeLists.txt` disables wolfSSL hardware crypto. The roadmap reports a reproduced watchdog stall involving mbedTLS external-RAM hardware-AES DMA, uncoordinated mbedTLS/wolfSSL hardware locks, and a successful software-crypto concurrency retest; no standalone execution record is checked in.
**Consequence for future changes:** Do not remove these definitions as a performance cleanup. Any re-enablement needs target-hardware concurrency testing with simultaneous USB, WebSocket, SSH, and serial traffic plus watchdog/stack telemetry.
@@ -158,6 +228,6 @@ Only constraints supported by implementation or current project documentation be
**Rationale/evidence:** `src/CMakeLists.txt` lists generated data as a source, and `web_assets/SOURCES.md` documents pinned versions, hashes, and deterministic gzip inputs.
**Consequence for future changes:** Edit authored web UI separately. When dependency assets change, follow the documented provenance/generation process and review generated diffs; do not hand-edit arrays or regenerate assets during unrelated work.
**Consequence for future changes:** Edit authored web UI separately. Changes to its inline bootstrap loader must update the hard-coded CSP hash atomically and preserve the response security policy. When dependency assets change, follow the documented provenance/generation process and review generated diffs; do not hand-edit arrays or regenerate assets during unrelated work.
**Relevant files:** `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py`, `src/web_assets_data.{h,c}`, `src/web_ui.c`
+34 -12
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. Only initial administrator bootstrap and explicit recovery of an unavailable user database remain physically bound to UART0. 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
@@ -8,7 +8,7 @@ UART0 and authenticated `admin` SSH sessions use the same registered command imp
|---|---|
| `memory` | Show free memory, minimum free memory, and largest blocks for internal RAM, DMA-capable RAM, and PSRAM. |
| `reboot` | Drain console output briefly and restart the ESP32. |
| `status` | Show quick MAX3243 signal state. |
| `exit` | Close the current administrative SSH session after its acknowledgement drains; unavailable on UART0. Ctrl+D on an empty admin SSH command line does the same. |
## Role-based users
@@ -24,7 +24,7 @@ UART0 and authenticated `admin` SSH sessions use the same registered command imp
| `user role <username> <user|admin> --force` | Change a role; the final administrator cannot be demoted. |
| `user password <username>` | Set and confirm a new password without echo. |
| `user password <username> --generate` | Replace a password with a generated value displayed once. |
| `user key add <username>` | Prompt on physical UART0 for one bounded OpenSSH public-key line. |
| `user key add <username>` | Prompt on UART0 or authenticated admin SSH for one bounded OpenSSH public-key line. |
| `user key add <username> <type> <base64>` | Import a key non-interactively; intended for authenticated admin SSH and also accepted on UART0. |
| `user key delete <username> <0..2> --force` | Delete one key by the index shown by `user show`. |
| `user key clear <username> --force` | Delete all public keys for an account. |
@@ -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
@@ -85,7 +87,7 @@ Each client has a generation-safe ID. There can be one writer and multiple obser
| `usb counters` / `usb clear-counters` | Show or clear USB counters. |
| `usb request-writer` / `usb release-writer` | Request or release USB writer ownership. |
Opening `/dev/ttyACM*` with DTR asserted creates the `usb-cdc` broker client, starts UART1 if needed, and requests writer ownership. It becomes an observer if another client is writer. USB data is binary-transparent. Supported host line coding is 1101000000 baud, 7/8 data bits, none/odd/even parity, and 1/2 stop bits; settings apply only while USB owns the writer lease and are not saved automatically.
Opening `/dev/ttyACM*` with DTR asserted creates the `usb-cdc` broker client, starts UART1 if needed, and requests writer ownership. It becomes an observer if another client is writer. USB data is binary-transparent. The host's CDC line coding is shown by `usb status` for diagnostics only; it does not alter UART1. Configure physical baud rate, framing, flow control, and DTR explicitly with `serial` commands and persist them with `serial save`.
## Wi-Fi
@@ -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. 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 physical-console-only legacy migration/recovery credential; it is not a Phase 8B 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,16 +161,17 @@ 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.
Admin SSH supports four-entry per-session command history with Up/Down, bounded whole-line Tab completion, Backspace/Ctrl-C, and visible or no-echo interactive prompts. 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.
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.
`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. Only `user bootstrap` and `user recover --force` remain UART0-only. A connected administrator still 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
| Command | Description |
|---|---|
| `debug status` | Show MAX3243 driver, receiver, VLD, and shutdown states. It requires UART1 to be stopped. |
| `debug transceiver <enable|disable>` | Enable or shut down the MAX3243. |
| `debug drivers <tx 0|1> <dtr 0|1> <rts 0|1>` | Drive static TX, DTR, and RTS logic levels for measurement. |
| `debug loopback-a` / `debug loopback-b` | Test MAX3243 driver/receiver loopback configurations. |
+10 -8
View File
@@ -1,6 +1,6 @@
# Electrical tests
These procedures verify the Phase 7A OLED and buttons, MAX3243 breakout, UART1 data path, hardware flow control, and session broker. They are manual tests: the firmware never starts one automatically.
These manual procedures verify the OLED and buttons, MAX3243 breakout, UART1 data path, hardware flow control, and session broker. The firmware does not start diagnostic commands automatically, but normal boot does initialize the OLED, play the bounded identity animation, and start the local status/control UI when the hardware responds.
> **Safety:** With power removed, install only the wiring required by the selected test. DE-9 pins 3 (`TX`), 4 (`DTR`), and 7 (`RTS`) are driven outputs. Never connect one of these outputs to another driven output. Keep temporary Dupont wiring short and secure. Power the OLED only from 3.3 V because module-mounted I²C pull-ups may connect SDA and SCL to the OLED `VCC` rail.
@@ -20,11 +20,11 @@ Disconnect both USB connectors and every other power source before checking or c
### 2. Powered idle checks and address probe
Apply power, but do not initialize the OLED yet.
Apply power and allow normal boot to complete. A connected, responsive OLED is normally initialized automatically and may already show the status UI.
1. Measure OLED `VCC` relative to `GND`; it should be approximately 3.3 V.
2. Measure idle SDA on GPIO11 and idle SCL on GPIO12. Both should be near 3.3 V. Power down immediately if either bus line rises toward 5 V; correct the OLED supply or pull-up wiring before continuing.
3. Run `debug display status` and record the diagnostic state.
3. Run `debug display status` and record whether normal boot initialized the display.
4. Run `debug display probe`. Confirm that it tests only 7-bit `0x3c` and `0x3d` at the initial 100 kHz bus rate. The tested module should acknowledge at `0x3c` (8-bit `0x78` write / `0x79` read).
If the expected address does not respond, treat the result as a nonfatal hardware finding. On this dedicated local-UI bus, `debug display scan --force` may identify an unexpected address before further investigation. Otherwise leave the serial core running, power down, and recheck 3.3 V power, common ground, SDA/SCL order, solder joints, and pull-ups. Do not scan a bus shared with unrelated I²C devices.
@@ -103,6 +103,8 @@ With all buttons released, run `debug buttons status`. Confirm previous/back GPI
Run `debug buttons test` for the default 10-second interval. During the test, press and release each button separately with a deliberate short press, then repeat with a sustained long press. Confirm that the correct button and short/long classification are reported exactly once per intended action.
> **Live-UI caution:** The normal local status/control task continues to process button input during this diagnostic. Begin on a status page, watch the OLED, and cancel any Controls confirmation that opens. Do not leave a disruptive action selected while testing long holds.
Repeat with an explicit duration, for example:
```text
@@ -118,9 +120,9 @@ Use the longer run to check:
`debug buttons test [seconds]` accepts 1 through 30 seconds and defaults to 10 seconds when omitted. Record unexpected event duplication, missed transitions, incorrect GPIO mapping, false long presses, or a test that fails to terminate.
### 8. Read-only status UI (Phase 7C)
### 8. Status pages
After boot, the OLED starts on the **OVERVIEW** page. A short previous/back press on GPIO10 and a short next press on GPIO14 must wrap through these read-only pages:
After boot, the OLED starts on the **OVERVIEW** page. A short previous/back press on GPIO10 and a short next press on GPIO14 must wrap through these status pages:
1. **OVERVIEW** — serial, broker, USB, HTTPS/WebSocket, SSH, and Wi-Fi summary.
2. **RS232 MODEM** — framing, modem inputs, byte counters, queue depth, drops, and faults.
@@ -129,7 +131,7 @@ After boot, the OLED starts on the **OVERVIEW** page. A short previous/back pres
Confirm that each page remains entirely within the blue content panel. The yellow panel must retain the same fixed icon positions while pages change: serial, segmented Wi-Fi strength, USB, WebSocket, SSH, broker clients, and alert. Active icons are solid with a value below; inactive icon slots remain completely blank and omit their value. Confirm that page text uses readable upper/lowercase, separates items and values with colons, and displays the active station SSID or AP SSID with its channel on the Overview and Network pages.
The UI refreshes at 4 Hz maximum and is read-only: select (GPIO13) must not change a service, acquire/release a writer, inject serial data, or expose passwords, credentials, tickets, or key material. With UART0, USB CDC, WebSocket, and SSH active, navigate pages and confirm that the display continues to update without disrupting serial traffic or UART0 recovery.
The UI refreshes at 4 Hz maximum. Status pages are observational and must not expose passwords, credentials, tickets, or key material. A short Select press opens the separate Controls menu described below; merely navigating status pages must not change a service, alter writer ownership, or inject serial data. With UART0, USB CDC, WebSocket, and SSH active, navigate pages and confirm that the display continues to update without disrupting serial traffic or UART0 recovery.
For the OLED-aging policy, leave all three buttons untouched and verify:
@@ -138,7 +140,7 @@ For the OLED-aging policy, leave all three buttons untouched and verify:
3. The first debounced press of any button wakes the OLED at contrast `127` without changing the current page.
4. A subsequent previous/next press navigates normally and restarts the inactivity timers.
`debug display status` may verify an individual transition in a separate timing run. Every display diagnostic counts as activity and holds normal UI rendering for 30 seconds, so do not invoke it between the five- and ten-minute observations of one continuous run. Record a missing, stale, clipped, or implausible status value or incorrect dim/off/wake transition before Phase 7C is marked complete.
`debug display status` may verify an individual transition in a separate timing run. Every display diagnostic counts as activity and holds normal UI rendering for 30 seconds, so do not invoke it between the five- and ten-minute observations of one continuous run. Record any missing, stale, clipped, or implausible status value or incorrect dim/off/wake transition.
### 9. Local controls (Phase 7D)
@@ -274,7 +276,7 @@ Each output should be negative. Exact voltages depend on supply, load, meter, an
## Recommended order
1. With no DE-9 jumpers, run `status`.
1. With no DE-9 jumpers and after `serial stop`, run `debug status`.
2. Measure output polarity with `debug drivers 0 0 0` and `debug drivers 1 1 1`.
3. Power down, install configuration A, power up, and run `debug loopback-a`.
4. Run `debug valid-test` with configuration A still installed.
+62 -132
View File
@@ -38,14 +38,14 @@ 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** |
| 12 | Advanced network integration | **Under evaluation** |
| 13 | Optional filesystem-backed features | **Under evaluation** |
## Completed foundation
## Completed phases
### Phase 0 — RS-232 hardware characterization
@@ -57,7 +57,7 @@ Implemented and hardware-validated:
- Hardware CTS transmit gating and RTS receive-backpressure tests.
- Exclusive RS-232 port ownership and fault-safe cleanup.
- RGB status indication for diagnostic state.
- Potentially disruptive tests isolated under the `debug` CLI submenu; top-level `status` retained.
- Phase 0 hardware diagnostics, including signal status, are isolated under the `debug` CLI submenu.
Detailed procedures are in [Electrical tests](electrical_tests.md) and [Hardware wiring](wiring.md).
@@ -93,7 +93,7 @@ Implemented and hardware-validated:
- Automatic broker connection when the host opens the port with DTR asserted.
- Opportunistic writer acquisition with observer fallback.
- Binary-transparent bidirectional serial data.
- Supported host line-coding application while USB owns the writer lease.
- Host line-coding visibility for diagnostics; UART1 remains under explicit serial configuration control.
- Disconnect cleanup, bounded buffering, counters, and UART0 lifecycle controls.
### Phase 4 — Wi-Fi foundation
@@ -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
@@ -116,7 +121,7 @@ A WPA3-required profile correctly rejects an AP that does not advertise the requ
Implemented and hardware-validated:
- HTTPS-only service on TCP port 443 using ESP-IDF mbedTLS.
- Persistent random shared `admin` credential.
- Persistent random shared `admin` credential, later retained only as migration/recovery material by Phase 8.
- Persistent device-specific ECDSA P-256 certificate and private key.
- Physical-console credential retrieval, rotation, certificate inspection, rotation, and recovery.
- Explicit failure behavior that preserves UART0, USB, serial, and Wi-Fi recovery paths.
@@ -136,156 +141,81 @@ 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.
## Latest completed phase
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
The SSH implementation is complete and hardware-validated. It provides:
Implemented and target-hardware validated:
- wolfSSH on TCP port 22 while HTTPS remains on mbedTLS.
- Password authentication using the shared `admin` credential.
- A separate persistent ECDSA P-256 SSH host key and OpenSSH-compatible fingerprint.
- Interactive shell/PTY channels only.
- No `exec`, subsystem, SFTP, SCP, agent forwarding, or TCP forwarding.
- Two fixed session/handshake slots.
- Three password attempts and a 15-second handshake deadline.
- No broker client before successful authentication and shell negotiation.
- Binary-transparent serial forwarding with no in-band writer-control protocol.
- Opportunistic writer acquisition and observer behavior through the common broker.
- Credential-rotation revocation and UART0 session/host-key administration.
- A single bounded wolfSSH owner task pinned to CPU 1.
- wolfSSL/wolfSSH allocations that prefer PSRAM with internal-memory fallback.
- A per-call wolfSSH receive budget to prevent pipelined input from monopolizing the owner task.
- Bounded broker and WebSocket work bursts that guarantee scheduler/idle progress during sustained serial traffic.
- Software AES for PSRAM-backed mbedTLS records plus software wolfCrypt AES/SHA, preventing mbedTLS and wolfSSL from independently reconfiguring shared ESP32 crypto hardware.
- Internal task stacks retained for safety during flash/NVS cache-disable windows.
- Heap and SSH stack telemetry through `memory` and `ssh status`.
SSH login, host-key verification, serial traffic, Web Serial coexistence, and USB CDC coexistence have been validated. During concurrency testing, the previously failing three-client arrangement used USB CDC and SSH as writer in turn while the remaining transports observed.
Early runs showed healthy heap values but CPU 0 idle-task watchdog timeouts, disproving the initial memory-pressure hypothesis. One trace exposed an unbounded broker scheduling path. A later, decisive trace repeatedly stopped inside mbedTLS's external-RAM hardware-AES DMA wait while HTTPD encrypted a WebSocket record. The broker and WebSocket producer now use bounded active bursts, mbedTLS software AES avoids the hanging PSRAM DMA path, and wolfSSL AES/SHA hardware acceleration is disabled because its private locks do not coordinate with ESP-IDF's crypto lock.
#### Phase 6 hardware validation
The final target-hardware retest covered:
1. USB CDC, one WebSocket terminal, and one SSH terminal connected concurrently.
2. SSH as writer with USB CDC and WebSocket observing.
3. USB CDC as writer with SSH and WebSocket observing.
4. Serial traffic with active readers and writer-token release/reassignment.
5. Continued HTTPS/WebSocket, SSH, USB CDC, serial-service, and UART0 responsiveness.
The software-crypto build no longer reproduces the HTTPD watchdog stall. This validates that the failure was a shared hardware-crypto/PSRAM DMA problem rather than heap exhaustion. Phase 6 is complete; these concurrent arrangements remain regression tests for future transport, TLS, memory-placement, and ESP-IDF changes.
## Current and planned phases
The order below is the current plan. Phase 7, Phase 8A, and Phase 8B are complete; Phase 8C is implemented and awaiting target-hardware validation; later work remains planned or under evaluation. Detailed requirements should be finalized at the start of each phase, and optional features must not weaken the completed serial and recovery paths.
- wolfSSH on TCP port 22 with a separate persistent ECDSA P-256 host key.
- Two bounded interactive shell/PTY sessions; no `exec`, file transfer, forwarding, or subsystems.
- Authenticated, binary-transparent broker forwarding with opportunistic writer acquisition and observer fallback.
- Bounded handshake attempts/deadlines, receive work, buffers, and a single wolfSSH owner task pinned to CPU 1.
- UART0 lifecycle, session, counter, and host-key administration.
- PSRAM-preferred allocations with internal fallback and internal task stacks.
- Bounded broker/WebSocket work, software mbedTLS AES, and software wolfCrypt AES/SHA to avoid the validated shared-hardware-crypto/PSRAM watchdog failure while retaining ESP-IDF SHA/MPI acceleration.
- Concurrent USB, WebSocket, and SSH operation validated with USB and SSH alternately holding the writer lease.
### Phase 7 — Local display and buttons
Add a standalone local status/control interface without making it a dependency of the serial core. The planning baseline uses a 128×64 dual-color monochrome I²C OLED sold with an SSD1315 controller. Phase 7A confirmed SSD1306-compatible operation, 7-bit I²C address `0x3c`, orientation, column mapping, contrast/inversion behavior, button inputs, and the physical color geometry on the selected hardware.
Implemented and target-hardware validated:
Phase 7A diagnostics and target-hardware electrical validation are complete. Phases 7B through 7E are complete and target-hardware validated.
- Optional 128×64 SSD1315-compatible I²C OLED on GPIO11/12 and active-low buttons on GPIO10/13/14; assignments remain centralized in `board_pins.h`.
- Display service with a static framebuffer, bounded dirty-page commits, and separate yellow status rows 015 plus blue content rows 1663.
- Low-priority status/control task built from copied public snapshots; no service lock is held across I²C and the UI never becomes a broker client.
- Overview, RS-232, broker, and network/service pages with no credential or key material.
- Confirmation-protected local controls for service lifecycle, Wi-Fi reconnect/profile rotation, writer release, display off, and reboot; the UI cannot assign a writer or edit secrets/configuration.
- Persistent dim/off settings, bounded boot animation, consumed wake presses, stuck-button quarantine, and one-shot confirmations.
- Missing, disconnected, or unresponsive display hardware remains nonfatal and can recover through one bounded reprobe.
- Concurrent serial, UART0, USB, WebSocket, SSH, and injected display/button fault behavior validated. See [Electrical tests](electrical_tests.md) and [Hardware wiring](wiring.md).
#### Hardware baseline
## Current and planned phases
- Wire OLED `VCC` to `3V3` and OLED `GND` to `GND`. The OLED must use 3.3 V because module-mounted I²C pull-ups may connect SDA and SCL to `VCC`.
- Wire OLED `SDA` to GPIO11 and OLED `SCL` to GPIO12. These pins are currently unused and sit in the available GPIO1014 block on the DevKit header.
- Wire three active-low buttons between their GPIO and `GND`, using the ESP32 internal pull-ups: GPIO10 for previous/back, GPIO13 for select/confirm, and GPIO14 for next.
- Use short left/right presses for page or item navigation, short select for entry, a long left press for back/home, and an explicit select hold for disruptive confirmation.
- Verify whether the module provides suitable SDA/SCL pull-ups and that every external pull-up is tied to 3.3 V, not 5 V; add external pull-ups to 3.3 V if the module does not provide them.
- Hardware verification established yellow rows 015 and blue rows 1663. A narrow physical black divider separates the two regions even though row 15 is the final yellow addressable row and row 16 the first blue addressable row.
- Keep assignments centralized in the board profile rather than scattering display or button GPIO assumptions through UI code.
![Phase 7C icon status-page mockup](phase7c_icon_mockup.svg)
The persistent yellow strip uses fixed-position serial, Wi-Fi-strength, USB, WebSocket, SSH, broker-client, and alert icons. Active icons are solid with a compact value below; inactive icon slots remain blank and show no value. The blue 128×48 content panel begins at row 16 and rotates through overview, RS-232, broker-client, and network/service pages; the physical black divider remains visually clear. No password, Wi-Fi secret, private-key material, or routine credential data may appear on the display.
#### Implementation sequence
1. **Phase 7A — Electrical bring-up and diagnostics — Complete**
- Bounded low-level display and button diagnostics are available under the existing `debug` submenu.
- The selected module acknowledged at 7-bit `0x3c` (8-bit `0x78` write / `0x79` read). A guarded full scan is retained for the dedicated local-UI bus; an absent display remains nonfatal and does not make the serial core dependent on the OLED.
- Hardware validation passed for geometry, orientation, row/column addressing, contrast, inversion, button pull-ups/debounce/short-press/long-press/stuck behavior, and the color geometry: yellow rows 015, blue rows 1663, with a physical black separator between the regions.
2. **Phase 7B — Display driver — Complete**
- The `local_display` service owns the local I²C bus, SSD1315-compatible panel, and framebuffer. It uses a static 1 KiB 128×64 framebuffer, a compact 5×7 upper/lowercase, digit, and punctuation renderer, and an internal mutex; do not add LVGL for this fixed monochrome UI.
- Rendering treats the yellow 128×16 status panel (rows 015) and blue 128×48 content panel (rows 1663) as separate panels so the physical divider remains clear.
- Frame commits refresh only dirty 8-pixel pages. I²C runs at 100 kHz with bounded transactions.
- The display bus initializes at boot; a known display is automatically initialized and cleared. A missing or unresponsive display is nonfatal and does not make the serial core dependent on it.
- Target-hardware validation passed for initialization, all display diagnostic patterns, the rendered panel layout and physical divider, row 15/16 boundary, contrast, inversion, display-off/reinitialization, and button diagnostics. Observed contrast control has a useful brightness range of 1255.
3. **Phase 7C — Read-only status UI — Complete**
- The low-priority `local_status_ui` owner task builds display state from copied public serial, Wi-Fi, broker, USB, WebSocket, HTTPS, and SSH snapshots. It does not parse CLI output, reach into transport internals, or become a broker client.
- Previous/back and next short presses navigate overview, RS-232/modem, broker-client/writer, and network/service pages. Select has no state-changing action in this read-only phase.
- The task polls and debounces the existing active-low button inputs and refreshes at 4 Hz maximum. It collects service snapshots before beginning a display frame, so no service or broker lock is held across an I²C transaction.
- The yellow panel is page-independent and uses fixed icon slots. Wi-Fi strength is shown by segmented Wi-Fi bars; the USB glyph is derived from Pictogrammers Material Design Icons. Page text uses lowercase where appropriate, separates labels and values with colons, and shows the active station or AP SSID plus channel.
- To reduce OLED aging, inactivity dims contrast to `1` after five minutes and switches the panel off after ten minutes. The first debounced button press restores contrast `127` and wakes without navigating.
- A missing/unresponsive display is nonfatal; the task remains read-only and never acquires serial writer ownership.
- Target-hardware validation passed for the four pages, fixed active-only status icons, lowercase layout, diagnostics hold, dim/off/wake behavior, all available concurrent client interfaces, UART0 recovery, and serial traffic from 9600 through 230400 baud.
4. **Phase 7D — Local controls — Complete**
- A shallow local `Controls` menu directly invokes public serial-service, Wi-Fi, HTTPS, SSH, broker, display, and restart APIs; it never parses or injects CLI text.
- The menu provides serial start/stop, Wi-Fi start/stop/reconnect/next-profile, HTTPS start/stop, SSH start/stop, emergency current-writer revocation, display off, and reboot. `WiFi:next profile` queues a rotation to the enabled profile after the currently active one in priority order, wrapping safely; profile configuration and credentials remain unavailable to the UI. The menu deliberately excludes serial configuration editing, Wi-Fi profile/AP changes, host-key/certificate changes, I²C scans, and assignment of the writer to another client.
- A visible confirmation screen and one continuous two-second Select hold are required for service stops, Wi-Fi reconnect, Wi-Fi next-profile, writer revocation, and reboot. The confirmation times out after 30 seconds; previous/back cancels. A wake press is consumed and cannot navigate or confirm an action.
- Wi-Fi lifecycle results report a queued request rather than falsely claiming an asynchronous transition has completed. HTTPS/SSH start requests require a connected station or active AP. The current writer is only ever force-released to no writer; the UI is not a broker client and cannot acquire or assign a writer lease.
- Target-hardware validation passed for menu navigation and timeout behavior, controls and asynchronous results, two-second confirmations and cancellation, Wi-Fi next-profile progression/wrap, chord rejection, wake-press suppression, and preservation of UART0 plus transport recovery.
5. **Phase 7E — Reliability, persistence, and documentation — Complete**
- At startup, a bounded five-second OLED-only identity animation renders a compact monochrome, right-oriented derivative of the project logo in the blue panel: its upright `>_` terminal remains readable, while both USB and serial cable ends enter from the left. The full device name scrolls through the yellow panel. A missing or failing OLED skips the animation without delaying recovery services further.
- A versioned `local_ui` NVS configuration makes the dim and display-off inactivity delays independently configurable from 0 through 86400 seconds. Zero disables a transition; defaults remain 300/600 seconds. UART0 `display` commands provide status, RAM edits, save/load, defaults, and atomic reset behavior.
- A button held for ten seconds is quarantined until its debounced release, after which it rearms. Quarantined inputs no longer keep a chord latched or block the other controls. A confirmation hold can execute at most once.
- Dim/off wake and missing-display recovery now occur only on a new debounced press edge. A held or stuck input cannot continuously probe an absent OLED, flood logs, or indefinitely refresh the inactivity timer; the wake gesture remains consumed.
- Dirty framebuffer commits have a 500 ms scheduling budget in addition to each bounded 250 ms I²C transaction. A failed frame marks the panel unavailable, while serial, broker, network, USB, and UART0 services remain independent. Safely reconnecting the OLED and pressing a button requests one bounded reprobe.
- Wi-Fi manager queue drops now activate the persistent yellow alert slot and remain observable through Wi-Fi counters. Local action errors continue to be reported directly without blocking queue insertion.
- Target-hardware validation passed for configuration persistence and disable semantics, absent and reattached display behavior, I²C NACK/timeout handling, each stuck-button and rearm path, repeated actions/queue-error indication, and concurrent USB CDC, WebSocket, SSH, serial, and UART0 operation during faults.
Phase 7 completion required electrical validation on the selected module, correct operation with all current transports, bounded display/button failure behavior, safe action confirmation, no unexplained serial or broker loss, and documented UART0 recovery; these criteria are met.
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
Replace the single shared network credential with a bounded, persistent user system. Accounts have one of two authorization roles: `user` receives the currently implemented authenticated serial/status experience, while `admin` additionally receives administrative command and user-management capabilities. UART0 remains the physical recovery and bootstrap authority.
Replace the single shared network credential with a bounded, persistent user system. Both roles can use the authenticated web serial/status interface. Over SSH, `user` routes to the broker-backed serial stream while `admin` routes exclusively to the administration shell and receives no broker client or writer lease. UART0 remains the physical recovery and bootstrap authority.
Implementation sequence:
1. **Phase 8A — User database and UART0 administration — Complete**
- A versioned `user_db` NVS blob holds at most eight accounts with unique lowercase usernames, `user`/`admin` roles, random account IDs, per-account authentication generations, and fixed storage bounds. The final administrator cannot be deleted or demoted.
- Passwords use random 16-byte salts and PBKDF2-HMAC-SHA256 verifiers rather than recoverable plaintext. UART0 provides bounded no-echo entry, confirmation, and one-time 24-character generated passwords for bootstrap, create, and password-change operations. Valid authentication attempts perform one real or dummy PBKDF2 outside the database mutex and revalidate the account generation before returning a principal.
- Each account accepts up to three bounded OpenSSH public keys. Initial import supports Ed25519 and ECDSA P-256, validates key structure and P-256 points, rejects duplicate keys on the same account while allowing a key to be assigned to multiple accounts, and exposes only key type plus SHA-256 fingerprint in snapshots and status output.
- On first boot after upgrade, the existing shared `admin` network credential is transactionally imported as a role-`user` account; it is never silently elevated. `user bootstrap` sets a new password and promotes that account to `admin`. Until bootstrap, legacy web credential rotation/reset synchronizes the imported verifier and boot reconciliation repairs an interrupted second NVS commit. After bootstrap, Phase 8A deliberately leaves the legacy HTTPS/SSH credential separate until Phase 8B switches network authentication to the common database.
- `user recover --force` remains registered even when a malformed or incompatible user blob prevents normal initialization. From physical UART0 it explicitly replaces only the user-database blob with a role-`user` account derived from the current legacy network credential; it never automatically erases shared NVS.
- NVS remains unencrypted. Verifiers improve password-at-rest handling, but offline guessing remains possible and append-oriented flash may retain historical plaintext legacy credentials until the partition or flash is physically erased.
- Target-hardware basics for migration, bootstrap, CRUD, persistence, and command behavior were validated before beginning Phase 8B. The full fault-injection matrix remains a regression suite for later hardening.
- Versioned NVS database for up to eight `user`/`admin` accounts, random account IDs and authentication generations, final-admin protection, and three Ed25519/P-256 keys per account.
- Salted PBKDF2-HMAC-SHA256 password verifiers, bounded no-echo entry, one-time generated passwords, and secret-free account/key status.
- Transactional migration of the legacy credential to an unprivileged account, explicit UART0 bootstrap, and UART0-only recovery that replaces only the unavailable user blob.
- Migration, bootstrap, CRUD, persistence, and command basics target-hardware validated; the full fault-injection matrix remains regression coverage. NVS remains unencrypted and offline guessing remains possible.
2. **Phase 8B — Role-aware HTTPS and SSH authentication — Complete**
- HTTPS Basic and SSH password authentication now use the common user database and retain a secret-free principal containing account ID, username, role, authentication method, and credential generation. Authentication fails closed when the database is unavailable; both `user` and `admin` roles retain the existing serial/status experience.
- SSH advertises password and public-key authentication only. The application authorizes stored Ed25519 or ECDSA P-256 username/key pairs, while wolfSSH verifies possession. Authorized unsigned probes produce only the protocol `PK_OK` response; a principal becomes authenticated only from wolfSSH's post-signature result callback and is checked again before broker admission.
- WebSocket tickets are one-time, principal-bound bearer values. Tickets are consumed before currentness validation, and active WebSocket/SSH sessions retain copied principals. Currentness is checked around broker admission, before WebSocket input, and periodically for idle/output sessions.
- Successful password, role, key, bootstrap, delete, and legacy pre-bootstrap synchronization mutations synchronously invalidate only the affected username's tickets and sessions. Per-account generations plus ongoing currentness checks provide fail-safe revocation for changed/deleted accounts while unrelated users remain connected.
- Secret-free UART0 session snapshots show account, role, and authentication method. Transport slot generations remain separate from account authentication generations to preserve ABA-safe task/work ownership.
- The legacy `web_sec` credential no longer authenticates HTTPS or SSH. It remains only for first migration and explicit physical recovery; rotating it after bootstrap does not disconnect or alter role-based users. TLS certificate/private-key handling remains in `web_security`.
- **Completed target-hardware validation:** HTTPS Basic authentication, `/api/status`, and the WebSocket terminal work for both `user` and `admin` accounts with role-based passwords. The legacy recovery credential no longer authenticates network services after bootstrap. Normal SSH password login for both roles reaches the existing broker-backed serial stream; `admin` deliberately receives that same stream rather than UART0 administration until Phase 8C.
- **Completed SSH-key validation:** Ed25519 and ECDSA P-256 public-key login work for both roles, including normal unsigned probe followed by signed proof-of-possession. A public key may be assigned to multiple accounts but cannot be duplicated within one account; the SSH username selects the account principal. Unsupported or incorrect credentials remain rejected without granting a broker client.
- **Completed ticket and revocation validation:** WebSocket tickets are account-bound, one-time, and expire as intended. Password, role, key, delete, and username-recreation mutations promptly revoke only the affected user's tickets and active network sessions, release any affected writer lease, and leave unrelated users connected.
- **Completed recovery and concurrency validation:** The database-unavailable path fails closed and retains UART0 recovery. Concurrent USB CDC, WebSocket, SSH, UART1, and UART0 operation preserves normal serial writer/observer behavior. `web credentials rotate --force` remains recovery-only after bootstrap. `web reset --force` restarts HTTPS with a replacement certificate without revoking unrelated SSH sessions; browsers correctly require a fresh TLS certificate acceptance before reconnecting.
3. **Phase 8C — SSH administrative shell — Implemented; validation pending**
- HTTPS Basic plus SSH password/public-key authentication use the common database and copied secret-free principals; unavailable user storage fails closed.
- 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 — 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 full 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 are executed through the existing synchronous owner APIs from a separate bounded control task. Only initial `user bootstrap` and explicit `user recover --force` remain physical-UART0 operations.
- `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.
- 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. `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
@@ -346,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.
+70 -9
View File
@@ -1,8 +1,10 @@
# Role-based user database and UART0 administration
# User administration and authentication tests
## Phase 8A role-based user database and UART0 administration
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.
Keep UART0 physically attached throughout these tests. Phase 8A deliberately does not change HTTPS or SSH authentication: existing network sessions still use the legacy shared credential, while the new database and public keys are prepared for the Phase 8B cutover. Do not include generated or entered passwords in test logs.
## Historical Phase 8A baseline — role-based database and UART0 administration
Keep UART0 physically attached throughout these tests. At the Phase 8A baseline, HTTPS and SSH still used the legacy shared credential while the new database and public keys were prepared for the Phase 8B cutover.
### 1. First-boot migration and persistence
@@ -12,7 +14,7 @@ Upgrade a device that already has working HTTPS/SSH credentials and no `user_db`
2. Confirm exactly one `admin`-named account exists with role `user`, `bootstrapped=no`, no displayed password/salt/verifier, and no public keys.
3. Confirm the pre-upgrade credential still authenticates to HTTPS and SSH and that serial terminal behavior is unchanged.
4. Reboot and confirm `user status` reports the same account ID and authentication generation.
5. Confirm routine boot logs, `status`, `web status`, and `ssh status` contain no user password, verifier, salt, or raw public-key blob.
5. Confirm routine boot logs, `web status`, and `ssh status` contain no user password, verifier, salt, or raw public-key blob.
A transient failure reading the legacy web credential must not create or commit an empty user database. If a valid `user_db` blob already exists, it must still load without the legacy credential being readable.
@@ -85,9 +87,9 @@ Because NVS is not yet encrypted, this test validates logical verifier storage r
With the maximum supported mix of USB CDC, WebSocket, and SSH serial clients active, repeat user list/show and several UART0 mutations. Confirm password prompts remain physical-UART-only, no user operation acquires a broker client or writer lease, existing Phase 8A network authentication and revocation behavior remains functional, and serial/broker counters show no unexplained loss. Record `memory`, `web counters`, and `ssh counters` before and after the run.
## Phase 8B role-aware HTTPS and SSH authentication
## Historical Phase 8B baseline — role-aware HTTPS and SSH authentication
Complete the Phase 8A bootstrap first and retain two disposable accounts, one `user` and one `admin`. Phase 8B gives both roles the existing serial/status interface; the administrative SSH shell is not expected until Phase 8C.
These procedures preserve the Phase 8B regression baseline. Complete the Phase 8A bootstrap first and retain two disposable accounts, one `user` and one `admin`. At that baseline both roles received the serial/status interface; current Phase 8C firmware instead routes SSH administrators to the administration shell.
### 1. HTTPS passwords and fail-closed behavior
@@ -123,19 +125,21 @@ 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
1. Connect as the normal user and confirm the existing broker-backed UART1 serial stream, broker client, and writer/observer behavior are unchanged.
2. Connect as the administrator and confirm the `admin@serial-tool>` prompt appears. Run `help`, `status`, `memory`, `serial status`, `wifi status`, `web status`, `broker status`, and `broker clients`. Compare representative output with UART0 and confirm both routes execute the same registered command implementations.
2. Connect as the administrator and confirm the `admin@serial-tool>` prompt appears. Run `help`, `memory`, `serial status`, `wifi status`, `web status`, `broker status`, and `broker clients`. Compare representative output with UART0 and confirm both routes execute the same registered command implementations.
3. From UART0 run `ssh sessions` and `broker clients`. The user session must show `route=broker`; the admin session must show `route=admin-console`, `broker=0`, `broker-role=n/a`, and no writer lease. The admin session must not start UART1 or alter broker client/writer counts merely by connecting.
### 2. Bounded command processing
Exercise printable input, backspace, Ctrl-C, CR/LF, an empty line, and a line longer than the documented limit. Confirm the command line is bounded, overflow is discarded through a clear diagnostic, and a new prompt remains usable. Run `help`, `user list`, and `broker clients` in a normal ANSI terminal and confirm every line starts in column zero: canonical LF output must be normalized to CRLF without doubling handlers that already emit CRLF.
Run at least five distinct commands, then use Up/Down to navigate the four-entry per-session history, return to a saved draft with Down, and confirm older entries are bounded out. Verify history does not survive reconnect and is not shared with a second administrator. Exercise Tab on root and nested prefixes such as `us`, `user l`, `wifi ap sh`, and `ssh host-key i`; confirm unique/common prefixes redraw cleanly without inserting escape-sequence bytes into the command.
Run at least five distinct commands, then use Up/Down to navigate the four-entry per-session history, return to a saved draft with Down, and confirm older entries are bounded out. Verify history does not survive reconnect and is not shared with a second administrator. Exercise Tab on root and nested prefixes such as `us`, `user l`, `wifi ap sh`, `wifi next`, and `ssh host-key i`; confirm the same hints are offered by UART0 and SSH. For an ambiguous prefix, confirm both routes print the same matching candidates and redraw the unchanged input line instead of cycling candidates; unique/common prefixes must redraw cleanly without inserting escape-sequence bytes into the command. Type a command, use Left/Right to insert and delete characters in its middle, then use Home/End and the terminal's Pos1/Ende keys; verify cursor placement, Backspace, and Delete change exactly the intended byte before submission.
Run an unsupported command and confirm it is rejected without affecting UART0 or the serial broker. Run the full root `help` output to exercise output-ring draining. With the SSH client temporarily unable to read output, confirm the worker applies input backpressure rather than accepting an unbounded command/output backlog; inspect `ssh counters` for admin-console admission and input-backpressure values.
@@ -154,10 +158,67 @@ Confirm the full ECDSA P-256 command is accepted, fingerprints appear in `user s
### 4. Interactive administration, lifecycle actions, and revocation
Confirm only `user bootstrap` and `user recover --force` remain unavailable from SSH and continue to work through physical UART0. From admin SSH, exercise manually entered user passwords and public keys, Wi-Fi station/AP secret entry, AP secret display, legacy web recovery credential display/rotation, HTTPS certificate rotation/reset, and both `ping` and `wifi ping`. Hidden characters must not echo or enter command history; visible key input must support Backspace and Ctrl-C; ping lines must remain ordered and correctly attributed to the invoking SSH session.
Confirm `user bootstrap` and `user recover --force` remain unavailable from SSH and continue to work through physical UART0. Also confirm that an administrator cannot run `user password <own-username> --generate` over its own SSH session; entered replacement passwords and generated passwords for other accounts remain available. From admin SSH, exercise manually entered user passwords and public keys, Wi-Fi station/AP secret entry, AP secret display, legacy web recovery credential display/rotation, HTTPS certificate rotation/reset, and both `ping` and `wifi ping`. Hidden characters must not echo or enter command history; visible key input must support Backspace and Ctrl-C; ping lines must remain ordered and correctly attributed to the invoking SSH session.
Exercise `ssh disconnect` for another session and the current session. For the other-session case, confirm the acknowledgement drains, the target closes, and the source shell returns. Separately test `reboot`, `ssh stop`, `ssh host-key rotate --force`, and `ssh reset --force`: each must acknowledge scheduling, stop accepting another command, drain output, and then close/reboot as appropriate. Reconnect after key replacement and verify the new fingerprint. Simulate an unread SSH output window and confirm the destructive action cancels after its bounded drain timeout rather than remaining pending forever.
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
+331 -74
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,7 +49,9 @@ 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];
uint8_t history[ADMIN_SSH_CONSOLE_HISTORY_DEPTH]
[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
@@ -57,7 +60,10 @@ typedef struct {
uint8_t draft[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
size_t draft_length;
uint8_t escape_state;
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;
@@ -69,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;
@@ -77,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;
@@ -84,11 +91,13 @@ 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];
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;
static uint8_t s_request_queue_bytes[ADMIN_SSH_CONSOLE_REQUEST_QUEUE_LENGTH *
@@ -100,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;
@@ -110,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;
@@ -124,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;
}
@@ -185,12 +225,25 @@ static void print_prompt(const admin_ssh_console_token_t *token)
static bool redraw_line_locked(admin_session_t *session)
{
static const char prefix[] = "\r\x1b[2Kadmin@serial-tool> ";
size_t required = sizeof(prefix) - 1U + session->input_length;
char cursor_back[16] = {0};
size_t tail_length = session->input_length - session->input_cursor;
size_t cursor_back_length = 0U;
if (tail_length > 0U) {
int written = snprintf(cursor_back, sizeof(cursor_back), "\x1b[%uD",
(unsigned int)tail_length);
if (written < 0 || (size_t)written >= sizeof(cursor_back)) {
return false;
}
cursor_back_length = (size_t)written;
}
size_t required = sizeof(prefix) - 1U + session->input_length + cursor_back_length;
if (required > ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_length) {
return append_output_locked(session, (const uint8_t *)"\a", 1U);
}
(void)append_output_locked(session, (const uint8_t *)prefix, sizeof(prefix) - 1U);
return append_output_locked(session, session->input, session->input_length);
(void)append_output_locked(session, session->input, session->input_length);
return cursor_back_length == 0U ||
append_output_locked(session, (const uint8_t *)cursor_back, cursor_back_length);
}
static void history_commit_locked(admin_session_t *session)
@@ -210,6 +263,35 @@ static void history_commit_locked(admin_session_t *session)
}
}
static void history_move_locked(admin_session_t *session, bool older);
static void editor_key_locked(admin_session_t *session, uint8_t key)
{
if (key == 'A' || key == 'B') {
history_move_locked(session, key == 'A');
return;
}
if (key == 'C' && session->input_cursor < session->input_length) {
++session->input_cursor;
} else if (key == 'D' && session->input_cursor > 0U) {
--session->input_cursor;
} else if (key == 'H') {
session->input_cursor = 0U;
} else if (key == 'F') {
session->input_cursor = session->input_length;
} else if (key == 'X' && session->input_cursor < session->input_length) {
memmove(session->input + session->input_cursor,
session->input + session->input_cursor + 1U,
session->input_length - session->input_cursor);
--session->input_length;
session->history_position = -1;
} else {
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
return;
}
(void)redraw_line_locked(session);
}
static void history_move_locked(admin_session_t *session, bool older)
{
if (older) {
@@ -226,6 +308,7 @@ static void history_move_locked(admin_session_t *session, bool older)
memcpy(session->input, session->history[session->history_position],
sizeof(session->input));
session->input_length = strlen((const char *)session->input);
session->input_cursor = session->input_length;
} else {
if (session->history_position < 0) {
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
@@ -235,10 +318,12 @@ static void history_move_locked(admin_session_t *session, bool older)
if (session->history_position < 0) {
memcpy(session->input, session->draft, sizeof(session->input));
session->input_length = session->draft_length;
session->input_cursor = session->input_length;
} else {
memcpy(session->input, session->history[session->history_position],
sizeof(session->input));
session->input_length = strlen((const char *)session->input);
session->input_cursor = session->input_length;
}
}
(void)redraw_line_locked(session);
@@ -307,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) {
@@ -321,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) {
@@ -335,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) {
@@ -410,13 +498,36 @@ static void report_command_result(esp_err_t error, int command_result)
}
}
static int command_exit(int argc, char **argv)
{
(void)argv;
if (argc != 1) {
printf("Usage: exit\n");
return 1;
}
if (!admin_ssh_console_dispatch_is_remote()) {
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_FRONTEND_DISCONNECT, s_dispatch_token.session_id);
if (error != ESP_OK) {
printf("Could not schedule administrative session close: %s\n",
esp_err_to_name(error));
return 1;
}
printf("Administrative session close scheduled after output drains.\n");
return 0;
}
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;
@@ -466,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];
@@ -477,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);
@@ -494,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 &&
@@ -516,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);
@@ -543,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);
@@ -552,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;
@@ -586,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:
@@ -595,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;
@@ -653,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,
@@ -674,6 +805,18 @@ esp_err_t admin_ssh_console_init(void)
return ESP_OK;
}
esp_err_t admin_ssh_console_register_commands(void)
{
const esp_console_cmd_t command = {
.command = "exit",
.help = "Close the current remote administrative session",
.hint = NULL,
.func = &command_exit,
.argtable = NULL,
};
return esp_console_cmd_register(&command);
}
esp_err_t admin_ssh_console_start_uart_frontend(void)
{
taskENTER_CRITICAL(&s_lock);
@@ -698,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;
@@ -725,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);
@@ -777,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)
{
@@ -854,14 +998,63 @@ 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 && (value == '[' || value == 'O')) {
session->escape_state = 2U;
} else if (session->escape_state == 2U) {
if (value == 'A' || value == 'B') {
history_move_locked(session, value == 'A');
if (session->escape_state == 1U) {
if (value == '[') {
session->escape_state = 2U;
session->escape_parameter_length = 0U;
} else if (value == 'O') {
session->escape_state = 3U;
} else {
session->escape_state = 0U;
}
} else if (session->escape_state == 3U) {
editor_key_locked(session, value);
session->escape_state = 0U;
} else if (value >= 'A' && value <= 'Z') {
editor_key_locked(session, value);
session->escape_state = 0U;
} else if (value == '~') {
uint8_t key = 0U;
if (session->escape_parameter_length > 0U) {
switch (session->escape_parameters[0]) {
case '1':
case '7':
key = 'H';
break;
case '3':
key = 'X';
break;
case '4':
case '8':
key = 'F';
break;
default:
break;
}
}
editor_key_locked(session, key);
session->escape_state = 0U;
} else if ((value == ';' || (value >= '0' && value <= '9')) &&
session->escape_parameter_length < sizeof(session->escape_parameters)) {
session->escape_parameters[session->escape_parameter_length++] = value;
} else {
session->escape_state = 0U;
}
@@ -876,13 +1069,24 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
continue;
}
if (value == '\t') {
if (session->input_cursor != session->input_length) {
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
++*consumed;
taskEXIT_CRITICAL(&s_lock);
continue;
}
char current[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
memcpy(current, session->input, sizeof(current));
++*consumed;
taskEXIT_CRITICAL(&s_lock);
char completed[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U] = {0};
size_t candidates_length = 0U;
bool expanded = console_completion_expand(current, completed,
sizeof(completed));
bool candidates_formatted = !expanded &&
console_completion_format_matches(current, s_completion_output,
sizeof(s_completion_output),
&candidates_length);
taskENTER_CRITICAL(&s_lock);
session = &s_sessions[token->slot_index];
if (token_matches(session, token) && !session->command_pending &&
@@ -890,8 +1094,17 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
if (expanded) {
strlcpy((char *)session->input, completed, sizeof(session->input));
session->input_length = strlen((const char *)session->input);
session->input_cursor = session->input_length;
session->history_position = -1;
(void)redraw_line_locked(session);
} else if (candidates_formatted && candidates_length > 0U &&
candidates_length + 2U + sizeof("admin@serial-tool> ") - 1U +
session->input_length <=
ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_length) {
(void)append_output_locked(session, (const uint8_t *)"\r\n", 2U);
(void)append_output_locked(session, (const uint8_t *)s_completion_output,
candidates_length);
(void)redraw_line_locked(session);
} else {
(void)append_output_locked(session, (const uint8_t *)"\a", 1U);
}
@@ -901,46 +1114,73 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
secure_wipe(completed, sizeof(completed));
continue;
}
if (value == '\r' || value == '\n') {
bool exit_requested = value == 0x04U && session->input_length == 0U;
if (value == '\r' || value == '\n' || exit_requested) {
session->discard_next_lf = value == '\r';
history_commit_locked(session);
memcpy(request.line, session->input, session->input_length);
request.origin = ADMIN_REQUEST_SSH;
if (exit_requested) {
memcpy(request.line, "exit", sizeof("exit"));
} else {
history_commit_locked(session);
memcpy(request.line, session->input, session->input_length);
}
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;
session->history_position = -1;
session->command_pending = true;
(void)append_output_locked(session, (const uint8_t *)"\r\n",
sizeof("\r\n") - 1U);
(void)append_output_locked(session,
(const uint8_t *)(exit_requested ? "^D\r\n" : "\r\n"),
exit_requested ? sizeof("^D\r\n") - 1U
: sizeof("\r\n") - 1U);
submit = true;
} else if (value == 0x03U) {
secure_wipe(session->input, sizeof(session->input));
session->input_length = 0U;
session->input_cursor = 0U;
session->history_position = -1;
(void)append_output_locked(session, (const uint8_t *)"^C\r\n",
sizeof("^C\r\n") - 1U);
(void)append_output_locked(session, (const uint8_t *)"admin@serial-tool> ",
sizeof("admin@serial-tool> ") - 1U);
} else if (value == 0x08U || value == 0x7fU) {
if (session->input_length > 0U) {
session->input[--session->input_length] = 0U;
if (session->input_cursor > 0U) {
memmove(session->input + session->input_cursor - 1U,
session->input + session->input_cursor,
session->input_length - session->input_cursor + 1U);
--session->input_cursor;
--session->input_length;
session->history_position = -1;
(void)append_output_locked(session, (const uint8_t *)"\b \b",
sizeof("\b \b") - 1U);
(void)redraw_line_locked(session);
}
} 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;
(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);
} else {
session->input_cursor = 0U;
session->history_position = -1;
session->input[session->input_length++] = value;
session->input[session->input_length] = 0U;
(void)append_output_locked(session, &value, 1U);
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,
session->input_length - session->input_cursor + 1U);
session->input[session->input_cursor++] = value;
++session->input_length;
session->history_position = -1;
if (session->input_cursor == session->input_length) {
(void)append_output_locked(session, &value, 1U);
} else {
(void)redraw_line_locked(session);
}
}
}
++*consumed;
@@ -961,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)
+37 -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 {
@@ -43,11 +66,15 @@ typedef struct {
/* Starts the single command worker. It is the sole esp_console_run() caller. */
esp_err_t admin_ssh_console_init(void);
/* Register administration-shell-only root commands after esp_console initialization. */
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,
@@ -55,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);
+125 -42
View File
@@ -4,20 +4,21 @@
#include "console_completion.h"
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include "esp_console.h"
#include "linenoise/linenoise.h"
static const char *const s_root_candidates[] = {
"help", "status", "debug", "display", "serial", "broker", "usb", "user",
"wifi", "web", "ssh", "ping", "nslookup", "traceroute", "reboot", "memory",
"help", "exit", "debug", "display", "serial", "broker", "usb", "user",
"wifi", "mdns", "web", "ssh", "ping", "nslookup", "traceroute", "reboot", "memory",
};
/* Keep full-line candidate strings grouped by their registered root command. */
static const char *const s_completion_candidates[] = {
/* Hardware debug commands and safe fixed arguments. */
"debug status",
"debug transceiver",
"debug transceiver enable",
"debug transceiver disable",
@@ -144,6 +145,7 @@ static const char *const s_completion_candidates[] = {
"wifi start",
"wifi stop",
"wifi reconnect",
"wifi next-profile",
"wifi save",
"wifi load",
"wifi defaults",
@@ -167,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",
@@ -202,10 +212,12 @@ static const char *const s_completion_candidates[] = {
"ssh reset --force",
};
bool console_completion_expand(const char *line, char *completed, size_t capacity)
void console_completion_visit(const char *line,
console_completion_visitor_t visitor,
void *context)
{
if (line == NULL || completed == NULL || capacity == 0U) {
return false;
if (line == NULL || visitor == NULL) {
return;
}
size_t line_length = strlen(line);
const char *const *candidates = strchr(line, ' ') == NULL
@@ -215,33 +227,97 @@ bool console_completion_expand(const char *line, char *completed, size_t capacit
? sizeof(s_root_candidates) / sizeof(s_root_candidates[0])
: sizeof(s_completion_candidates) /
sizeof(s_completion_candidates[0]);
const char *first = NULL;
size_t common_length = 0U;
for (size_t index = 0U; index < candidate_count; ++index) {
const char *candidate = candidates[index];
if (strncmp(candidate, line, line_length) != 0) {
continue;
if (strlen(candidate) > line_length &&
strncmp(candidate, line, line_length) == 0 &&
!visitor(candidate, context)) {
return;
}
if (first == NULL) {
first = candidate;
common_length = strlen(candidate);
continue;
}
size_t candidate_length = strlen(candidate);
if (common_length > candidate_length) {
common_length = candidate_length;
}
size_t offset = line_length;
while (offset < common_length && first[offset] == candidate[offset]) {
++offset;
}
common_length = offset;
}
if (first == NULL || common_length <= line_length || common_length >= capacity) {
}
typedef struct {
const char *line;
const char *first;
size_t common_length;
} completion_expand_context_t;
static bool collect_common_prefix(const char *candidate, void *context)
{
completion_expand_context_t *result = context;
if (result->first == NULL) {
result->first = candidate;
result->common_length = strlen(candidate);
return true;
}
size_t candidate_length = strlen(candidate);
if (result->common_length > candidate_length) {
result->common_length = candidate_length;
}
size_t offset = strlen(result->line);
while (offset < result->common_length && result->first[offset] == candidate[offset]) {
++offset;
}
result->common_length = offset;
return true;
}
bool console_completion_expand(const char *line, char *completed, size_t capacity)
{
if (line == NULL || completed == NULL || capacity == 0U) {
return false;
}
memcpy(completed, first, common_length);
completed[common_length] = '\0';
completion_expand_context_t result = {.line = line};
console_completion_visit(line, collect_common_prefix, &result);
size_t line_length = strlen(line);
if (result.first == NULL || result.common_length <= line_length ||
result.common_length >= capacity) {
return false;
}
memcpy(completed, result.first, result.common_length);
completed[result.common_length] = '\0';
return true;
}
typedef struct {
char *output;
size_t capacity;
size_t length;
bool complete;
} completion_format_context_t;
static bool format_completion_candidate(const char *candidate, void *context)
{
completion_format_context_t *result = context;
size_t candidate_length = strlen(candidate);
if (candidate_length + 2U > result->capacity - result->length) {
result->complete = false;
return false;
}
memcpy(result->output + result->length, candidate, candidate_length);
result->length += candidate_length;
result->output[result->length++] = '\r';
result->output[result->length++] = '\n';
return true;
}
bool console_completion_format_matches(const char *line, char *output, size_t capacity,
size_t *output_length)
{
if (line == NULL || output == NULL || output_length == NULL || capacity == 0U) {
return false;
}
completion_format_context_t result = {
.output = output,
.capacity = capacity,
.complete = true,
};
console_completion_visit(line, format_completion_candidate, &result);
if (!result.complete) {
return false;
}
*output_length = result.length;
return true;
}
@@ -261,27 +337,34 @@ static ssize_t console_read_with_late_terminal_upgrade(int file_descriptor,
return received;
}
/* The UART frontend is the sole caller of linenoise's completion callback. */
static char s_uart_completion_output[CONSOLE_COMPLETION_OUTPUT_CAPACITY];
static void console_completion_callback(const char *buffer, linenoiseCompletions *completions)
{
/* Preserve ESP-IDF completion for registered root command names. */
if (strchr(buffer, ' ') == NULL) {
esp_console_get_completion(buffer, completions);
char completed[257U] = {0};
if (console_completion_expand(buffer, completed, sizeof(completed))) {
linenoiseAddCompletion(completions, completed);
return;
}
const size_t buffer_length = strlen(buffer);
for (size_t index = 0;
index < sizeof(s_completion_candidates) / sizeof(s_completion_candidates[0]);
++index) {
const char *const candidate = s_completion_candidates[index];
const size_t candidate_length = strlen(candidate);
/* linenoise expects the complete replacement line, not only its suffix. */
if (candidate_length > buffer_length &&
strncmp(candidate, buffer, buffer_length) == 0) {
linenoiseAddCompletion(completions, candidate);
}
size_t output_length = 0U;
if (!console_completion_format_matches(buffer, s_uart_completion_output,
sizeof(s_uart_completion_output),
&output_length) ||
output_length == 0U) {
return;
}
/*
* Linenoise cycles every completion it receives. Print the shared list
* ourselves, then return the unchanged line as its one completion so its
* normal refresh restores the prompt without selecting a candidate.
*/
fputs("\r\n", stdout);
(void)fwrite(s_uart_completion_output, 1U, output_length, stdout);
fflush(stdout);
linenoiseAddCompletion(completions, buffer);
}
void console_completion_install(void)
+19
View File
@@ -9,12 +9,31 @@
extern "C" {
#endif
/* Current longest formatted match list is below this; overflow fails closed. */
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
/* Install late-terminal upgrade handling and project-specific completion. */
void console_completion_install(void);
typedef bool (*console_completion_visitor_t)(const char *candidate, void *context);
/* Visit the same matching hint candidates used by both UART0 and admin SSH. */
void console_completion_visit(const char *line,
console_completion_visitor_t visitor,
void *context);
/* Bounded longest-prefix completion shared by the UART and admin SSH frontends. */
bool console_completion_expand(const char *line, char *completed, size_t capacity);
/*
* Format the matching candidates as CRLF-terminated lines for a frontend that
* cannot use linenoise's native completion display. A successful empty result
* means no candidate matched; false means the supplied output buffer was too
* small or an argument was invalid.
*/
bool console_completion_format_matches(const char *line, char *output, size_t capacity,
size_t *output_length);
#ifdef __cplusplus
}
#endif
+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 = {
+24 -1
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"
@@ -46,7 +49,6 @@ static const char *TAG = "firmware";
void app_main(void)
{
ESP_LOGI(TAG, "ESP32-S3 Serial Swiss Army Knife Phase 7E local UI reliability started");
if (esp_psram_is_initialized()) {
ESP_LOGI(TAG, "PSRAM initialized: %u bytes", (unsigned int)esp_psram_get_size());
@@ -216,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);
@@ -302,10 +321,14 @@ 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());
ESP_ERROR_CHECK(system_console_register_commands());
ESP_ERROR_CHECK(admin_ssh_console_register_commands());
/* Upgrade late UART terminals safely and add nested completion. */
console_completion_install();
ESP_ERROR_CHECK(admin_ssh_console_start_uart_frontend());
+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);
+4 -7
View File
@@ -1579,6 +1579,7 @@ DEFINE_OWNED_COMMAND(rts_flow_test)
static void print_debug_usage(void)
{
printf("Usage:\n");
printf(" debug status\n");
printf(" debug transceiver <enable|disable>\n");
printf(" debug drivers <TX 0|1> <DTR 0|1> <RTS 0|1>\n");
printf(" debug loopback-a|loopback-b|valid-test\n");
@@ -1595,6 +1596,9 @@ static int command_debug(int argc, char **argv)
}
/* Existing handlers expect their own command name in argv[0]. */
if (strcmp(argv[1], "status") == 0) {
return command_status(argc - 1, argv + 1);
}
if (strcmp(argv[1], "transceiver") == 0) {
return command_transceiver(argc - 1, argv + 1);
}
@@ -1647,13 +1651,6 @@ esp_err_t rs232_hw_test_register_console_commands(void)
}
const esp_console_cmd_t commands[] = {
{
.command = "status",
.help = "Show MAX3243 driver, receiver, VLD, and shutdown states",
.hint = NULL,
.func = &command_status,
.argtable = NULL,
},
{
.command = "debug",
.help = "Low-level RS-232 and local-UI hardware diagnostics; run 'debug' for subcommands",
+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 = {
+31 -4
View File
@@ -7,6 +7,7 @@
#include "driver/gpio.h"
#include "driver/uart.h"
#include "esp_check.h"
#include "esp_heap_caps.h"
#include "esp_log.h"
#include "freertos/queue.h"
#include "freertos/semphr.h"
@@ -17,8 +18,10 @@
#define SERIAL_UART_RX_RING_SIZE 8192
#define SERIAL_UART_EVENT_QUEUE_SIZE 64
#define SERIAL_RX_STREAM_SIZE 16384
#define SERIAL_TX_STREAM_SIZE 8192
#define SERIAL_RX_STREAM_SIZE 16384U
#define SERIAL_TX_STREAM_SIZE 8192U
#define SERIAL_RX_STREAM_STORAGE_SIZE (SERIAL_RX_STREAM_SIZE + 1U)
#define SERIAL_TX_STREAM_STORAGE_SIZE (SERIAL_TX_STREAM_SIZE + 1U)
#define SERIAL_IO_CHUNK_SIZE 256
#define SERIAL_TASK_STACK_SIZE 4096
#define SERIAL_TASK_PRIORITY 10
@@ -32,6 +35,10 @@ static SemaphoreHandle_t s_state_mutex;
static SemaphoreHandle_t s_task_stopped;
static StreamBufferHandle_t s_rx_stream;
static StreamBufferHandle_t s_tx_stream;
static StaticStreamBuffer_t s_rx_stream_control;
static StaticStreamBuffer_t s_tx_stream_control;
static uint8_t *s_rx_stream_storage;
static uint8_t *s_tx_stream_storage;
static QueueHandle_t s_uart_event_queue;
static TaskHandle_t s_event_task;
static portMUX_TYPE s_counter_lock = portMUX_INITIALIZER_UNLOCKED;
@@ -472,8 +479,24 @@ esp_err_t serial_service_init(const serial_config_t *initial_config)
s_state_mutex = xSemaphoreCreateMutex();
s_task_stopped = xSemaphoreCreateBinary();
s_rx_stream = xStreamBufferCreate(SERIAL_RX_STREAM_SIZE, 1);
s_tx_stream = xStreamBufferCreate(SERIAL_TX_STREAM_SIZE, 1);
s_rx_stream_storage = heap_caps_calloc_prefer(
1U, SERIAL_RX_STREAM_STORAGE_SIZE, 2,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
s_tx_stream_storage = heap_caps_calloc_prefer(
1U, SERIAL_TX_STREAM_STORAGE_SIZE, 2,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
if (s_rx_stream_storage != NULL) {
s_rx_stream = xStreamBufferCreateStatic(
SERIAL_RX_STREAM_STORAGE_SIZE, 1U, s_rx_stream_storage,
&s_rx_stream_control);
}
if (s_tx_stream_storage != NULL) {
s_tx_stream = xStreamBufferCreateStatic(
SERIAL_TX_STREAM_STORAGE_SIZE, 1U, s_tx_stream_storage,
&s_tx_stream_control);
}
if (s_state_mutex == NULL || s_task_stopped == NULL ||
s_rx_stream == NULL || s_tx_stream == NULL) {
if (s_state_mutex != NULL) {
@@ -488,10 +511,14 @@ esp_err_t serial_service_init(const serial_config_t *initial_config)
if (s_tx_stream != NULL) {
vStreamBufferDelete(s_tx_stream);
}
heap_caps_free(s_rx_stream_storage);
heap_caps_free(s_tx_stream_storage);
s_state_mutex = NULL;
s_task_stopped = NULL;
s_rx_stream = NULL;
s_tx_stream = NULL;
s_rx_stream_storage = NULL;
s_tx_stream_storage = NULL;
return ESP_ERR_NO_MEM;
}
+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);
+86 -6
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 {
@@ -89,6 +92,7 @@ static uint32_t s_external_close_id[SSH_TRANSPORT_MAX_SESSIONS];
static ssh_transport_counters_t s_counters;
static SemaphoreHandle_t s_command_mutex;
static bool s_initializing;
static bool s_init_faulted;
static bool s_initialized;
static bool s_running;
static bool s_transitioning;
@@ -131,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 = {
@@ -547,8 +616,7 @@ static esp_err_t create_context(void)
(void)wolfSSH_CTX_SetChannelReqShellCb(context, accept_shell);
(void)wolfSSH_CTX_SetChannelReqExecCb(context, reject_channel_request);
(void)wolfSSH_CTX_SetChannelReqSubsysCb(context, reject_channel_request);
(void)wolfSSH_CTX_SetBanner(
context, "ESP32 Serial Swiss Army Knife - authenticated serial transport\r\n");
s_context = context;
return ESP_OK;
}
@@ -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);
@@ -1295,20 +1364,29 @@ esp_err_t ssh_transport_init(void)
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
if (s_initializing) {
if (s_initializing || s_init_faulted) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
s_initializing = true;
taskEXIT_CRITICAL(&s_lock);
bool wolfssh_initialized = false;
esp_err_t error = secure_random_init();
if (error != ESP_OK) {
error = ESP_ERR_INVALID_STATE;
goto fail;
}
if (wolfSSL_SetAllocators(ssh_malloc, ssh_free, ssh_realloc) != 0 ||
wolfSSH_Init() != WS_SUCCESS || wc_SetSeed_Cb(ssh_seed) != 0) {
if (wolfSSL_SetAllocators(ssh_malloc, ssh_free, ssh_realloc) != 0) {
error = ESP_FAIL;
goto fail;
}
if (wolfSSH_Init() != WS_SUCCESS) {
error = ESP_FAIL;
goto fail;
}
wolfssh_initialized = true;
if (wc_SetSeed_Cb(ssh_seed) != 0) {
error = ESP_FAIL;
goto fail;
}
@@ -1348,7 +1426,9 @@ esp_err_t ssh_transport_init(void)
return ESP_OK;
fail:
bool cleanup_failed = wolfssh_initialized && wolfSSH_Cleanup() != WS_SUCCESS;
taskENTER_CRITICAL(&s_lock);
s_init_faulted = cleanup_failed;
s_initializing = false;
taskEXIT_CRITICAL(&s_lock);
return error;
+33 -155
View File
@@ -9,7 +9,6 @@
#include "freertos/queue.h"
#include "freertos/stream_buffer.h"
#include "freertos/task.h"
#include "serial_config.h"
#include "serial_service.h"
#include "tinyusb.h"
#include "tinyusb_cdc_acm.h"
@@ -47,14 +46,15 @@ static portMUX_TYPE s_state_lock = portMUX_INITIALIZER_UNLOCKED;
static atomic_bool s_initialized;
static atomic_bool s_initializing;
static atomic_bool s_init_faulted;
static atomic_uint s_usb_state;
/* Changes on every effective CDC open/close boundary, even during one task poll. */
static atomic_uint s_connection_generation;
static session_broker_client_id_t s_broker_client_id;
static bool s_writer;
/* Host-provided CDC line coding is diagnostic information only. */
static usb_cdc_transport_line_coding_t s_line_coding;
static bool s_line_coding_pending;
static usb_cdc_transport_counters_t s_counters;
static const char s_language_descriptor[] = {0x09, 0x04};
@@ -133,14 +133,11 @@ static void device_event_callback(tinyusb_event_t *event, void *arg)
notify_transport_task();
break;
case TINYUSB_EVENT_DETACHED: {
/* A new attachment must receive fresh control state and line coding. */
/* A new attachment must receive fresh control state. */
unsigned int old_state = atomic_exchange(&s_usb_state, 0U);
if (usb_state_is_open(old_state)) {
atomic_fetch_add(&s_connection_generation, 1U);
}
taskENTER_CRITICAL(&s_state_lock);
s_line_coding_pending = false;
taskEXIT_CRITICAL(&s_state_lock);
notify_transport_task();
break;
}
@@ -225,13 +222,6 @@ static void cdc_line_state_callback(int itf, cdcacm_event_t *event)
}
}
if (!event->line_state_changed_data.dtr) {
/* Do not apply a closed host session's deferred line coding after reopen. */
taskENTER_CRITICAL(&s_state_lock);
s_line_coding_pending = false;
taskEXIT_CRITICAL(&s_state_lock);
}
notify_transport_task();
}
@@ -251,137 +241,15 @@ static void cdc_line_coding_callback(int itf, cdcacm_event_t *event)
sizeof(coding));
taskENTER_CRITICAL(&s_state_lock);
if (s_line_coding_pending) {
/* Preserve the latest complete setting and account for the superseded one. */
++s_counters.callback_drops;
}
s_line_coding = (usb_cdc_transport_line_coding_t) {
.baud_rate = coding.bit_rate,
.stop_bits = coding.stop_bits,
.parity = coding.parity,
.data_bits = coding.data_bits,
};
s_line_coding_pending = true;
taskEXIT_CRITICAL(&s_state_lock);
notify_transport_task();
}
static bool take_pending_line_coding(usb_cdc_transport_line_coding_t *coding)
{
bool pending;
taskENTER_CRITICAL(&s_state_lock);
pending = s_line_coding_pending;
if (pending) {
*coding = s_line_coding;
s_line_coding_pending = false;
}
taskEXIT_CRITICAL(&s_state_lock);
return pending;
}
static bool serial_configs_equal(const serial_config_t *left,
const serial_config_t *right)
{
return left->version == right->version &&
left->baud_rate == right->baud_rate &&
left->data_bits == right->data_bits &&
left->parity == right->parity &&
left->stop_bits == right->stop_bits &&
left->flow_control == right->flow_control &&
left->dtr_behavior == right->dtr_behavior &&
left->rts_threshold == right->rts_threshold;
}
static bool map_line_coding(const usb_cdc_transport_line_coding_t *coding,
serial_config_t *config)
{
if (coding->baud_rate < SERIAL_CONFIG_MIN_BAUD_RATE ||
coding->baud_rate > SERIAL_CONFIG_MAX_BAUD_RATE) {
return false;
}
config->baud_rate = coding->baud_rate;
switch (coding->data_bits) {
case 7U:
config->data_bits = SERIAL_CONFIG_DATA_BITS_7;
break;
case 8U:
config->data_bits = SERIAL_CONFIG_DATA_BITS_8;
break;
default:
return false;
}
switch (coding->parity) {
case CDC_LINE_CODING_PARITY_NONE:
config->parity = SERIAL_CONFIG_PARITY_NONE;
break;
case CDC_LINE_CODING_PARITY_ODD:
config->parity = SERIAL_CONFIG_PARITY_ODD;
break;
case CDC_LINE_CODING_PARITY_EVEN:
config->parity = SERIAL_CONFIG_PARITY_EVEN;
break;
default:
/* Mark and space parity are intentionally not representable by UART policy. */
return false;
}
switch (coding->stop_bits) {
case CDC_LINE_CODING_STOP_BITS_1:
config->stop_bits = SERIAL_CONFIG_STOP_BITS_1;
break;
case CDC_LINE_CODING_STOP_BITS_2:
config->stop_bits = SERIAL_CONFIG_STOP_BITS_2;
break;
default:
/* This also rejects USB's 1.5-stop-bit encoding. */
return false;
}
return true;
}
static void apply_pending_line_coding(bool writer)
{
if (!writer || !serial_service_is_running()) {
return;
}
/* Restarting UART1 discards queued TX, so defer framing changes until idle. */
if (serial_service_tx_pending() > 0U) {
return;
}
usb_cdc_transport_line_coding_t coding;
if (!take_pending_line_coding(&coding)) {
return;
}
serial_config_t current;
if (serial_service_get_config(&current) != ESP_OK) {
add_counter(&s_counters.line_coding_failed, 1U);
return;
}
serial_config_t desired = current;
if (!map_line_coding(&coding, &desired)) {
add_counter(&s_counters.line_coding_rejected, 1U);
return;
}
/* Flow control, DTR policy, and RTS threshold remain from current RAM state. */
if (serial_configs_equal(&current, &desired)) {
return;
}
if (serial_service_apply_config(&desired) == ESP_OK) {
add_counter(&s_counters.line_coding_applied, 1U);
} else {
add_counter(&s_counters.line_coding_failed, 1U);
}
/* CDC line coding must not reconfigure the independently configured UART1. */
}
static bool writer_event_type(session_broker_event_type_t type)
@@ -761,8 +629,6 @@ static void transport_task(void *context)
continue;
}
apply_pending_line_coding(writer);
if (atomic_load(&s_connection_generation) != observed_generation) {
continue;
}
@@ -811,18 +677,23 @@ static void reset_uninitialized_state(void)
.parity = USB_CDC_TRANSPORT_PARITY_NONE,
.data_bits = 8U,
};
s_line_coding_pending = false;
memset(&s_counters, 0, sizeof(s_counters));
taskEXIT_CRITICAL(&s_state_lock);
}
static void cleanup_init_allocations(bool cdc_initialized, bool driver_installed)
static esp_err_t cleanup_init_allocations(bool cdc_initialized, bool driver_installed)
{
if (cdc_initialized) {
(void)tinyusb_cdcacm_deinit(TINYUSB_CDC_ACM_0);
esp_err_t error = tinyusb_cdcacm_deinit(TINYUSB_CDC_ACM_0);
if (error != ESP_OK) {
return error;
}
}
if (driver_installed) {
(void)tinyusb_driver_uninstall();
esp_err_t error = tinyusb_driver_uninstall();
if (error != ESP_OK) {
return error;
}
}
if (s_control_queue != NULL) {
vQueueDelete(s_control_queue);
@@ -833,12 +704,27 @@ static void cleanup_init_allocations(bool cdc_initialized, bool driver_installed
s_host_rx_stream = NULL;
}
reset_uninitialized_state();
return ESP_OK;
}
static esp_err_t fail_initialization(esp_err_t original_error,
bool cdc_initialized,
bool driver_installed)
{
esp_err_t cleanup_error = cleanup_init_allocations(cdc_initialized, driver_installed);
if (cleanup_error != ESP_OK) {
/* Retain callback-facing storage and reject retries after incomplete teardown. */
atomic_store(&s_init_faulted, true);
original_error = cleanup_error;
}
atomic_store(&s_initializing, false);
return original_error;
}
esp_err_t usb_cdc_transport_init(void)
{
bool expected = false;
if (atomic_load(&s_initialized) ||
if (atomic_load(&s_initialized) || atomic_load(&s_init_faulted) ||
!atomic_compare_exchange_strong(&s_initializing, &expected, true)) {
return ESP_ERR_INVALID_STATE;
}
@@ -870,9 +756,7 @@ esp_err_t usb_cdc_transport_init(void)
s_control_queue = xQueueCreate(USB_CDC_CONTROL_QUEUE_LENGTH,
sizeof(usb_cdc_control_t));
if (s_control_queue == NULL) {
cleanup_init_allocations(false, false);
atomic_store(&s_initializing, false);
return ESP_ERR_NO_MEM;
return fail_initialization(ESP_ERR_NO_MEM, false, false);
}
/* ESP32-S3's default full-speed internal PHY is fixed to GPIO19/20. */
@@ -884,9 +768,7 @@ esp_err_t usb_cdc_transport_init(void)
result = tinyusb_driver_install(&usb_config);
if (result != ESP_OK) {
cleanup_init_allocations(false, false);
atomic_store(&s_initializing, false);
return result;
return fail_initialization(result, false, false);
}
const tinyusb_config_cdcacm_t cdc_config = {
@@ -898,9 +780,7 @@ esp_err_t usb_cdc_transport_init(void)
};
result = tinyusb_cdcacm_init(&cdc_config);
if (result != ESP_OK) {
cleanup_init_allocations(false, true);
atomic_store(&s_initializing, false);
return result;
return fail_initialization(result, false, true);
}
TaskHandle_t task = NULL;
@@ -910,9 +790,7 @@ esp_err_t usb_cdc_transport_init(void)
NULL,
USB_CDC_TASK_PRIORITY,
&task) != pdPASS) {
cleanup_init_allocations(true, true);
atomic_store(&s_initializing, false);
return ESP_ERR_NO_MEM;
return fail_initialization(ESP_ERR_NO_MEM, true, true);
}
atomic_store(&s_transport_task, (uintptr_t)task);
-3
View File
@@ -44,9 +44,6 @@ typedef struct {
uint64_t writer_revocations;
uint64_t writer_events;
uint64_t service_start_failures;
uint64_t line_coding_applied;
uint64_t line_coding_rejected;
uint64_t line_coding_failed;
uint64_t callback_drops;
uint64_t control_drops;
} usb_cdc_transport_counters_t;
+2 -6
View File
@@ -76,7 +76,7 @@ static int show_status(void)
(unsigned int)snapshot.line_coding.data_bits,
parity_name(snapshot.line_coding.parity),
stop_bits_name(snapshot.line_coding.stop_bits));
printf("USB line coding changes RAM only; use 'serial save' to persist it.\n");
printf("Host line coding is reported only; UART1 uses the serial configuration.\n");
return 0;
}
@@ -110,11 +110,7 @@ static int show_counters(void)
counter->writer_denials,
counter->writer_revocations,
counter->writer_events);
printf("Line coding: applied=%" PRIu64 " rejected=%" PRIu64
" failed=%" PRIu64 " service-start-failures=%" PRIu64 "\n",
counter->line_coding_applied,
counter->line_coding_rejected,
counter->line_coding_failed,
printf("Service start failures=%" PRIu64 "\n",
counter->service_start_failures);
printf("Control/callback drops: control=%" PRIu64 " callback=%" PRIu64 "\n",
counter->control_drops,
+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;
}
+65 -33
View File
@@ -6,6 +6,7 @@
#include <stddef.h>
#include <string.h>
#include "esp_heap_caps.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "mbedtls/ecp.h"
@@ -66,7 +67,7 @@ typedef struct {
static SemaphoreHandle_t s_mutex;
static stored_database_t s_database;
static stored_database_t s_candidate;
static stored_database_t *s_candidate;
static bool s_initialized;
static uint8_t s_dummy_salt[USER_DATABASE_PASSWORD_SALT_LENGTH];
static uint8_t s_dummy_hash[USER_DATABASE_PASSWORD_HASH_LENGTH];
@@ -80,9 +81,30 @@ static esp_err_t initialize_dummy_verifier(void)
return error;
}
static esp_err_t allocate_candidate(void)
{
if (s_candidate != NULL) {
return ESP_ERR_INVALID_STATE;
}
s_candidate = heap_caps_calloc_prefer(
1U, sizeof(*s_candidate), 2,
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
return s_candidate != NULL ? ESP_OK : ESP_ERR_NO_MEM;
}
static void discard_candidate(void)
{
secure_wipe(&s_candidate, sizeof(s_candidate));
if (s_candidate != NULL) {
secure_wipe(s_candidate, sizeof(*s_candidate));
}
}
static void release_candidate(void)
{
discard_candidate();
heap_caps_free(s_candidate);
s_candidate = NULL;
}
static bool constant_time_equal(const uint8_t *left, const uint8_t *right, size_t length)
@@ -437,10 +459,10 @@ static esp_err_t next_generation(uint32_t *generation)
static esp_err_t commit_candidate_locked(void)
{
recount(&s_candidate);
esp_err_t error = next_generation(&s_candidate.generation);
recount(s_candidate);
esp_err_t error = next_generation(&s_candidate->generation);
if (error == ESP_OK) {
error = validate_database(&s_candidate);
error = validate_database(s_candidate);
}
if (error != ESP_OK) {
discard_candidate();
@@ -451,7 +473,7 @@ static esp_err_t commit_candidate_locked(void)
error = nvs_open(USER_DATABASE_NVS_NAMESPACE, NVS_READWRITE, &handle);
if (error == ESP_OK) {
error = nvs_set_blob(handle, USER_DATABASE_NVS_BLOB_KEY,
&s_candidate, sizeof(s_candidate));
s_candidate, sizeof(*s_candidate));
if (error == ESP_OK) {
error = nvs_commit(handle);
}
@@ -459,7 +481,7 @@ static esp_err_t commit_candidate_locked(void)
}
if (error == ESP_OK) {
secure_wipe(&s_database, sizeof(s_database));
s_database = s_candidate;
s_database = *s_candidate;
}
discard_candidate();
return error;
@@ -530,8 +552,8 @@ static esp_err_t synchronize_legacy_locked(
return error;
}
s_candidate = s_database;
stored_user_t *candidate_user = &s_candidate.users[index];
*s_candidate = s_database;
stored_user_t *candidate_user = &s_candidate->users[index];
error = set_record_password(candidate_user, legacy->password,
legacy->password_length);
if (error == ESP_OK) {
@@ -557,12 +579,16 @@ esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
if (s_mutex == NULL) {
return ESP_ERR_NO_MEM;
}
esp_err_t error = nvs_flash_init();
esp_err_t error = allocate_candidate();
if (error != ESP_OK) {
vSemaphoreDelete(s_mutex);
s_mutex = NULL;
return error;
}
error = nvs_flash_init();
if (error != ESP_OK) {
goto init_failed;
}
bool storage_missing = false;
nvs_handle_t handle;
@@ -621,7 +647,7 @@ esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
}
*load_result = USER_DATABASE_LOAD_MIGRATED_LEGACY;
recount(&s_database);
s_candidate = s_database;
*s_candidate = s_database;
error = commit_candidate_locked();
if (error != ESP_OK) {
goto init_failed;
@@ -635,7 +661,7 @@ esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
init_failed:
secure_wipe(&s_database, sizeof(s_database));
discard_candidate();
release_candidate();
secure_wipe(s_dummy_salt, sizeof(s_dummy_salt));
secure_wipe(s_dummy_hash, sizeof(s_dummy_hash));
vSemaphoreDelete(s_mutex);
@@ -673,17 +699,23 @@ esp_err_t user_database_recover_from_legacy(
if (s_mutex == NULL) {
return ESP_ERR_NO_MEM;
}
esp_err_t error = nvs_flash_init();
esp_err_t error = allocate_candidate();
if (error != ESP_OK) {
vSemaphoreDelete(s_mutex);
s_mutex = NULL;
return error;
}
error = nvs_flash_init();
if (error != ESP_OK) {
goto recovery_failed;
}
memset(&s_database, 0, sizeof(s_database));
memset(&s_candidate, 0, sizeof(s_candidate));
s_candidate.version = USER_DATABASE_SCHEMA_VERSION;
s_candidate.size = sizeof(s_candidate);
s_candidate.generation = 1U;
error = initialize_user(&s_candidate.users[0], legacy->username,
memset(s_candidate, 0, sizeof(*s_candidate));
s_candidate->version = USER_DATABASE_SCHEMA_VERSION;
s_candidate->size = sizeof(*s_candidate);
s_candidate->generation = 1U;
error = initialize_user(&s_candidate->users[0], legacy->username,
legacy->username_length, USER_ROLE_USER,
legacy->password, legacy->password_length);
if (error == ESP_OK) {
@@ -701,7 +733,7 @@ esp_err_t user_database_recover_from_legacy(
recovery_failed:
secure_wipe(&s_database, sizeof(s_database));
discard_candidate();
release_candidate();
secure_wipe(s_dummy_salt, sizeof(s_dummy_salt));
secure_wipe(s_dummy_hash, sizeof(s_dummy_hash));
vSemaphoreDelete(s_mutex);
@@ -918,11 +950,11 @@ static esp_err_t create_locked(const uint8_t *username, size_t username_length,
if (free_index < 0) {
return ESP_ERR_NO_MEM;
}
s_candidate = s_database;
esp_err_t error = initialize_user(&s_candidate.users[free_index], username,
*s_candidate = s_database;
esp_err_t error = initialize_user(&s_candidate->users[free_index], username,
username_length, role, password, password_length);
if (error == ESP_OK && role == USER_ROLE_ADMIN) {
s_candidate.admin_bootstrapped = 1U;
s_candidate->admin_bootstrapped = 1U;
}
if (error == ESP_OK) {
return commit_candidate_locked();
@@ -984,15 +1016,15 @@ esp_err_t user_database_bootstrap_admin(const uint8_t *password,
error = create_locked(s_admin_username, sizeof(s_admin_username) - 1U,
USER_ROLE_ADMIN, password, password_length);
} else {
s_candidate = s_database;
stored_user_t *user = &s_candidate.users[index];
*s_candidate = s_database;
stored_user_t *user = &s_candidate->users[index];
error = set_record_password(user, password, password_length);
if (error == ESP_OK) {
user->role = USER_ROLE_ADMIN;
error = next_generation(&user->auth_generation);
}
if (error == ESP_OK) {
s_candidate.admin_bootstrapped = 1U;
s_candidate->admin_bootstrapped = 1U;
error = commit_candidate_locked();
} else {
discard_candidate();
@@ -1026,7 +1058,7 @@ static esp_err_t mutate_user_begin(const uint8_t *username, size_t username_leng
if (*index < 0) {
return ESP_ERR_NOT_FOUND;
}
s_candidate = s_database;
*s_candidate = s_database;
return ESP_OK;
}
@@ -1050,7 +1082,7 @@ esp_err_t user_database_delete(const uint8_t *username, size_t username_length)
error = ESP_ERR_INVALID_STATE;
discard_candidate();
} else {
secure_wipe(&s_candidate.users[index], sizeof(s_candidate.users[index]));
secure_wipe(&s_candidate->users[index], sizeof(s_candidate->users[index]));
error = commit_candidate_locked();
}
}
@@ -1069,7 +1101,7 @@ esp_err_t user_database_set_role(const uint8_t *username, size_t username_length
int index;
esp_err_t error = mutate_user_begin(username, username_length, &index);
if (error == ESP_OK) {
stored_user_t *user = &s_candidate.users[index];
stored_user_t *user = &s_candidate->users[index];
if (user->role == role) {
error = ESP_OK;
discard_candidate();
@@ -1080,7 +1112,7 @@ esp_err_t user_database_set_role(const uint8_t *username, size_t username_length
user->role = (uint8_t)role;
error = next_generation(&user->auth_generation);
if (error == ESP_OK && role == USER_ROLE_ADMIN) {
s_candidate.admin_bootstrapped = 1U;
s_candidate->admin_bootstrapped = 1U;
}
if (error == ESP_OK) {
error = commit_candidate_locked();
@@ -1104,7 +1136,7 @@ esp_err_t user_database_set_password(const uint8_t *username, size_t username_le
int index;
esp_err_t error = mutate_user_begin(username, username_length, &index);
if (error == ESP_OK) {
stored_user_t *user = &s_candidate.users[index];
stored_user_t *user = &s_candidate->users[index];
error = set_record_password(user, password, password_length);
if (error == ESP_OK) {
error = next_generation(&user->auth_generation);
@@ -1149,7 +1181,7 @@ esp_err_t user_database_add_ssh_key(
int user_index;
esp_err_t error = mutate_user_begin(username, username_length, &user_index);
if (error == ESP_OK) {
stored_user_t *user = &s_candidate.users[user_index];
stored_user_t *user = &s_candidate->users[user_index];
int free_index = -1;
for (size_t index = 0U;
index < USER_DATABASE_MAX_SSH_KEYS_PER_USER; ++index) {
@@ -1210,7 +1242,7 @@ esp_err_t user_database_remove_ssh_key(const uint8_t *username,
int user_index;
esp_err_t error = mutate_user_begin(username, username_length, &user_index);
if (error == ESP_OK) {
stored_user_t *user = &s_candidate.users[user_index];
stored_user_t *user = &s_candidate->users[user_index];
if (user->keys[key_index].active == 0U) {
error = ESP_ERR_NOT_FOUND;
discard_candidate();
@@ -1239,7 +1271,7 @@ esp_err_t user_database_clear_ssh_keys(const uint8_t *username,
int user_index;
esp_err_t error = mutate_user_begin(username, username_length, &user_index);
if (error == ESP_OK) {
stored_user_t *user = &s_candidate.users[user_index];
stored_user_t *user = &s_candidate->users[user_index];
if (user->key_count == 0U) {
error = ESP_OK;
discard_candidate();
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);
+3480 -201
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
+1190 -61
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));
}
}
}
+216 -23
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;
@@ -196,6 +219,48 @@ static void copy_working_config(wifi_app_config_t *config)
unlock_shared();
}
static bool profiles_equal(const wifi_config_sta_profile_t *left,
const wifi_config_sta_profile_t *right)
{
return left->enabled == right->enabled &&
left->priority == right->priority &&
left->security == right->security &&
left->ssid_len == right->ssid_len &&
left->psk_len == right->psk_len &&
memcmp(left->ssid, right->ssid, left->ssid_len) == 0 &&
memcmp(left->psk, right->psk, left->psk_len) == 0;
}
/*
* Disabled profiles are staged configuration, not current radio policy. Their
* credentials and priority may be prepared without interrupting a live link.
*/
static bool config_requires_radio_restart(const wifi_app_config_t *current,
const wifi_app_config_t *candidate)
{
if (current->ap_policy != candidate->ap_policy ||
current->ap_ssid_len != candidate->ap_ssid_len ||
current->ap_psk_len != candidate->ap_psk_len ||
current->ap_channel != candidate->ap_channel ||
memcmp(current->ap_ssid, candidate->ap_ssid, current->ap_ssid_len) != 0 ||
memcmp(current->ap_psk, candidate->ap_psk, current->ap_psk_len) != 0) {
return true;
}
for (size_t slot = 0U; slot < WIFI_CONFIG_STA_PROFILE_COUNT; ++slot) {
const wifi_config_sta_profile_t *old_profile = &current->profiles[slot];
const wifi_config_sta_profile_t *new_profile = &candidate->profiles[slot];
bool old_enabled = old_profile->enabled != 0U;
bool new_enabled = new_profile->enabled != 0U;
if (old_enabled != new_enabled ||
(old_enabled && !profiles_equal(old_profile, new_profile))) {
return true;
}
}
return false;
}
static bool connected_event_matches_active_profile(const manager_message_t *message)
{
bool matches = false;
@@ -368,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;
}
@@ -702,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,
@@ -722,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;
@@ -826,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)) {
@@ -858,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;
@@ -1210,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;
}
@@ -1223,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;
}
@@ -1231,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);
@@ -1334,69 +1423,168 @@ esp_err_t wifi_manager_init(const wifi_app_config_t *config)
return ESP_OK;
}
esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config)
esp_err_t wifi_manager_get_working_config_versioned(wifi_app_config_t *config,
uint32_t *generation)
{
if (config == NULL) {
if (config != NULL) {
wifi_config_secure_wipe(config, sizeof(*config));
}
if (generation != NULL) {
*generation = 0U;
}
if (config == NULL || generation == NULL) {
return ESP_ERR_INVALID_ARG;
}
if (s_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
copy_working_config(config);
lock_shared();
*config = s_shared.config;
*generation = s_shared.snapshot.config_generation;
unlock_shared();
return ESP_OK;
}
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)
{
uint32_t generation = 0U;
return wifi_manager_get_working_config_versioned(config, &generation);
}
/* s_config_writer_mutex must be held by the caller. */
static esp_err_t apply_working_config_serialized(
const wifi_app_config_t *config, bool compare_generation,
uint32_t expected_generation, uint32_t *resulting_generation)
{
lock_shared();
if (compare_generation &&
s_shared.snapshot.config_generation != expected_generation) {
unlock_shared();
return WIFI_MANAGER_ERR_CONFIG_CONFLICT;
}
if (s_shared.snapshot.config_generation == UINT32_MAX) {
unlock_shared();
return ESP_ERR_INVALID_STATE;
}
bool restart_radio = config_requires_radio_restart(&s_shared.config, config);
if (restart_radio) {
manager_message_t message = {.type = MESSAGE_COMMAND_APPLY};
if (!enqueue_message(&message)) {
unlock_shared();
return ESP_ERR_TIMEOUT;
}
}
s_shared.config = *config;
++s_shared.snapshot.config_generation;
s_shared.snapshot.ap_policy = config->ap_policy;
++s_shared.snapshot.counters.applies;
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) {
if (s_mutex == NULL || s_config_writer_mutex == NULL) {
return ESP_ERR_INVALID_STATE;
}
manager_message_t message = {.type = MESSAGE_COMMAND_APPLY};
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 (!enqueue_message(&message)) {
if (s_shared.snapshot.config_generation != expected_generation) {
unlock_shared();
return ESP_ERR_TIMEOUT;
unlock_config_writer();
wifi_config_secure_wipe(&config, sizeof(config));
return WIFI_MANAGER_ERR_CONFIG_CONFLICT;
}
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;
config = s_shared.config;
unlock_shared();
return ESP_OK;
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;
}
@@ -1420,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 -4
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,14 +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. Application is asynchronous; when
* Wi-Fi is running, the manager task restarts it using 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);
@@ -94,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);