Phase 8D - massive refactor and admin functions / admin shell in
webinterface. Memory and cross-origin problems.
This commit is contained in:
@@ -14,13 +14,13 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
|
||||
|
||||
## Development status
|
||||
|
||||
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D plans integrated browser login/logout, an admin-shell terminal mode, typed settings, and contextual quick administration while preserving any browser-held serial writer lease across terminal-mode changes. Configurable STA-only mDNS naming as `sak-<suffix>.local` is implemented with independent NVS persistence; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
|
||||
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D 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, session revocation, and the planned integrated web-administration acceptance matrix.
|
||||
- [Role-based user database and UART0 administration](docs/user_administration_tests.md): user migration and administration, HTTPS/SSH authentication, session revocation, and the pending integrated web-administration validation matrix.
|
||||
- [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands.
|
||||
|
||||
## Flash partition layout
|
||||
@@ -73,11 +73,11 @@ The firmware provides an interactive UART0 console at `serial-tool>`. Run `help`
|
||||
|
||||
The console supports session history, line editing, cursor movement, and hierarchical Tab completion. After an unattended boot, attach an ANSI-capable terminal and press Enter once to enable enhanced editing; this avoids blocking while no terminal is attached.
|
||||
|
||||
Serial, Wi-Fi, and mDNS hostname edits remain in RAM until explicitly saved with `serial save`, `wifi save`, or `mdns save`. Authenticated admin SSH sessions expose the shared operational administration registry, including interactive secrets, recovery-material management, network diagnostics, and deferred reboot/SSH lifecycle commands. Initial administrator bootstrap and explicit recovery of an unavailable user database remain UART0-only. An administrator also cannot generate a replacement password for its own account over SSH, preventing the one-time value from being lost when that mutation revokes the session. `web credentials show` exposes only the legacy migration/recovery credential, not an active Phase 8B network login.
|
||||
Serial, Wi-Fi, display-aging, and mDNS hostname edits remain in RAM until explicitly saved with `serial save`, `wifi save`, `display save`, or `mdns save`. Authenticated admin SSH sessions and the admin-only browser shell expose the shared operational administration registry, including interactive secrets, recovery-material management, network diagnostics, and deferred self-affecting lifecycle commands. Initial administrator bootstrap and explicit recovery of an unavailable user database remain UART0-only. A remotely connected administrator also cannot generate a replacement password for its own account, preventing the one-time value from being lost when that mutation revokes the session. `web credentials show` exposes only the legacy migration/recovery credential, not an active Phase 8B network login.
|
||||
|
||||
## Security notes
|
||||
|
||||
The HTTPS interface uses a device-specific self-signed certificate and role-aware HTTP Basic authentication over TLS; there is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the legacy recovery password, HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete.
|
||||
The HTTPS interface uses a device-specific self-signed certificate and an explicit same-origin login/logout flow over TLS; opaque eight-hour browser sessions are held in a bounded RAM table, while raw session tokens are sent only in a host-only `Secure`, `HttpOnly`, `SameSite=Strict` cookie. State-changing web requests require a session-bound CSRF token and strict same-origin validation; there is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the legacy recovery password, HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+28
-20
@@ -39,7 +39,7 @@ SSH role=admin ------> shared administration dispatcher <------ UART0
|
||||
10. Start the local status/control task if button initialization succeeded.
|
||||
11. Construct ESP-IDF's UART REPL to initialize `esp_console`, but do not start the stock REPL task. Register command groups, install completion, and start the custom UART frontend that feeds the shared dispatcher.
|
||||
|
||||
Several core initializers use `ESP_ERROR_CHECK`; optional display and network/security paths generally log failure while retaining UART0 administrative recovery and network-independent UART1 access through USB. SSH starts before command registration, so role-`user` sessions can be admitted in that interval while role-`admin` sessions are rejected until the administration frontend is ready.
|
||||
Several core initializers use `ESP_ERROR_CHECK`; optional display and network/security paths generally log failure while retaining UART0 administrative recovery and network-independent UART1 access through USB. Network services start before command registration, so role-`user` serial access can be admitted in that interval while remote admin-console admission is rejected until the administration frontend is ready.
|
||||
|
||||
## Serial service and physical ownership
|
||||
|
||||
@@ -96,15 +96,19 @@ TinyUSB callbacks enqueue/copy data and state; the transport task owns broker li
|
||||
|
||||
### HTTPS, WebSocket, and web serial
|
||||
|
||||
`web_server` runs HTTPS only on port 443 using the device-specific self-signed P-256 certificate from `web_security`. Current routes provide the UI, static assets, status, ticket issuance, and serial WebSocket upgrade.
|
||||
`web_server` runs HTTPS only on port 443 using the device-specific self-signed P-256 certificate from `web_security`. Current routes provide same-origin login/logout, the UI and static assets, status, typed admin operations, and separate serial/admin WebSocket ticket and upgrade paths.
|
||||
|
||||
HTTP Basic authentication uses `user_database`. Before administrator bootstrap, the migrated role-`user` account is synchronized from the legacy credential, so that username/password can authenticate through the database; after bootstrap, the legacy blob is independent recovery material and is no longer consulted for authentication or synchronized into role-based accounts. Both `user` and `admin` roles currently receive the same web status/terminal experience; web administration is not implemented.
|
||||
Browser passwords authenticate through `user_database`; the legacy web credential remains migration/recovery material and is not an active browser login after bootstrap. Successful login creates one of eight fixed eight-hour RAM sessions, with at most two retained per account. The raw 192-bit token is sent only in the host-only `__Host-sak-session` cookie (`Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`) while storage retains its SHA-256 digest, copied principal, generation-safe slot identity, and monotonic expiry. A boot-local key derives a deterministic session-bound CSRF token. Logout and every state-changing endpoint require strict `Origin == https://<Host>` and CSRF validation; a four-entry source-address limiter provides bounded, deliberately modest login throttling.
|
||||
|
||||
The boot-local Basic-authentication cache has four RAM entries and a five-minute sliding lifetime. It stores a keyed digest of the complete `Authorization` header rather than the raw header, and every hit revalidates principal currentness. Its current lack of locking relies on the single-HTTPD-owner execution model.
|
||||
The serial WebSocket requires a one-time exact-browser-session-bound ticket with a maximum 30-second lifetime. Tickets are stored as digests, consumed before currentness validation, and never persisted. Admission and active input revalidate both the copied principal and exact browser-session reference. An admitted serial connection starts the service if necessary, creates a broker client, and opportunistically requests writer ownership. The serial transport has two fixed slots and four ticket slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the permanent transport task mediates broker work through bounded scheduling.
|
||||
|
||||
A WebSocket connection requires a one-time, principal-bound ticket with a maximum 30-second lifetime. Only four tickets can be outstanding; minting another evicts the live entry with the earliest expiry. Ticket issuance and upgrade also validate a supplied `Origin` against `https://<Host>`; absence of `Origin` is accepted for non-browser clients. Tickets are stored as digests, consumed before currentness validation, and are never persisted. An admitted session starts the serial service if necessary, creates a broker client, and opportunistically requests writer ownership. The web transport has two fixed session slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the web transport task mediates broker work through bounded scheduling. The browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect; after a user-paused disconnect it changes to Connect, which resumes connection attempts.
|
||||
Administrators can separately mint a one-time ticket for one fixed browser admin-console WebSocket. That transport has its own permanent task, bounded input/output storage, copied admin principal, exact browser-session reference, and a single canonical-console slot; it never starts serial service or joins the broker. Terminal-mode switching changes only DOM visibility/focus and lazily opens the admin route, leaving the serial socket and writer lease alive. The combined Connect/Disconnect serial control explicitly manages only the serial route.
|
||||
|
||||
Web serial initialization is failure-isolated from the base HTTPS service: if the transport cannot initialize, `web_server_init()` can still succeed and serve authenticated non-WebSocket routes.
|
||||
Typed admin endpoints cover full serial framing/lifecycle/persistence operations; Wi-Fi lifecycle/profile rotation; guided user CRUD, roles, entered/generated passwords, and authorized Ed25519/P-256 key add/remove; generation-safe station-profile/AP/secret editing and exact-generation save; display-aging apply/save/load/defaults/reset; a bounded secret-free broker-client list; and atomic expected-writer-to-target transfer. All authenticate the admin role server-side; mutators revalidate the exact browser session after their bounded body is parsed and immediately before the typed side effect. The common URL-form parser decodes in its body buffer, accepts at most 512 bytes and 10 unique fields, and rejects duplicate fields. User edits compare the database generation plus stable user ID; Wi-Fi edits compare the working-config generation, and reads disclose only `secret_set` flags. On stale conflicts the browser reloads user/Wi-Fi state without replaying the request and clears entered/generated secret fields on failure or Settings close.
|
||||
|
||||
The Admin shell remains the web route for broader service/session controls, network diagnostics, security/danger operations, and unusual hardware/debug commands; those operations do not have guided forms.
|
||||
|
||||
Serial and admin WebSocket initialization are failure-isolated from base HTTPS. An unavailable transport leaves login and non-WebSocket routes available. A lifecycle mutex serializes start, stop, and TLS refresh; a generation records explicit desired-running intent so a post-material refresh cannot override a newer request. Certificate rotation and full material reset require that refresh after persistence. HTTPS stop first disables further transport-owned HTTPD calls and tracks calls already in progress. Failed HTTPD destruction retains the handle for a retry, while a timed-out or failed admin detach is finalized only after successful HTTPD destruction and remains pending for retry before a later start.
|
||||
|
||||
`web_ui.c` contains authored index/application strings and response policy. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets.
|
||||
|
||||
@@ -129,37 +133,41 @@ Network code holds copied, secret-free principals rather than pointers into data
|
||||
|
||||
Revocation has two layers:
|
||||
|
||||
1. after a database mutation commits, the command layer makes best-effort targeted WebSocket/SSH revocation calls; notification failure does not roll back the mutation;
|
||||
1. `user_admin_service` serializes typed web and console mutations with `admin_command_gate`; after a database mutation commits, it makes best-effort targeted WebSocket/SSH revocation calls, and notification failure does not roll back the mutation;
|
||||
2. transports periodically and at sensitive boundaries recheck principal currentness, providing authoritative fail-safe closure if notification fails.
|
||||
|
||||
The final administrator cannot be deleted or demoted. UART0 is trusted for initial administrator bootstrap and explicit unavailable-database recovery. Authenticated admin SSH can run the operational registry but is denied those two recovery operations; other secret-bearing commands are remotely available unless their handlers deny them.
|
||||
The web user editor supplies an expected database generation for every mutation and the stable target user ID for existing-account operations. A mismatch rejects stale state, including delete/recreate of the same username. The final administrator remains protected, and remote self-generated password replacement is rejected so its one-time result cannot be lost during revocation.
|
||||
|
||||
The final administrator cannot be deleted or demoted. UART0 is trusted for initial administrator bootstrap and explicit unavailable-database recovery. Authenticated admin SSH and the browser Admin shell can run the operational registry but are denied those two recovery operations; other secret-bearing commands are remotely available unless their handlers deny them.
|
||||
|
||||
NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials, legacy recovery credentials, and TLS/SSH private keys remain recoverable under physical flash extraction.
|
||||
|
||||
## Console architecture
|
||||
|
||||
UART0 and admin SSH share canonical command implementations:
|
||||
UART0, admin SSH, and the browser admin shell share canonical command implementations:
|
||||
|
||||
```text
|
||||
UART0 linenoise frontend --\
|
||||
> fixed request queue -> one dispatcher -> esp_console_run()
|
||||
admin SSH line editor ----/ |
|
||||
UART0 linenoise frontend -----\
|
||||
admin SSH line editor ---------> fixed request queue -> one dispatcher -> esp_console_run()
|
||||
browser admin line editor -----/ |
|
||||
+-> registered *_console handlers
|
||||
```
|
||||
|
||||
`admin_ssh_console` creates the dispatcher before network services but marks command dispatch ready only after ESP-IDF console registration and successful UART frontend task creation. An admin SSH connection during that boot window is rejected rather than racing an incomplete registry.
|
||||
`admin_ssh_console` creates the transport-neutral dispatcher before network services but marks command dispatch ready only after ESP-IDF console registration and successful UART frontend task creation. Remote admin admission during that boot window is rejected rather than racing an incomplete registry.
|
||||
|
||||
The dispatcher is the sole caller of `esp_console_run()`, serializing UART0 and all admin SSH commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes.
|
||||
The dispatcher is the sole caller of `esp_console_run()`, serializing UART0, admin SSH, and browser-admin commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes.
|
||||
|
||||
For SSH, standard output/error is redirected to the invoking session's bounded output ring. `console_input` routes visible or hidden prompts to UART0 or the active SSH session. `exit` and Ctrl+D on an empty admin SSH line use bounded deferred self-disconnect after their acknowledgement drains; role-`user` SSH remains a binary-transparent serial stream. Session tokens include slot and generation so late queued work cannot attach to a reused SSH slot. Only the SSH owner task moves ring output through wolfSSH.
|
||||
For remote frontends, standard output/error is redirected to the invoking session's bounded output ring. `console_input` routes visible or hidden prompts to UART0 or the active remote session. SSH and browser admin use the same bounded editor, four-entry history, completion formatter, overlong-line discard state, prompt handling, and generation-safe console tokens. Exact frontend callbacks revalidate the transport/session binding immediately before dispatch. Only the SSH owner task moves SSH ring output through wolfSSH; only the web-admin task queues browser output to HTTPD.
|
||||
|
||||
Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
|
||||
Remote `exit`, reboot, SSH stop/session disconnect/host-key changes, and web-origin HTTPS stop/restart use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
|
||||
|
||||
## Wi-Fi and persistence
|
||||
|
||||
`wifi_config` owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy `off`, `fallback`, or `always`. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used.
|
||||
|
||||
`wifi_manager` is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP deadlines, profile failover, AP policy, retries/backoff, next-profile requests, and the mDNS announcement lifecycle. `mdns_service` initializes the responder at most once after a validated STA `GOT_IP`; the managed component's own event handlers withdraw and restore the STA announcement across transient connectivity changes, while the project tracks whether announcement is currently expected. Initialization failure is latched rather than retried because partial upstream low-memory initialization is not safely recoverable; mDNS failure is nonfatal. It also reconciles against authoritative driver/netif state so dropped events do not permanently wedge policy. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save. Edits to disabled station profiles are staged in RAM without restarting the radio; enabling/disabling a profile or changing enabled station/AP policy restarts it asynchronously. Start/stop—including local controls—intentionally update the RAM `enabled_at_boot` field. Working-configuration copies contain PSKs and must be securely wiped; routine status and the local UI use secret-free snapshots.
|
||||
`wifi_manager` is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP deadlines, profile failover, AP policy, retries/backoff, next-profile requests, and the mDNS announcement lifecycle. `mdns_service` initializes the responder at most once after a validated STA `GOT_IP`; the managed component's own event handlers withdraw and restore the STA announcement across transient connectivity changes, while the project tracks whether announcement is currently expected. Initialization failure is latched rather than retried because partial upstream low-memory initialization is not safely recoverable; mDNS failure is nonfatal. It also reconciles against authoritative driver/netif state so dropped events do not permanently wedge policy. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save. Edits to disabled station profiles are staged in RAM without restarting the radio; enabling/disabling a profile or changing enabled station/AP policy restarts it asynchronously. Start/stop—including local controls—intentionally update the RAM `enabled_at_boot` field.
|
||||
|
||||
A dedicated config-writer mutex serializes complete working-config writers. The typed browser editor copies the credential-bearing config with its exact nonzero generation, modifies and validates the copy, and commits only through compare-and-swap; Save holds the same writer serialization through NVS and persists only the expected generation. Generation mismatch or exhaustion fails closed. Credential-bearing copies are securely wiped, browser reads return only per-profile/AP `secret_set` booleans, and routine status/local UI use secret-free snapshots.
|
||||
|
||||
Persistent namespaces/blobs include:
|
||||
|
||||
@@ -179,7 +187,7 @@ Configuration modules generally choose RAM defaults without erasing incompatible
|
||||
|
||||
When button GPIO initialization succeeds, `local_status_ui` starts a firmware-lifetime low-priority task that polls/debounces buttons, renders copied public snapshots, implements aging/wake behavior, and invokes a constrained set of public service APIs for local controls. It collects snapshots before opening a display frame, so service/broker locks are not held across I2C. It never parses CLI output, becomes a broker client, edits credentials, or assigns a writer; emergency action can only release the expected current writer.
|
||||
|
||||
The task can run with an absent OLED, and a fresh button press can request one bounded panel reprobe after successful I2C bus setup. Failed I2C bus creation is not recoverable through that path. The `display` configuration commands depend on the UI task. Long confirmation holds protect disruptive local actions, and stuck buttons are quarantined.
|
||||
The task can run with an absent OLED, and a fresh button press can request one bounded panel reprobe after successful I2C bus setup. Failed I2C bus creation is not recoverable through that path. The `display` configuration commands and typed `/api/admin/display` aging operations depend on the UI task; web Apply/Save/Load/Defaults/Reset reuse the local-UI validation and persistence contracts and share `admin_command_gate` with console display writers so each complete RAM/NVS operation is serialized. Long confirmation holds protect disruptive local actions, and stuck buttons are quarantined.
|
||||
|
||||
Hardware diagnostics are synchronous console commands. RS-232 tests own the physical port exclusively and restore safe GPIO state; OLED tests reuse the display service rather than taking independent I2C ownership.
|
||||
|
||||
@@ -187,9 +195,9 @@ Hardware diagnostics are synchronous console commands. RS-232 tests own the phys
|
||||
|
||||
- Broker, USB, web-transport, Wi-Fi, and SSH owner tasks are firmware-lifetime tasks; the local-UI task is also firmware-lifetime when button initialization allowed it to start. Stopping a service generally stops its runtime/listener, not the owner task.
|
||||
- Bounded queues, stream buffers, work bursts, and drop counters are part of slow-client and watchdog isolation.
|
||||
- Transport slot generations and account authentication generations solve different stale-reference problems; preserve both.
|
||||
- Transport slot generations, account authentication generations/stable IDs, Wi-Fi working-config generations, and HTTPS lifecycle generations solve different stale-reference problems; preserve each domain's checks.
|
||||
- Library/hardware ownership is centralized: serial task owns UART1 while running, display service owns I2C/framebuffer, the SSH owner task owns post-initialization wolfSSH runtime calls, and the console dispatcher owns `esp_console_run()`.
|
||||
- Password authentication performs PBKDF2 outside the user-database mutex and revalidates afterward. Some password mutation paths currently derive verifiers while holding the mutation lock; do not generalize the authentication locking pattern without checking the exact path.
|
||||
- Password authentication performs PBKDF2 outside the user-database mutex and revalidates afterward. Typed user mutations are serialized by `user_admin_service` plus `admin_command_gate`; typed display Apply/Save/Load/Defaults/Reset also use that gate with console display writers. Some password mutation paths derive verifiers while holding the mutation lock, so do not generalize the authentication locking pattern without checking the exact path.
|
||||
- Avoid holding service/database/broker locks across I2C, network sends, or other potentially long operations unless the existing contract explicitly requires it. Preserve the existing broker-before-serial lock order.
|
||||
- Serial RX/TX stream payloads, broker per-client payloads, the transactional user-database candidate, and selected cryptographic allocations prefer PSRAM with internal fallback. The live user database, FreeRTOS control structures, UART driver buffers, and task stacks remain internal where deterministic/cache-disable access matters.
|
||||
- The build disables wolfSSL ESP32 AES/SHA acceleration, and the HTTPS path uses software AES for PSRAM-backed records. This preserves the validated workaround for uncoordinated mbedTLS/wolfSSL hardware-crypto locks and a prior mbedTLS external-RAM DMA watchdog stall.
|
||||
|
||||
+28
-26
@@ -60,15 +60,15 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
|
||||
**Responsibility:** serve authenticated HTTPS UI/API, issue WebSocket tickets, and adapt browser serial sessions to broker clients.
|
||||
|
||||
- Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}`
|
||||
- Files: `src/web_server.{h,c}`, `src/web_session.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_admin_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}`
|
||||
- Security files: `src/web_security.{h,c}`
|
||||
- Asset files: authored/generated boundary in `src/web_assets_data.{h,c}`, `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py`
|
||||
- Interfaces: web init/start/stop/snapshots; HTTP handlers; ticket mint/consume; attach/detach; targeted session revocation
|
||||
- Interfaces: generation-tagged web init/start/stop/TLS refresh and snapshots; HTTP handlers including `/api/admin/users`, `/api/admin/wifi-config`, and `/api/admin/display`; ticket mint/consume; attach/detach/finalize; targeted session revocation
|
||||
- Called by: startup, ESP-IDF HTTPS server, user administration revocation, console/local UI
|
||||
- Dependencies: user database, secure random, broker, successful Wi-Fi manager initialization at boot, mbedTLS/HTTPS server; actual network reachability is an operational prerequisite, not an initializer invariant
|
||||
- Flow: `browser -> HTTPS Basic auth -> ticket -> WebSocket -> web transport -> broker`
|
||||
- Ownership: HTTPD owns socket send/close work; transport task owns broker mediation; two fixed WebSocket slots and four outstanding tickets.
|
||||
- Security constraints: Basic-auth cache hits still revalidate principal currentness; the browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect until Connect is selected. Changes to the authored inline loader must update its hard-coded CSP hash in the same change.
|
||||
- Flow: `browser -> HTTPS login session -> exact-session ticket -> serial WebSocket -> broker`; admin sessions may separately use `admin ticket -> admin WebSocket -> canonical dispatcher` without joining the broker.
|
||||
- Ownership: HTTPD owns socket send/close work; separate permanent web tasks own serial broker mediation and browser-admin console I/O; there are two serial slots/four serial tickets and one admin slot/two admin tickets. HTTPS lifecycle transitions are serialized separately from state snapshots, carry a lifecycle generation, and retain failed-stop/finalizer ownership for retry. The admin transport disables new HTTPD calls during detach and tracks calls already in progress.
|
||||
- Security constraints: eight opaque browser sessions retain digest-only tokens and copied current principals, with at most two sessions per account. Mutations require strict same-origin and session-bound CSRF checks. Typed URL-form bodies decode in their own storage and are limited to 512 bytes/10 unique fields. User and Wi-Fi editors use optimistic generations; user edits also bind stable user IDs, while Wi-Fi reads expose only `secret_set` flags. Terminal-mode switching never closes the serial socket or releases its writer lease; the combined control explicitly connects/disconnects only serial. Changes to the authored inline loader must update its hard-coded CSP hash in the same change.
|
||||
- Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
|
||||
|
||||
## SSH
|
||||
@@ -87,47 +87,47 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
|
||||
**Responsibility:** persist bounded accounts, verify passwords/SSH keys, issue secret-free principals, and enforce account invariants.
|
||||
|
||||
- Files: `src/user_database.{h,c}`, `src/user_console.{h,c}`; `src/admin_command_gate.{h,c}` is currently a narrow recursive wrapper used only by the `user` command handler, not the global command serializer
|
||||
- Interfaces: init/migration/recovery, authenticate, principal-currentness, account/password/role/key mutations, snapshots
|
||||
- Called by: web and SSH authentication/currentness checks and console administration
|
||||
- Dependencies: NVS, secure random, mbedTLS cryptography; after a committed command-layer mutation, best-effort web/SSH revocation calls supplement authoritative transport currentness checks
|
||||
- Ownership: database mutex protects the internal live record and PSRAM-preferred transactional candidate; password authentication runs PBKDF2 outside the mutex and revalidates afterward, while mutation locking must be checked per operation.
|
||||
- Authorization: UART0 exclusively owns initial administrator bootstrap and unavailable-database recovery; current admins may use admin SSH for other commands unless handler policy denies them. HTTPS currently treats both roles alike.
|
||||
- Files: `src/user_database.{h,c}`, `src/user_admin_service.{h,c}`, `src/user_console.{h,c}`, `src/admin_command_gate.{h,c}`
|
||||
- Interfaces: init/migration/recovery, authenticate, principal-currentness, account/password/role/key mutations, optimistic mutation results, snapshots
|
||||
- Called by: web and SSH authentication/currentness checks, `/api/admin/users`, and console administration
|
||||
- Dependencies: NVS, secure random, mbedTLS cryptography, web/SSH targeted revocation
|
||||
- Ownership: database mutex protects the internal live record and PSRAM-preferred transactional candidate; password authentication runs PBKDF2 outside the mutex and revalidates afterward. `user_admin_service` is the shared typed mutation boundary for web and console paths: its recursive `admin_command_gate` region serializes snapshot expectation checks plus commit, then performs best-effort web and SSH revocation after a committed change.
|
||||
- Authorization: UART0 exclusively owns initial administrator bootstrap and unavailable-database recovery. Both roles may use browser serial; only current admins may use guided admin APIs, the browser Admin shell, or admin SSH, subject to handler policy.
|
||||
- Constraint: final administrator cannot be deleted or demoted; transport principals must be rechecked after mutations.
|
||||
|
||||
## Administration console infrastructure
|
||||
|
||||
**Responsibility:** provide one canonical command registry and serialized execution for UART0 and admin SSH.
|
||||
**Responsibility:** provide one canonical command registry and serialized execution for UART0, admin SSH, and the browser Admin shell.
|
||||
|
||||
- Files: `src/admin_ssh_console.{h,c}`, `src/console_input.{h,c}`, `src/console_completion.{h,c}`, `src/system_console.{h,c}`, `src/network_console.{h,c}` and all `*_console.{h,c}` modules
|
||||
- Entry points: `admin_ssh_console_init()`, `admin_ssh_console_start_uart_frontend()`, command registration functions
|
||||
- Called by: startup, UART0 frontend, role-`admin` SSH transport
|
||||
- Files: `src/admin_ssh_console.{h,c}`, `src/console_input.{h,c}`, `src/console_completion.{h,c}`, `src/system_console.{h,c}` and all `*_console.{h,c}` modules
|
||||
- Entry points: `admin_ssh_console_init()`, `admin_ssh_console_start_uart_frontend()`, `admin_ssh_console_open()`, command registration functions
|
||||
- Called by: startup, UART0 frontend, role-`admin` SSH transport, browser admin transport
|
||||
- Dependencies: ESP-IDF console/linenoise, all command handlers, user-principal currentness
|
||||
- Flow: `UART0/admin SSH -> bounded request queue -> one dispatcher -> esp_console_run()`
|
||||
- Flow: `UART0/admin SSH/browser admin -> bounded request queue -> one dispatcher -> esp_console_run()`
|
||||
- Ownership: dispatcher is sole `esp_console_run()` caller; the SSH owner exclusively performs post-initialization wolfSSH runtime calls.
|
||||
- Lifecycle: remote session tokens include slot generation; fixed output/history/prompt state is wiped immediately on idle close or after an executing handler returns. Admin SSH `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort output draining.
|
||||
- Constraint: one slow command or prompt serializes all administration. Admin SSH is unavailable until command registration and UART frontend creation complete; supported deferred actions wait only for a bounded application-buffer drain heuristic.
|
||||
- Lifecycle: remote session tokens include frontend identity and slot generation; fixed output/history/prompt state is wiped immediately on idle close or after an executing handler returns. Remote `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort output draining.
|
||||
- Constraint: one slow command or prompt serializes all administration. Remote admin-console admission is unavailable until command registration and UART frontend creation complete; supported deferred actions wait only for a bounded application-buffer drain heuristic.
|
||||
|
||||
## Wi-Fi
|
||||
|
||||
**Responsibility:** persist station/AP policy and own asynchronous ESP-NETIF/Wi-Fi state transitions.
|
||||
|
||||
- Files: `src/wifi_config.{h,c}`, `src/wifi_manager.{h,c}`, `src/wifi_console.{h,c}`, `src/mdns_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_console.{h,c}`, `src/network_console.{h,c}`
|
||||
- Interfaces: config defaults/validate/load/save; manager init/start/stop/apply/reconnect/next-profile/snapshot
|
||||
- Called by: startup, console, local UI, ESP event callbacks
|
||||
- Interfaces: config defaults/validate/load/save; manager init/start/stop/apply/reconnect/next-profile/snapshot; versioned working-config copy, compare-and-swap, and exact-generation save
|
||||
- Called by: startup, console, local UI, typed web handlers, ESP event callbacks
|
||||
- Dependencies: secure random for default AP password, NVS, ESP-NETIF/Wi-Fi/events, Espressif mDNS, lwIP diagnostics
|
||||
- Lifecycle: permanent manager task and bounded queue; callbacks enqueue compact events only.
|
||||
- Constraint: application NVS is authoritative (`WIFI_STORAGE_RAM`); working edits are not persisted until save. Start/stop, including local controls, intentionally update the RAM `enabled_at_boot` field. Working-config copies contain PSKs and must be tightly scoped and wiped; routine status/local UI must use secret-free snapshots.
|
||||
- Constraint: application NVS is authoritative (`WIFI_STORAGE_RAM`); working edits are not persisted until save. Start/stop, including local controls, intentionally update the RAM `enabled_at_boot` field. Browser edits compare a nonzero working-config generation, and browser Save persists exactly that generation; stale or exhausted generations fail closed. Working-config copies contain PSKs and must be tightly scoped and wiped; web reads expose only `secret_set` flags, and routine status/local UI must use secret-free snapshots.
|
||||
|
||||
## Local display and controls
|
||||
|
||||
**Responsibility:** own OLED I2C/framebuffer operations and present status plus constrained button actions.
|
||||
|
||||
- Files: `src/local_display.{h,c}`, `src/local_status_ui.{h,c}`, `src/local_boot_animation.{h,c}`, `src/local_ui_config.{h,c}`, `src/local_ui_console.{h,c}`
|
||||
- Files: `src/local_display.{h,c}`, `src/local_status_ui.{h,c}`, `src/local_boot_animation.{h,c}`, `src/local_ui_config.{h,c}`, `src/local_ui_console.{h,c}`; shared writer serialization uses `src/admin_command_gate.{h,c}`
|
||||
- Interfaces: display init/frame/draw/commit/snapshot; UI start/activity/config; versioned NVS settings
|
||||
- Called by: startup, local UI task, diagnostics, display console
|
||||
- Called by: startup, local UI task, diagnostics, display console, and typed `/api/admin/display` handlers
|
||||
- Dependencies: copied snapshots/public APIs from serial, broker, USB, Wi-Fi, web, SSH
|
||||
- Ownership: `local_display` solely owns I2C0 and framebuffer mutex; a frame belongs to its initiating task.
|
||||
- Ownership: `local_display` solely owns I2C0 and framebuffer mutex; a frame belongs to its initiating task. Typed web Apply/Save/Load/Defaults/Reset and console display writers share `admin_command_gate`, serializing each complete working-config or persistence operation.
|
||||
- Lifecycle: the low-priority task is firmware-lifetime only if button GPIO initialization succeeds; it still runs with an absent panel so a press can reprobe after successful I2C bus setup. Failed bus creation is not recoverable by that reprobe, and `display` configuration commands depend on the UI task.
|
||||
- Constraint: collect service snapshots before I2C; local UI never joins broker or handles secrets.
|
||||
|
||||
@@ -155,8 +155,10 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
||||
| Change HTTPS endpoints/authentication | `web_server.*`, `web_security.*`, `user_database.*` |
|
||||
| Change SSH login or role routing | `ssh_transport.*`, `ssh_security.*`, `user_database.*` |
|
||||
| Add or change a command | relevant `*_console.c`, `console_completion.c`, `admin_ssh_console.c` policy/deferred handling |
|
||||
| Change account roles/passwords/keys | `user_database.*`, `user_console.c`, transport revocation APIs |
|
||||
| Change Wi-Fi policy or profile persistence | `wifi_manager.*`, `wifi_config.*`, `wifi_console.c` |
|
||||
| Change account roles/passwords/keys | `user_admin_service.*`, `user_database.*`, `user_console.c`, `/api/admin/users` handlers, transport revocation APIs |
|
||||
| Change Wi-Fi policy or profile persistence | `wifi_manager.*`, `wifi_config.*`, `wifi_console.c`, `/api/admin/wifi-config` handlers |
|
||||
| Change guided display aging | `local_ui_config.*`, `local_status_ui.*`, `/api/admin/display` handlers, `web_ui.c` |
|
||||
| Change HTTPS stop/restart or TLS-material refresh | `web_server.*`, `web_console.c`, `web_admin_transport.*`, deferred control in `admin_ssh_console.*` |
|
||||
| Change station mDNS hostname or persistence | `mdns_service.*`, `mdns_config.*`, `mdns_console.c`, then `wifi_manager.c` |
|
||||
| Change OLED rendering or buttons | `local_status_ui.c`, `local_display.*`, `local_ui_config.*` |
|
||||
| Change board GPIO or electrical tests | `board_pins.h`, hardware test module, `docs/wiring.md` |
|
||||
|
||||
+14
-15
@@ -4,12 +4,12 @@ This file is working memory. Update it during active work and before handoff; do
|
||||
|
||||
## Development state
|
||||
|
||||
Based on checked-in source plus `README.md` and `docs/roadmap.md`:
|
||||
Based on the current uncommitted worktree plus `README.md` and `docs/roadmap.md`:
|
||||
|
||||
- Hardware characterization, serial service, session broker, USB CDC, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and local display/control are implemented and documented as target-hardware validated.
|
||||
- Phase 8A role-based user storage/UART0 administration and Phase 8B role-aware HTTPS/SSH authentication and targeted revocation are documented as target-hardware validated.
|
||||
- Phase 8C admin SSH is implemented in source, uses the shared `esp_console` registry, and has passed target-hardware validation.
|
||||
- Phase 8D integrated web administration is planned, not implemented. Its ordered scope combines browser login/logout sessions, a shared admin-shell terminal route, typed settings, and contextual Serial/Wi-Fi/client/writer controls; changing terminal modes must preserve the browser serial broker client and any writer lease.
|
||||
- Phase 8D is in progress. Explicit browser sessions, exact-session serial/admin WebSocket binding, one browser frontend for the canonical admin dispatcher, typed serial controls, guided user CRUD/roles/passwords/authorized keys, generation-safe Wi-Fi profile/AP/secret editing and save, display-aging operations serialized with console writers through `admin_command_gate`, contextual client/status popovers, atomic writer transfer, and serialized retry-safe HTTPS lifecycle handling are implemented in the current worktree. All current Phase 8D target-hardware validation remains pending.
|
||||
- Security/production hardening, OTA, BLE evaluation, advanced networking, and optional filesystem features remain future roadmap work.
|
||||
- Reserved OTA, coredump, NVS-key, and storage partitions do not imply those runtime features are implemented.
|
||||
|
||||
@@ -20,16 +20,15 @@ Based on checked-in source plus `README.md` and `docs/roadmap.md`:
|
||||
- The 5,360-byte transactional user-database candidate now prefers PSRAM with internal fallback while the live database remains internal. Candidate contents are wiped after each transaction and wiped/freed on initialization or recovery failure.
|
||||
- UART and admin-SSH completion formatter buffers were reduced from 2 KiB to 1 KiB each; current worst-case output is 890 bytes and overflow remains fail-closed.
|
||||
- Linked RAM fell from 99,508 to 92,188 bytes (7,320 bytes). PSRAM placement of serial payloads additionally removes about 24 KiB of normal internal-heap pressure on the target.
|
||||
- `pio run` passes. A preliminary target run reports significantly more free memory and stable, improved operation after these changes. This is useful evidence but not completion of Phase 8C validation.
|
||||
- The reviewed mDNS-enabled build uses 94,532 bytes of linked static RAM, 2,344 bytes above the earlier 92,188-byte baseline, and 1,599,765 bytes of flash. Minimizing the managed component saved 112 bytes of linked RAM and about 5.9 KiB flash versus the first mDNS build. Its 4 KiB task stack remains internal, while checked-in settings move general mDNS allocations to PSRAM and disable unused browse, component CLI, AP/ETH, and multiple-instance features. Runtime heap impact still requires target measurement.
|
||||
- Remaining targeted checks include stored/migrated/recovered user-database mutations, USB enumeration, HTTPS start/stop failure recovery where injectable, SSH initialization/login, completion display, and sustained multi-transport serial traffic while checking `memory` telemetry.
|
||||
- The final current Phase 8D build footprint is 119,860 bytes linked static RAM and 1,715,753 bytes flash. Runtime heap/stack/socket headroom requires target measurement.
|
||||
|
||||
## Clearly incomplete or transitional areas
|
||||
|
||||
- Phase 8C hardware validation passed, including route separation, shared command serialization, history/completion, prompts, output backpressure, revocation during queued work, deferred SSH lifecycle/reboot actions, and full concurrent transport operation. At 460800 baud with SSH and WebSocket clients in parallel, substantial packet drops and slow display controls were observed under load, without memory exhaustion; no baud-rate reduction is planned.
|
||||
- Current HTTPS has no web-based user administration and gives both roles the same status/terminal routes.
|
||||
- Browser authentication still uses HTTP Basic; Phase 8D plans integrated login/logout sessions before exposing administrative browser routes.
|
||||
- NVS encryption, secure boot/flash encryption review, authentication rate limiting, production certificate/provisioning policy, and OTA are not implemented.
|
||||
- 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
|
||||
|
||||
@@ -42,17 +41,17 @@ These observations should be checked when touching the relevant area; they are n
|
||||
## Items to verify in future work
|
||||
|
||||
- Confirm task-local Newlib standard-stream behavior if ESP-IDF/Newlib configuration changes; admin SSH command output relies on dispatcher-task stream redirection.
|
||||
- If HTTPD concurrency configuration changes, add locking around the boot-local Basic-authentication cache.
|
||||
- If HTTPD changes from one handler task to concurrent request execution, protect or eliminate the shared 4 KiB typed-response scratch and audit all handler-local single-owner assumptions.
|
||||
|
||||
## Active Task
|
||||
|
||||
- **Objective:** Announce a configurable `sak-<suffix>.local` hostname through mDNS when Wi-Fi STA has an IPv4 address, without changing the Wi-Fi NVS blob schema.
|
||||
- **Relevant files:** `src/mdns_config.{c,h}`, `src/mdns_service.{c,h}`, `src/mdns_console.{c,h}`, `src/wifi_manager.{c,h}`, `src/main.c`, `src/CMakeLists.txt`, `src/idf_component.yml`, `dependencies.lock`, completion and command documentation.
|
||||
- **Findings:** `wifi_manager` already serializes all meaningful STA transitions through its permanent task; callbacks only enqueue events. This is the appropriate lifecycle owner for mDNS, while a separate configuration module preserves the existing `wifi_app/config` wire format.
|
||||
- **Decision:** Persist a fixed v1 record under `mdns_cfg/config`, separate from Wi-Fi configuration. Defaults derive a safe lower-case hexadecimal suffix from the STA MAC. The manager initializes mDNS at most once after validating `IP_EVENT_STA_GOT_IP`; the managed component's own handlers withdraw/restore the STA responder across connectivity changes, and online hostname changes use `mdns_hostname_set()` without teardown. Initialization failure is latched instead of retried because the resolved upstream 1.12.0 component has an unsafe partial low-memory initialization path. mDNS errors cannot fail Wi-Fi, UART0, UART1, or native USB.
|
||||
- **Changes completed:** Added the `espressif/mdns` managed dependency (resolved to 1.12.0 on IDF 5.5), mDNS config/service/console modules, `mdns status|suffix|save|load|defaults|reset`, completion, CMake integration, and command/architecture documentation. Minimized the component to STA-only responder use, moved general allocations to PSRAM, retained the internal task stack, and removed reconnect-time free/reinit churn. Final `pio run` passes at 94,532 bytes linked RAM and 1,599,765 bytes flash.
|
||||
- **Remaining work:** Target-hardware verification: associate a station and resolve the default `sak-<mac>.local`; change/save/load a suffix and confirm live reannouncement plus reboot persistence; stop Wi-Fi or remove the STA lease and confirm the record withdraws. Verify serial, native USB, and UART0 remain available if mDNS initialization fails.
|
||||
- **Risks / things to remember:** Hostnames are STA-only and are intentionally not announced by fallback AP mode. NVS changes to `mdns_cfg/config` are independent of the unchanged `wifi_app/config` blob. mDNS remains allocated after first successful initialization (including its internal 4 KiB task stack) to avoid fragmentation and unsafe repeated initialization; measure free/minimum/largest internal heap and mDNS stack margin during reconnect stress.
|
||||
- **Objective:** Finish documentation and target-hardware validation for the current Phase 8D integrated web-administration implementation without weakening broker, recovery, command-dispatch, concurrency, or secret-handling invariants.
|
||||
- **Relevant files:** `src/user_admin_service.{c,h}`, `src/web_session.{c,h}`, `src/web_server.{c,h}`, `src/web_serial_transport.{c,h}`, `src/web_admin_transport.{c,h}`, `src/web_ui.{c,h}`, `src/admin_ssh_console.{c,h}`, `src/wifi_manager.{c,h}`, `src/local_ui_config.{c,h}`, `src/local_ui_console.c`, `src/local_status_ui.{c,h}`, `src/ssh_transport.c`, `src/session_broker.{c,h}`, and Phase 8 documentation.
|
||||
- **Findings:** User web mutations need both database generation and stable user ID, while Wi-Fi edits/save need one exact working-config generation. Revocation is post-commit best effort, with principal currentness authoritative. HTTPD stop/finalizer failures require retained ownership and retry rather than unsafe reuse. Browser stale-conflict handling must clear secrets and reload without replay.
|
||||
- **Decisions made:** Route typed user mutations through `user_admin_service` and serialize them with `admin_command_gate`. Serialize typed display Apply/Save/Load/Defaults/Reset with console display writers through the same gate. Use full-config Wi-Fi compare-and-swap and exact-generation save, expose only `secret_set`, and fail closed on generation conflict/exhaustion. Serialize HTTPS lifecycle with generation-tagged intent and require post-material TLS refresh. Keep service/session controls, network diagnostics, security/danger operations, and unusual hardware/debug commands in the Admin shell rather than adding guided forms.
|
||||
- **Changes completed:** In addition to the browser-session/admin-shell/serial/client foundation, implemented guided user CRUD/roles/entered and one-time generated passwords/authorized Ed25519 and P-256 key add/remove, typed Wi-Fi profile/AP/secret editing and save, typed display aging serialized with console writers, secret clearing/stale reload behavior, centralized post-commit revocation, bounded 512-byte/10-field in-place form parsing, and serialized retry-safe HTTPS teardown/TLS refresh. The final current build footprint is 119,860 bytes linked static RAM and 1,715,753 bytes flash.
|
||||
- **Remaining work:** Run every current Phase 8D target-hardware procedure in `docs/user_administration_tests.md`; none is yet recorded as passed.
|
||||
- **Risks / things to remember:** Measure internal free/minimum/largest blocks and web-admin task stack margin under maximum HTTPS/SSH/serial concurrency. Exercise revocation-hook failure, Wi-Fi/user stale editors, self-revocation/final-admin protection, duplicate-key scope, secret clearing, display validation, failed HTTPD stop, and retryable post-stop finalization. The login limiter is bounded defense-in-depth, not strong distributed attack protection. Do not move browser admin through the broker or auto-release its preserved serial writer lease.
|
||||
|
||||
### Handoff template
|
||||
|
||||
|
||||
@@ -34,13 +34,13 @@ Only constraints supported by implementation or current project documentation be
|
||||
|
||||
## Resource IDs are generation-safe
|
||||
|
||||
**Decision:** Broker clients, SSH/WebSocket slots, queued admin work, and user principals carry generations or random stable IDs to reject stale references and slot reuse.
|
||||
**Decision:** Broker clients, SSH/WebSocket slots, queued admin work, user principals/accounts, Wi-Fi working configuration, and HTTPS lifecycle intent carry domain-specific generations or random stable IDs to reject stale references, slot reuse, and lost updates.
|
||||
|
||||
**Rationale/evidence:** Broker IDs encode slot generation; transports track slot generations; admin tokens include session/slot generation; user principal currentness includes account ID and authentication generation.
|
||||
**Rationale/evidence:** Broker IDs encode slot generation; transports track slot generations; admin tokens include session/slot generation; user principal currentness includes account ID and authentication generation; user/Wi-Fi web editors carry optimistic generations; HTTPS snapshots expose lifecycle generation.
|
||||
|
||||
**Consequence for future changes:** Preserve transport-slot generations and account-authentication generations as distinct concepts. Validate tokens immediately before side effects and discard late work after disconnect/reuse/revocation.
|
||||
**Consequence for future changes:** Preserve transport-slot, account-authentication/database, Wi-Fi working-config, and HTTPS lifecycle generations as distinct concepts. Validate tokens immediately before side effects and discard late work after disconnect/reuse/revocation. Existing-account web mutations must also compare stable user ID so deletion/recreation of the same username cannot retarget stale work.
|
||||
|
||||
**Relevant files:** `src/session_broker.{h,c}`, `src/ssh_transport.c`, `src/web_serial_transport.c`, `src/admin_ssh_console.c`, `src/user_database.{h,c}`
|
||||
**Relevant files:** `src/session_broker.{h,c}`, `src/ssh_transport.c`, `src/web_serial_transport.c`, `src/admin_ssh_console.c`, `src/user_database.{h,c}`, `src/user_admin_service.{h,c}`, `src/wifi_manager.{h,c}`, `src/web_server.{h,c}`
|
||||
|
||||
## UART0 is the physical recovery authority
|
||||
|
||||
@@ -92,6 +92,76 @@ Only constraints supported by implementation or current project documentation be
|
||||
|
||||
**Relevant files:** `src/user_database.{h,c}`, `src/user_console.c`, `src/web_server.c`, `src/web_serial_transport.c`, `src/ssh_transport.c`
|
||||
|
||||
## Browser authentication uses bounded explicit sessions
|
||||
|
||||
**Decision:** Browser access uses a same-origin login/logout flow and a fixed RAM session table rather than HTTP Basic. Raw opaque tokens exist only in host-only secure cookies; firmware storage retains token digests, copied principals, monotonic expiry, and generation-safe slot identity. State-changing requests require a session-bound CSRF token and exact Origin validation.
|
||||
|
||||
**Rationale/evidence:** Explicit logout and account switching cannot reliably invalidate browser-managed HTTP Basic credentials. Exact browser-session references also allow logout of one session without revoking another session for the same account.
|
||||
|
||||
**Consequence for future changes:** Preserve digest-only storage, the two-per-account/eight-global capacity, exact-session WebSocket binding, and current-principal checks. Do not expose tokens, CSRF values, ticket values, or internal references in logs/snapshots. New mutation endpoints must use the body-backed in-place URL-form parser's 512-byte/10-unique-field bounds, parse closed schemas, and revalidate the exact admin session immediately before typed side effects. Browser failure/close paths must clear entered and generated secret material rather than replaying it after a reload.
|
||||
|
||||
**Relevant files:** `src/web_session.{h,c}`, `src/web_server.c`, `src/web_serial_transport.c`, `src/web_admin_transport.c`
|
||||
|
||||
## Browser admin shell is separate from browser serial
|
||||
|
||||
**Decision:** An administrator may keep the browser serial WebSocket alive while independently opening one admin-only WebSocket frontend for the canonical command dispatcher. Terminal switching changes only browser visibility and focus; the admin route never becomes a broker client.
|
||||
|
||||
**Rationale/evidence:** This preserves a browser-held writer lease while giving full canonical administrative command parity without a generic HTTP command endpoint.
|
||||
|
||||
**Consequence for future changes:** Do not multiplex admin command bytes into `/ws/serial`, and do not close or release the serial route as a side effect of mode switching, settings navigation, or popover display. HTTPD remains the owner of WebSocket send/close calls; the web-admin task only queues bounded work. Self-affecting HTTPS commands must use deferred drain control.
|
||||
|
||||
**Relevant files:** `src/web_admin_transport.{h,c}`, `src/admin_ssh_console.{h,c}`, `src/web_ui.c`, `src/web_server.c`
|
||||
|
||||
## Browser writer transfer is atomic and generation-safe
|
||||
|
||||
**Decision:** Guided writer assignment compares the expected current writer and validates the exact generation-safe target under the broker mutex before making one atomic ownership change.
|
||||
|
||||
**Rationale/evidence:** A browser dialog can become stale while open. Unconditional force assignment could overwrite a newer legitimate lease or release ownership when its target disconnected.
|
||||
|
||||
**Consequence for future changes:** Use `session_broker_compare_exchange_writer()` for stale UI/API transfers. Opening or hovering a writer control must never mutate ownership, and target/current conflicts must leave the current lease unchanged.
|
||||
|
||||
**Relevant files:** `src/session_broker.{h,c}`, `src/web_server.c`, `src/web_ui.c`
|
||||
|
||||
## User mutations have one serialized typed boundary
|
||||
|
||||
**Decision:** `user_admin_service` owns typed account/password/role/key mutations for console and web callers. Its recursive `admin_command_gate` critical region includes the optimistic snapshot check and database commit; a committed mutation is followed by independent best-effort web and SSH revocation attempts.
|
||||
|
||||
**Rationale/evidence:** Browser requests can race one another and canonical shell commands. Database generation plus stable user ID reject stale editors and username delete/recreate races, while the shared gate prevents caller-specific check-then-mutate interleaving. Revocation cannot be made atomic with the NVS commit, so principal currentness remains authoritative.
|
||||
|
||||
**Consequence for future changes:** Route new ordinary user mutations through this service instead of calling `user_database` directly. Do not roll back or report a committed mutation as failed solely because a transport notification failed. Preserve final-admin checks, remote self-generated-password restrictions, secret wiping, and generation/user-ID conflict reporting.
|
||||
|
||||
**Relevant files:** `src/user_admin_service.{h,c}`, `src/user_database.{h,c}`, `src/user_console.c`, `src/admin_command_gate.{h,c}`, `src/web_server.c`, `src/ssh_transport.c`
|
||||
|
||||
## Display configuration writers share the administration gate
|
||||
|
||||
**Decision:** Typed browser display Apply/Save/Load/Defaults/Reset operations and console display-writer commands serialize through the recursive `admin_command_gate` for the complete RAM and persistence operation.
|
||||
|
||||
**Rationale/evidence:** Browser handlers and canonical console frontends can mutate the same local-UI working configuration and NVS record concurrently. Serializing only individual lower-level calls could allow interleaved apply/save/load/reset sequences and inconsistent final state.
|
||||
|
||||
**Consequence for future changes:** Keep all new display configuration writers under the same gate, including any read-modify-write and rollback sequence. Do not hold the gate for read-only status or rendering work, and do not conflate it with the display framebuffer/I2C mutex.
|
||||
|
||||
**Relevant files:** `src/web_server.c`, `src/local_ui_console.c`, `src/local_ui_config.{h,c}`, `src/local_status_ui.{h,c}`, `src/admin_command_gate.{h,c}`
|
||||
|
||||
## Guided Wi-Fi editing uses exact-generation compare-and-swap
|
||||
|
||||
**Decision:** Browser Wi-Fi reads return configuration metadata plus `secret_set` flags, never PSKs. Each typed edit applies a complete validated working-config copy only if its expected nonzero generation remains current, and Save persists exactly the expected generation under the same writer serialization.
|
||||
|
||||
**Rationale/evidence:** Multiple browser editors, console changes, and lifecycle controls can update RAM configuration concurrently. A conventional read/modify/write or copy-then-save sequence could overwrite a newer secret or persist a generation the user never reviewed.
|
||||
|
||||
**Consequence for future changes:** Keep credential-bearing copies tightly scoped and wiped. Add config writers under the writer mutex and advance generation without wraparound; mismatch or exhaustion must fail closed. Stale browser forms must reload without replay and clear entered secrets.
|
||||
|
||||
**Relevant files:** `src/wifi_manager.{h,c}`, `src/wifi_config.{h,c}`, `src/wifi_console.c`, `src/web_server.c`, `src/web_ui.c`
|
||||
|
||||
## HTTPS lifecycle and post-material refresh are serialized
|
||||
|
||||
**Decision:** HTTPS start, stop, and TLS refresh share a lifecycle mutex and generation-tagged desired-running intent. Certificate/material replacement always proceeds to a TLS refresh. Teardown disables new admin-transport HTTPD calls, tracks calls already in progress, retains a server whose stop failed, and keeps incomplete post-stop finalization pending for retry before another start.
|
||||
|
||||
**Rationale/evidence:** Browser-shell commands can tear down their own transport while console commands race a restart or replace persisted TLS material. HTTPD-owned queued work must finish before its handle or the admin transport's static state can be reused.
|
||||
|
||||
**Consequence for future changes:** Do not start a second server around a retained handle, bypass lifecycle serialization, or make post-material refresh optional after persistence. A newer explicit lifecycle intent must win over an older refresh. Finalize timed-out detach state only after HTTPD destruction, and retry a failed finalizer before attaching a replacement server.
|
||||
|
||||
**Relevant files:** `src/web_server.{h,c}`, `src/web_console.c`, `src/web_admin_transport.{h,c}`, `src/admin_ssh_console.{h,c}`
|
||||
|
||||
## Security material and configuration use bounded, versioned NVS records
|
||||
|
||||
**Decision:** Application settings, users, and identities use separate fixed/versioned NVS blobs. Serial, Wi-Fi, 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Command reference
|
||||
|
||||
UART0 and authenticated `admin` SSH sessions use the same registered command implementations through one serialized dispatcher. Admin SSH exposes the full operational registry, including interactive prompts, recovery-secret display, network diagnostics, reboot, and HTTPS/SSH material mutation. Initial administrator bootstrap and explicit recovery of an unavailable user database remain physically bound to UART0. Admin SSH also rejects generating a replacement password for its own account so the one-time value cannot be lost when the session is revoked. Run `help` for root commands and `<group> help` for a group summary. Configuration changes are RAM-only unless explicitly saved.
|
||||
UART0, authenticated `admin` SSH sessions, and the browser Admin shell use the same registered command implementations through one serialized dispatcher. The remote shells expose the operational registry, including interactive prompts, recovery-secret display, network diagnostics, reboot, and HTTPS/SSH material mutation. Initial administrator bootstrap and explicit recovery of an unavailable user database remain physically bound to UART0. A remote administrator cannot generate a replacement password for its own account, so the one-time value cannot be lost when the session is revoked. Run `help` for root commands and `<group> help` for a group summary. Configuration changes are RAM-only unless explicitly saved.
|
||||
|
||||
## System
|
||||
|
||||
@@ -36,6 +36,8 @@ On the first Phase 8A boot, the old shared `admin` credential is imported as a r
|
||||
|
||||
`user recover --force` is a destructive physical recovery operation and succeeds only while the database is unavailable. It replaces the user blob with one role-`user` account derived from the current legacy credential; run `user bootstrap` afterward. It does not erase unrelated NVS data. Successful password, role, key, bootstrap, and delete operations invalidate only that username's outstanding WebSocket tickets and active WebSocket/SSH sessions; unrelated users remain connected.
|
||||
|
||||
The administrator-only Settings dialog uses `/api/admin/users` for guided account create/list/edit/delete, role changes, entered or one-time generated passwords, and authorized Ed25519/P-256 key add/remove. Mutations include the displayed database generation and stable user ID, so a stale editor is rejected and reloaded instead of targeting a deleted/recreated account. CLI and browser mutations share `user_admin_service`; it serializes mutations with `admin_command_gate`, commits first, and then requests best-effort web and SSH revocation. Transport currentness checks remain authoritative if notification is incomplete. Entered keys/passwords and generated-password output are cleared from the dialog on close or failure.
|
||||
|
||||
## Local display
|
||||
|
||||
| Command | Description |
|
||||
@@ -46,7 +48,7 @@ On the first Phase 8A boot, the old shared `admin` credential is imported as a r
|
||||
| `display save` / `display load` | Save the working aging settings to NVS or load them. |
|
||||
| `display defaults` / `display reset` | Apply 300/600-second defaults in RAM, or apply and persist them. |
|
||||
|
||||
When both transitions are enabled, `off-seconds` must be greater than `dim-seconds`. Applying settings counts as local UI activity. At normal boot, an initialized OLED shows a bounded five-second identity animation before the status UI begins; it scrolls the device name in yellow and draws the compact upright-terminal logo in blue. A missing OLED remains nonfatal; after reconnecting it safely, one new button press requests a bounded reprobe and is consumed without navigating.
|
||||
When both transitions are enabled, `off-seconds` must be greater than `dim-seconds`. Applying settings counts as local UI activity. The administrator-only Settings dialog exposes the same typed Apply/Save/Load/Defaults/Reset behavior through `/api/admin/display`; all five operations are serialized with console display writers through `admin_command_gate`, and invalid aging combinations are rejected without applying them. At normal boot, an initialized OLED shows a bounded five-second identity animation before the status UI begins; it scrolls the device name in yellow and draws the compact upright-terminal logo in blue. A missing OLED remains nonfatal; after reconnecting it safely, one new button press requests a bounded reprobe and is consumed without navigating.
|
||||
|
||||
## Serial service
|
||||
|
||||
@@ -106,7 +108,9 @@ Opening `/dev/ttyACM*` with DTR asserted creates the `usb-cdc` broker client, st
|
||||
| `wifi nslookup <host>` | Resolve and display unique IPv4/IPv6 addresses. |
|
||||
| `wifi traceroute <host> [max-hops]` | Run IPv4 ICMP traceroute with up to 30 hops. |
|
||||
|
||||
`ping`, `nslookup`, and `traceroute` are root aliases. The four station-profile slots use lower priority values first. Edits to a disabled profile's SSID, priority, security mode, or secret are staged in RAM and do not interrupt the current Wi-Fi connection. Enabling or disabling a profile, changing an enabled profile, or changing AP policy/configuration applies the new radio policy and may reconnect Wi-Fi. Use `wifi save` to persist working changes. Passwords are not displayed by ordinary status output.
|
||||
`ping`, `nslookup`, and `traceroute` are root aliases. The four station-profile slots use lower priority values first. Edits to a disabled profile's SSID, priority, security mode, or secret are staged in RAM and do not interrupt the current Wi-Fi connection. Enabling or disabling a profile, changing an enabled profile, or changing AP policy/configuration applies the new radio policy and may reconnect Wi-Fi. Use `wifi save` to persist working changes.
|
||||
|
||||
The administrator-only Settings dialog uses `/api/admin/wifi-config` for typed station-profile, AP policy/SSID/channel, and write-only secret edits. Reads return only `secret_set` flags. Every mutation compares the exact working-configuration generation, and Save persists only that same generation; stale or exhausted generations fail closed without applying or saving another editor's state. On a conflict, the browser clears entered secrets and reloads current values without replaying the request.
|
||||
|
||||
## mDNS
|
||||
|
||||
@@ -124,16 +128,22 @@ When the Wi-Fi station receives an IPv4 address, the Wi-Fi manager announces `sa
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `web` / `web help` | Show web-service command usage. |
|
||||
| `web status` | Show HTTPS and WebSocket state. |
|
||||
| `web status` | Show HTTPS, browser-session, serial-WebSocket, and admin-WebSocket state. |
|
||||
| `web start` / `web stop` | Start or stop HTTPS service. |
|
||||
| `web counters` / `web clear-counters` | Show or clear web counters. |
|
||||
| `web credentials show` | Display the legacy migration/recovery credential on UART0 or authenticated admin SSH; it is not a role-based network login. |
|
||||
| `web credentials show` | Display the legacy migration/recovery credential on UART0 or an authenticated remote admin shell; it is not a role-based network login. |
|
||||
| `web credentials rotate --force` | Replace the legacy recovery credential and synchronize the migrated pre-bootstrap account only. |
|
||||
| `web certificate info` | Display certificate identity and fingerprint. |
|
||||
| `web certificate rotate --force` | Replace the HTTPS certificate and private key. |
|
||||
| `web reset --force` | Explicitly replace missing, incompatible, or damaged legacy credentials and web material. |
|
||||
|
||||
HTTPS listens on port 443 only. Authenticate with any current user-database username/password; both `user` and `admin` roles receive the existing status and browser-terminal interface. The device serves vendored xterm.js without Internet access. Browser sessions use one-time account-bound tickets, binary WebSocket frames, and the broker's one-writer rule. The combined **Connect**/**Disconnect** control closes the current WebSocket and pauses automatic reconnect when active; after a user-paused disconnect, it changes to **Connect** to resume connection attempts. Account mutations revoke only that account's tickets and sessions.
|
||||
HTTPS listens on port 443 only. Sign in with any current user-database username/password through the same-origin login page; explicit logout permits account switching without relying on a browser HTTP-authentication cache. The device stores at most eight opaque eight-hour browser sessions, with at most two retained per account, and sends the raw token only in a host-only secure cookie. State-changing requests require strict Origin and session-bound CSRF validation.
|
||||
|
||||
Both roles receive the offline browser serial terminal. Its one-time ticket and active WebSocket are bound to the exact browser session and obey the broker's one-writer rule. The combined **Connect serial**/**Disconnect serial** control closes only the serial WebSocket and pauses automatic reconnect when active. Account mutations revoke that account's browser sessions, serial/admin tickets, and WebSockets without disturbing unrelated accounts.
|
||||
|
||||
An administrator additionally receives a **Serial terminal**/**Admin shell** selector, typed Serial controls, guided user/password/role/authorized-key management, generation-safe Wi-Fi profile/AP/secret editing and saving, display-aging controls, and contextual Serial, Wi-Fi, broker-client, and writer-transfer popovers. The browser admin shell uses the same bounded editor, history, completion, prompts, serialized dispatcher, and registered command handlers as admin SSH and UART0; it never joins the serial broker. Switching terminal modes only changes visibility and focus: it does not close the serial WebSocket or release its writer lease. Writer transfer requires an explicit confirmation and atomically checks both the expected current writer and generation-safe target ID. Service/session controls beyond the guided serial/Wi-Fi actions, network diagnostics, security/danger operations, and unusual hardware/debug commands remain shell-only. Initial `user bootstrap` and `user recover --force` remain physical-UART0-only.
|
||||
|
||||
Typed mutation endpoints accept only body-backed URL-encoded forms bounded to 512 bytes and 10 unique fields; duplicate, oversized, malformed, stale-session, wrong-origin, and wrong-CSRF requests fail without a side effect. HTTPS start/stop and TLS refresh are serialized and carry a lifecycle generation. Certificate rotation and full material reset always trigger a post-material TLS refresh; a newer explicit lifecycle request takes precedence. Teardown disables transport-owned HTTPD calls, tracks any already in progress, retains an HTTPD handle after stop failure, and retries pending post-stop admin-transport finalization before a later start.
|
||||
|
||||
## SSH serial transport
|
||||
|
||||
@@ -151,7 +161,7 @@ HTTPS listens on port 443 only. Authenticate with any current user-database user
|
||||
|
||||
SSH listens on port 22 and accepts user-database passwords plus stored `ssh-ed25519` and `ecdsa-sha2-nistp256` public keys. wolfSSH verifies key possession after the database authorizes the username/key pair; unsigned key probes do not complete authentication. A `user` receives the broker-backed UART1 serial stream. An `admin` receives the administration shell instead, does not become a broker client, and cannot acquire a UART1 writer lease.
|
||||
|
||||
UART0 and admin SSH submit to one bounded queue, and one dispatcher task is the sole caller of `esp_console_run()`. Consequently, SSH commands execute the canonical UART0 handlers and produce the same status and mutation behavior rather than using a second command implementation. Remote output is routed into the authenticated session's bounded output ring; only the SSH transport task accesses wolfSSH.
|
||||
UART0, admin SSH, and the browser admin shell submit to one bounded queue, and one dispatcher task is the sole caller of `esp_console_run()`. Consequently, remote commands execute the canonical UART0 handlers rather than using separate command implementations. Each remote frontend has generation-safe session identity, exact authorization checks, bounded editor/history/output state, and transport-owned network I/O; only the SSH transport task accesses wolfSSH and only HTTPD performs browser WebSocket sends/closes.
|
||||
|
||||
UART0 and admin SSH use shared whole-line Tab completion. A unique/common prefix expands inline; a Tab that cannot extend an ambiguous prefix prints the matching candidates and redraws the unchanged input line instead of cycling candidates. Admin SSH additionally supports four-entry per-session command history with Up/Down, inline cursor editing with Left/Right, Home/End (including Pos1/Ende terminal sequences), Backspace/Delete, Ctrl-C, and visible or no-echo interactive prompts. Its history is RAM-only, private to the session, and wiped on disconnect. Ping callbacks enqueue bounded typed results so all formatting remains on the dispatcher task.
|
||||
|
||||
|
||||
+19
-13
@@ -38,7 +38,7 @@ These constraints apply across all phases:
|
||||
| 5B | Offline xterm.js WebSocket serial terminal | **Complete** |
|
||||
| 6 | Authenticated SSH serial transport | **Complete** |
|
||||
| 7 | Local display and button interface | **Complete** |
|
||||
| 8 | Role-based users and administrative access | **In progress (8A–8C complete; integrated web administration 8D planned)** |
|
||||
| 8 | Role-based users and administrative access | **In progress (8A–8C complete; current 8D guided workflows implemented, all 8D target validation pending)** |
|
||||
| 9 | Security and production hardening | **Planned** |
|
||||
| 10 | Authenticated, rollback-capable OTA | **Planned** |
|
||||
| 11 | BLE serial transport and provisioning evaluation | **Planned** |
|
||||
@@ -171,7 +171,7 @@ Implemented and target-hardware validated:
|
||||
|
||||
## Current and planned phases
|
||||
|
||||
Phase 8A through 8C are complete and target-hardware validated; integrated web administration in 8D remains planned. Later work remains planned or under evaluation. Optional features must not weaken the completed serial and recovery paths.
|
||||
Phase 8A through 8C are complete and target-hardware validated. The current Phase 8D browser-session, admin-shell, typed serial/user/Wi-Fi/display, broker-client, and writer-transfer implementation is present, but all Phase 8D target-hardware validation remains pending. Later work remains planned or under evaluation. Optional features must not weaken the completed serial and recovery paths.
|
||||
|
||||
### Phase 8 — Role-based users and administrative access
|
||||
|
||||
@@ -198,18 +198,24 @@ Implementation sequence:
|
||||
- `ssh sessions` and `ssh counters` identify broker versus admin-console routes, worker command state, queued admin output, admission failures, and input backpressure. `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort application-buffer draining. Admin sessions are checked for a current `admin` principal before command execution and during the active-session reconciliation.
|
||||
- Keep SFTP, SCP, `exec`, forwarding, subsystems, and unauthenticated shells disabled.
|
||||
- Target-hardware validation passed for route separation, history/Tab editing, interactive visible/hidden prompts, output/backpressure, generated and entered user/password/key management including the longest ECDSA P-256 import, ping event routing, deferred reboot/SSH lifecycle drain behavior, bootstrap/recovery rejection, targeted self/other-user revocation during queued work, UART0/SSH administration serialization, and concurrent USB/WebSocket/user-SSH/admin-SSH operation. Stress at 460800 baud with SSH and WebSocket clients caused substantial expected packet drops and slower display controls, but did not exhaust memory or require lowering the supported baud-rate range.
|
||||
4. **Phase 8D — Integrated web administration — Planned**
|
||||
- Begin with integrated authentication: replace browser-facing HTTP Basic authentication with a same-origin HTTPS login page, explicit logout, and bounded opaque server-side sessions. Store only a digest of each random session token with a copied secret-free principal, expiry, CSRF state, and authentication-generation binding. Send the raw token only in a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain`, and an explicit lifetime; never retain passwords, Basic headers, raw tokens, verifiers, or SSH-key blobs in snapshots or logs.
|
||||
- Make logout invalidate the current server-side session, expire its cookie, close that session's serial and administrative WebSockets, and redirect to login. Password/role/key mutation, deletion, recreation, and explicit revocation invalidate the affected account's web sessions and tickets without disturbing unrelated accounts. Require the CSRF token plus strict same-origin checks for every state-changing request, including logout, and rate-limit login attempts with bounded secret-free accounting.
|
||||
- Add an admin-only **Serial terminal**/**Admin shell** selector. The administrative route uses a short-lived, single-use, admin-principal-bound ticket and a bounded WebSocket frontend for the same serialized command registry used by UART0 and admin SSH; it is not a generic HTTP command-execution endpoint. Normal users retain the existing serial interface and cannot mint, upgrade, or invoke administrative routes. Remote policy still rejects physical-only `user bootstrap` and `user recover --force`.
|
||||
- Switching the visible terminal between Serial and Admin changes only the displayed terminal route. It must not disconnect the browser's serial broker client, release its writer lease, or silently stop serial observation. Keep the writer/observer badge plus Request control/Release control visible in both modes so an administrator knows the retained state and cannot unintentionally lose the lease to another client. Explicit Disconnect, logout, revocation, session expiry, or an explicit release/transfer operation still performs normal broker cleanup.
|
||||
- Add an admin-only Settings area backed by typed, bounded subsystem APIs rather than generated CLI strings. Cover user/password/role/SSH-key management, serial configuration and persistence, Wi-Fi profiles/AP policy/secrets, service and session controls, display settings, network diagnostics, and carefully separated security/danger-zone operations. The admin shell provides full remote operational parity and the settings pages provide guided high-frequency workflows; unusual electrical/debug operations may remain shell-only.
|
||||
- Add contextual admin quick settings to the existing status cards. Hover, keyboard focus, or click on **Serial** opens a popover with current framing/lifecycle state, safe common edits, apply/save semantics, and a link to full Serial settings. The **Wi-Fi** card similarly exposes connection/profile controls and a link to full Network settings without revealing saved secrets by default. Touch and keyboard users must receive the same functionality as pointer hover, with Escape/outside-click dismissal and no action triggered merely by opening a popover.
|
||||
- Hover, focus, or click on **Broker clients** opens a live, secret-free client list with IDs, transport, writer/observer state, and bounded queue/drop information. Activating **Active writer** opens an admin-only transfer dialog listing current eligible clients; transfer is an explicit confirmed generation-safe administrative assignment, never a side effect of opening or hovering. Stale/disconnected targets fail visibly without changing the current lease. Normal users may retain ordinary aggregate status but receive neither client-management details nor mutation controls.
|
||||
- Preserve strict CSP, no-referrer/frame-denial policy, no-store responses for login/session/admin material, secret-safe JSON encoding, one-time generated-password display, and bounded request/response bodies. Keep UART0 recovery, native USB UART1 access, and existing SSH behavior available if web sessions or administration cannot initialize.
|
||||
- Add secret-free counters/snapshots for session capacity, expiry, login failure/backoff, logout, invalidation, CSRF/origin rejection, admin-console admission/backpressure, typed-API failure, and writer-transfer races. Compatibility HTTP Basic, if retained temporarily during migration, must be explicit, separately constrained, and unable to bypass logout or revocation.
|
||||
4. **Phase 8D — Integrated web administration — In progress**
|
||||
- **Implemented, validation pending:** replace browser-facing HTTP Basic authentication with a same-origin HTTPS login page, explicit logout, and bounded opaque server-side sessions. Store only a digest of each random session token with a copied secret-free principal, expiry, CSRF state, and authentication-generation binding. Send the raw token only in a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain`, and an explicit lifetime; never retain passwords, Basic headers, raw tokens, verifiers, or SSH-key blobs in snapshots or logs.
|
||||
- **Implemented, validation pending:** logout invalidates the current server-side session, expires its cookie, closes that session's serial and administrative WebSockets, and redirects to login. Password/role/key mutation, deletion, recreation, and explicit revocation invalidate the affected account's web sessions and tickets without disturbing unrelated accounts. Require the CSRF token plus strict same-origin checks for every state-changing request, including logout, and rate-limit login attempts with bounded secret-free accounting.
|
||||
- **Implemented, validation pending:** an admin-only **Serial terminal**/**Admin shell** selector. The administrative route uses a short-lived, single-use, admin-principal-bound ticket and a bounded WebSocket frontend for the same serialized command registry used by UART0 and admin SSH; it is not a generic HTTP command-execution endpoint. Normal users retain the existing serial interface and cannot mint, upgrade, or invoke administrative routes. Remote policy still rejects physical-only `user bootstrap` and `user recover --force`.
|
||||
- **Implemented, validation pending:** switching the visible terminal between Serial and Admin changes only the displayed terminal route. It must not disconnect the browser's serial broker client, release its writer lease, or silently stop serial observation. Keep the writer/observer badge plus Request control/Release control visible in both modes so an administrator knows the retained state and cannot unintentionally lose the lease to another client. Explicit Disconnect, logout, revocation, session expiry, or an explicit release/transfer operation still performs normal broker cleanup.
|
||||
- **Implemented, validation pending:** the admin-only Settings area uses typed, bounded APIs rather than generated CLI strings. Full serial framing/lifecycle/load/default/save controls and Wi-Fi lifecycle/profile-rotation actions remain available.
|
||||
- **Implemented, validation pending:** `/api/admin/users` provides guided account CRUD, role changes, entered or one-time generated passwords, and authorized Ed25519/P-256 key add/remove. Existing-account mutations carry both the optimistic database generation and stable user ID, so stale state and delete/recreate races fail without retargeting. `user_admin_service` serializes CLI and web mutations with `admin_command_gate`; after a committed mutation it requests best-effort web and SSH revocation without rolling back the database if notification fails.
|
||||
- **Implemented, validation pending:** `/api/admin/wifi-config` provides typed station profile, enable/disable/delete, AP policy/SSID/channel, write-only station/AP secret, and Save operations. Reads disclose only `secret_set` flags. Every edit is a full validated compare-and-swap against the expected nonzero working generation, Save persists exactly the expected generation, and generation conflicts or exhaustion fail closed.
|
||||
- **Implemented, validation pending:** `/api/admin/display` provides typed display-aging Apply/Save/Load/Defaults/Reset operations with the same validation and persistence semantics as the console. All five operations serialize with console display writers through `admin_command_gate`.
|
||||
- **Implemented, validation pending:** the common body-backed URL-form parser decodes in place and remains bounded to 512 bytes and 10 unique fields. The browser clears entered and generated secrets when Settings closes or an operation fails; stale user and Wi-Fi editors reload current state without replaying the mutation.
|
||||
- **Implemented, validation pending:** contextual admin quick settings on the existing status cards. Hover, keyboard focus, or click on **Serial** opens a popover with current framing/lifecycle state, safe common edits, apply/save semantics, and a link to full Serial settings. The **Wi-Fi** card similarly exposes connection/profile controls and a link to full Network settings without revealing saved secrets by default. Touch and keyboard users must receive the same functionality as pointer hover, with Escape/outside-click dismissal and no action triggered merely by opening a popover.
|
||||
- **Implemented, validation pending:** hover, focus, or click on **Broker clients** opens a live, secret-free client list with IDs, transport, writer/observer state, and bounded queue/drop information. Activating **Active writer** opens an admin-only transfer dialog listing current eligible clients; transfer is an explicit confirmed generation-safe administrative assignment, never a side effect of opening or hovering. Stale/disconnected targets fail visibly without changing the current lease. Normal users may retain ordinary aggregate status but receive neither client-management details nor mutation controls.
|
||||
- **Implemented, validation pending:** HTTPS lifecycle operations are fully serialized and generation-tagged. TLS certificate/material replacement requires a post-commit TLS refresh; explicit newer start/stop intent wins races. Admin-transport HTTPD API work is disabled and tracked during teardown, failed HTTPD stop retains ownership for retry, and incomplete post-stop admin finalization remains pending and is retried before a later start.
|
||||
- **Implemented, validation pending:** preserve strict CSP, no-referrer/frame-denial policy, no-store responses for login/session/admin material, secret-safe JSON encoding, one-time generated-password display, and bounded request/response bodies. Keep UART0 recovery, native USB UART1 access, and existing SSH behavior available if web sessions or administration cannot initialize.
|
||||
- **Implemented, validation pending:** secret-free counters/snapshots cover session capacity, expiry, login failure/backoff, logout, invalidation, CSRF/origin rejection, admin-console admission/backpressure, typed-API failure, and writer-transfer races.
|
||||
- **Not provided as guided forms:** broader service/session administration, network diagnostics, security and danger-zone operations, and unusual hardware/debug commands remain available through the canonical Admin shell under its existing remote policy.
|
||||
|
||||
Completion requires login/logout and account switching without browser credential-cache dependence; stale-session, CSRF, origin, capacity, expiry, and revocation tests; hidden and server-rejected normal-user administration; shared admin-console serialization and backpressure; terminal switching that demonstrably preserves the browser broker client and writer lease; accessible Serial/Wi-Fi/client/writer popovers; generation-safe explicit writer transfer; typed settings and secret-handling tests; concurrent USB/WebSocket/user-SSH/admin-SSH/browser-admin operation; and continued UART0 recovery.
|
||||
Completion requires login/logout and account switching without browser credential-cache dependence; stale-session, CSRF, origin, capacity, expiry, and revocation tests; hidden and server-rejected normal-user administration; shared admin-console serialization and backpressure; terminal switching that demonstrably preserves the browser broker client and writer lease; accessible Serial/Wi-Fi/client/writer popovers; generation-safe explicit writer transfer; guided user/key, Wi-Fi secret/CAS, display validation, bounded-parser, and HTTPS teardown/finalizer tests; concurrent USB/WebSocket/user-SSH/admin-SSH/browser-admin operation; and continued UART0 recovery. None of the current Phase 8D target-hardware checks has passed yet.
|
||||
|
||||
### Phase 9 — Security and production hardening
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# User administration and authentication tests
|
||||
|
||||
This document retains phase-specific regression procedures. The Phase 8A and 8B sections describe the behavior of those historical implementation baselines; they are not the current end-to-end acceptance behavior. In current Phase 8C firmware, HTTPS and SSH authenticate through the user database, role-`user` SSH sessions receive the broker-backed serial stream, and role-`admin` SSH sessions receive the administration shell. Use the Phase 8C section for current routing and shared-console validation. Never include generated or entered passwords in test logs.
|
||||
This document retains phase-specific regression procedures. The Phase 8A and 8B sections describe historical baselines rather than the current end-to-end behavior. Phase 8C's shared UART0/admin-SSH shell is validated. The current Phase 8D worktree adds explicit browser sessions, the canonical browser admin shell, typed serial/user/Wi-Fi/display workflows, and broker controls; use the Phase 8D section for pending browser validation. Never include generated or entered passwords, Wi-Fi secrets, private keys, tickets, cookies, CSRF values, or verifier material in test logs.
|
||||
|
||||
## Historical Phase 8A baseline — role-based database and UART0 administration
|
||||
|
||||
@@ -166,9 +166,9 @@ While an administrative command is queued or running, use UART0 to change that a
|
||||
|
||||
Finally, issue commands concurrently from UART0 and admin SSH, including `user list`, long `help` output, and one UART0 interactive password or key prompt while an SSH command waits. Confirm the single dispatcher serializes all `esp_console_run()` calls, UART0 retains its line editing/history/completion, prompt input is consumed only from UART0, outputs are not mixed between transports, and there is no stack overflow, corrupted argument parsing, database damage, or broker disruption.
|
||||
|
||||
## Planned Phase 8D integrated web administration
|
||||
## Phase 8D integrated web administration
|
||||
|
||||
These are acceptance requirements for the planned implementation, not tests that have passed yet.
|
||||
**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
|
||||
|
||||
@@ -178,7 +178,7 @@ Authenticate as both roles through the same-origin login page, explicitly log ou
|
||||
|
||||
As an administrator, connect the browser serial terminal, acquire the writer lease, send and observe serial data, then switch repeatedly between **Serial terminal** and **Admin shell**. The visible terminal contents and input route must change, but `broker clients`, the displayed browser client ID, and active writer ID must remain unchanged. Request control/Release control and writer/observer state must stay visible in both modes. While Admin shell is selected, have a normal user request the writer lease and confirm the retained browser lease prevents unintended takeover. Only explicit Release control, confirmed writer transfer, Disconnect, logout, revocation, expiry, or connection failure may release it.
|
||||
|
||||
Verify the browser admin shell executes the canonical registry through the single dispatcher, preserves bounded history/completion/prompts and backpressure, and does not itself become a second broker client. Physical-only bootstrap/recovery commands remain rejected. Closing only the admin-console route must leave the browser serial client and its lease intact.
|
||||
Verify the browser admin shell executes the canonical registry through the single dispatcher, preserves bounded history/completion/prompts and backpressure, and does not itself become a second broker client. Paste a line longer than 256 bytes followed by a dangerous valid-command suffix before the newline; the whole overlong line must be discarded through CR/LF and the suffix must not execute. Physical-only bootstrap/recovery commands remain rejected. Closing only the admin-console route must leave the browser serial client and its lease intact.
|
||||
|
||||
### 3. Quick settings and client popovers
|
||||
|
||||
@@ -186,10 +186,39 @@ For an administrator, open the **Serial** and **Wi-Fi** card popovers by hover,
|
||||
|
||||
Open **Broker clients** and confirm its secret-free list matches authoritative broker snapshots: client ID, transport/name, writer/observer role, and bounded queue/drop state. Open **Active writer**, choose another currently connected eligible client, confirm the transfer explicitly, and verify exactly one writer remains. Disconnect or recycle the target before confirmation and confirm its stale generation/ID is rejected without releasing or reassigning the current writer. Hover/focus alone must never transfer ownership.
|
||||
|
||||
### 4. Typed settings and destructive operations
|
||||
### 4. Guided users, roles, passwords, keys, and revocation
|
||||
|
||||
Exercise user/password/role/key management, serial settings and persistence, Wi-Fi profiles/AP policy/secrets, service/session controls, display settings, and network diagnostics through typed bounded APIs. Compare resulting subsystem state with the equivalent canonical CLI behavior without routing API requests through command strings. Generated passwords appear once in no-store responses; destructive or self-terminating HTTPS/SSH/reboot/security actions require explicit confirmation and explain the expected connection loss.
|
||||
1. Through Settings, create disposable `user` and `admin` accounts with both entered and generated passwords. Confirm the generated value is displayed once, is absent from subsequent account reads, and is cleared when Settings closes or an operation fails. Exercise list/select, role change, entered password replacement, account deletion, and recreation.
|
||||
2. Add disposable Ed25519 and ECDSA P-256 public keys. Confirm only type, slot, and fingerprint are returned afterward. Re-adding a key to the same account must return a conflict without changing its generation or key list; adding that same key to a second account must succeed independently. Remove each key and verify the matching private key no longer authenticates for that account.
|
||||
3. Open the same account editor in two administrator sessions. Commit a mutation in the first, then submit the stale form from the second. It must receive a conflict, reload the current user list, clear entered password/key material, and not replay the request. Delete and recreate the username before submitting another stale form; the stable user-ID check must prevent it from targeting the replacement account.
|
||||
4. Keep affected browser and SSH sessions plus unrelated-account sessions active. After each committed password, role, key, or delete mutation, confirm best-effort web/SSH revocation is attempted, affected principals lose access, and unrelated sessions continue. Where revocation-hook failure can be injected, confirm the database commit remains authoritative and stale sessions close at their next currentness check rather than rolling back the mutation.
|
||||
5. With at least two administrators, change the signed-in administrator's entered password or role and confirm self-revocation prevents further admin requests. Generated replacement of the current remote administrator must be rejected. Then reduce the database to one administrator and verify attempts to demote or delete that final administrator are rejected without a generation change or session revocation.
|
||||
|
||||
### 5. Concurrency and failure isolation
|
||||
### 5. Guided Wi-Fi configuration and secret handling
|
||||
|
||||
Run USB, browser serial, browser admin shell, user SSH, admin SSH, UART0, and active UART1 traffic concurrently. Alternate explicit writer transfers while issuing administrative commands and opening/closing popovers. Verify one writer, isolated observers, bounded memory/queues, principal revocation, no mixed admin output, no hidden lease loss during terminal switching, and continued UART0/native-USB recovery if web-session or admin-console initialization fails.
|
||||
1. Read `/api/admin/wifi-config` and inspect browser state, HTTP responses, URLs, routine logs, and status endpoints. Saved station/AP secrets must never appear; only `secret_set` flags may indicate their presence.
|
||||
2. Exercise station SSID/priority/security apply, enable, disable, delete, and secret replacement for each slot. Exercise AP policy, SSID, channel, and secret replacement. Confirm disabled-profile-only edits remain staged without disrupting the active radio, while effective-policy changes follow the existing asynchronous restart behavior.
|
||||
3. Save a known working generation, reboot, and confirm it persists. With two editors at the same generation, let one commit and then have the stale editor submit a profile/AP edit or Save. The stale request must return a conflict without changing RAM or NVS. The browser must clear both secret fields, reload current state, and require explicit re-entry rather than replaying the secret-bearing request. Where generation exhaustion can be injected, confirm edit and Save both fail closed.
|
||||
4. Close Settings and force server, authorization, validation, and conflict failures after entering station/AP secrets. Confirm entered values are cleared and do not reappear when Settings is reopened.
|
||||
|
||||
### 6. Guided display aging
|
||||
|
||||
Exercise typed Apply, Save, Load saved, Defaults, and confirmed Reset. Verify valid zero-disabled transitions and valid increasing dim/off delays, then reject values above 86400 and any case where both transitions are enabled but off is not greater than dim. Invalid requests must leave the active configuration unchanged. Confirm Apply is RAM-only, Save survives reboot, Load restores the saved value, Defaults applies 300/600 seconds without persistence, and Reset applies and persists those defaults. Concurrently submit browser operations and display-writer commands from UART0 or a remote Admin shell; confirm `admin_command_gate` serializes each complete operation, with no mixed RAM/NVS result or lost update. Repeat with the local-UI configuration service unavailable and confirm a bounded failure without affecting UART0, USB, serial, Wi-Fi, or HTTPS.
|
||||
|
||||
### 7. Bounded typed requests and HTTPS lifecycle failures
|
||||
|
||||
For `/api/admin/serial`, `/api/admin/users`, `/api/admin/wifi-config`, `/api/admin/display`, and the writer endpoint, send malformed URL encoding, unknown/missing fields, duplicate fields, 11 fields, 513-byte bodies, stale sessions, wrong CSRF, wrong Origin, and normal-user requests. Confirm the 512-byte/10-unique-field body parser rejects them without side effects and that secret-bearing values never enter the request URL.
|
||||
|
||||
Run `web stop`, `web certificate rotate --force`, and `web reset --force` from the browser Admin shell. Administrative output must use bounded best-effort draining; certificate/material replacement must still perform the mandatory TLS refresh if the invoking frontend disappears or its drain times out. Verify the new certificate is served after reconnect and that a newer explicit start/stop intent wins a lifecycle-generation race.
|
||||
|
||||
Exercise these injected teardown paths separately:
|
||||
|
||||
1. Force admin-transport detach timeout followed by successful HTTPD stop. Confirm post-stop finalization runs only after HTTPD destruction; if finalization times out, the next start retries it and does not attach a new server until it succeeds.
|
||||
2. Force HTTPD stop failure. Confirm the handle remains owned, no second HTTPS server starts, transport-owned HTTPD calls stay disabled/tracked, and a later Stop can retry safely.
|
||||
3. Hold an admin HTTPD API operation in flight during detach. Confirm teardown does not free or finalize its server state early and no retired work calls HTTPD after successful destruction.
|
||||
|
||||
Service/session controls beyond the guided serial/Wi-Fi actions, network diagnostics, security/danger operations, and unusual hardware/debug commands have no guided forms; validate them through the canonical Admin shell under existing remote-command policy.
|
||||
|
||||
### 8. Concurrency and failure isolation
|
||||
|
||||
Run USB, browser serial, browser admin shell, user SSH, admin SSH, UART0, and active UART1 traffic concurrently. Alternate explicit writer transfers while issuing administrative commands and opening/closing Settings and popovers. Verify one writer, isolated observers, bounded memory/queues, serialized user mutations, principal revocation, no mixed admin output, no hidden lease loss during terminal switching, and continued UART0/native-USB recovery if web-session or admin-console initialization fails.
|
||||
|
||||
@@ -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"
|
||||
|
||||
+161
-60
@@ -1,5 +1,5 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Serialized, bounded administrative SSH command worker. */
|
||||
/* Serialized, bounded remote administrative command worker. */
|
||||
|
||||
#include "admin_ssh_console.h"
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "user_database.h"
|
||||
#include "web_server.h"
|
||||
|
||||
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
|
||||
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
|
||||
#define ADMIN_SSH_CONSOLE_RESPONSE_RESERVE 512U
|
||||
#define ADMIN_SSH_CONSOLE_REQUEST_QUEUE_LENGTH 4U
|
||||
@@ -29,7 +29,8 @@
|
||||
#define ADMIN_UART_CONSOLE_TASK_PRIORITY 3U
|
||||
#define ADMIN_SSH_CONSOLE_MAX_ARGUMENTS 10U
|
||||
#define ADMIN_SSH_CONSOLE_HISTORY_DEPTH 4U
|
||||
#define ADMIN_SSH_CONTROL_QUEUE_LENGTH 2U
|
||||
/* One deferred request per fixed remote console slot can be pending. */
|
||||
#define ADMIN_SSH_CONTROL_QUEUE_LENGTH ADMIN_SSH_CONSOLE_SLOT_COUNT
|
||||
#define ADMIN_SSH_CONTROL_TASK_STACK_SIZE 4096U
|
||||
#define ADMIN_SSH_CONTROL_TASK_PRIORITY 3U
|
||||
|
||||
@@ -48,6 +49,7 @@ typedef struct {
|
||||
bool deferred_action_pending;
|
||||
admin_ssh_console_token_t token;
|
||||
user_principal_t principal;
|
||||
admin_ssh_console_frontend_ops_t frontend_ops;
|
||||
size_t input_length;
|
||||
size_t input_cursor;
|
||||
uint8_t input[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
@@ -61,6 +63,7 @@ typedef struct {
|
||||
uint8_t escape_parameters[4];
|
||||
size_t escape_parameter_length;
|
||||
bool discard_next_lf;
|
||||
bool discard_overlong_line;
|
||||
admin_prompt_state_t prompt_state;
|
||||
bool prompt_hidden;
|
||||
size_t prompt_capacity;
|
||||
@@ -72,7 +75,7 @@ typedef struct {
|
||||
} admin_session_t;
|
||||
|
||||
typedef enum {
|
||||
ADMIN_REQUEST_SSH = 0,
|
||||
ADMIN_REQUEST_REMOTE = 0,
|
||||
ADMIN_REQUEST_UART0,
|
||||
} admin_request_origin_t;
|
||||
|
||||
@@ -80,6 +83,7 @@ typedef struct {
|
||||
admin_request_origin_t origin;
|
||||
admin_ssh_console_token_t token;
|
||||
user_principal_t principal;
|
||||
admin_ssh_console_frontend_ops_t frontend_ops;
|
||||
TaskHandle_t completion_task;
|
||||
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||
} admin_request_t;
|
||||
@@ -87,12 +91,12 @@ typedef struct {
|
||||
typedef struct {
|
||||
admin_ssh_deferred_action_type_t action;
|
||||
admin_ssh_console_token_t token;
|
||||
admin_ssh_console_frontend_ops_t frontend_ops;
|
||||
uint32_t argument;
|
||||
} admin_control_request_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_MAX_SESSIONS];
|
||||
/* admin_ssh_console_feed_input() is called only by the sole SSH owner task. */
|
||||
static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_SLOT_COUNT];
|
||||
static char s_completion_output[CONSOLE_COMPLETION_OUTPUT_CAPACITY];
|
||||
|
||||
static StaticQueue_t s_request_queue_storage;
|
||||
@@ -105,6 +109,8 @@ static uint8_t s_control_queue_bytes[ADMIN_SSH_CONTROL_QUEUE_LENGTH *
|
||||
static QueueHandle_t s_control_queue;
|
||||
static StaticSemaphore_t s_prompt_done_storage;
|
||||
static SemaphoreHandle_t s_prompt_done;
|
||||
static StaticSemaphore_t s_feed_mutex_storage;
|
||||
static SemaphoreHandle_t s_feed_mutex;
|
||||
static TaskHandle_t s_task;
|
||||
static TaskHandle_t s_uart_task;
|
||||
static TaskHandle_t s_control_task;
|
||||
@@ -115,13 +121,27 @@ static bool s_dispatch_remote;
|
||||
static bool s_dispatch_output_previous_cr;
|
||||
static admin_ssh_console_token_t s_dispatch_token;
|
||||
static user_principal_t s_dispatch_principal;
|
||||
static ssh_transport_snapshot_t s_control_ssh_snapshot;
|
||||
|
||||
bool admin_ssh_console_is_ready(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_initialized && s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ready;
|
||||
}
|
||||
|
||||
bool admin_ssh_console_dispatch_is_remote(void)
|
||||
{
|
||||
return xTaskGetCurrentTaskHandle() == s_task && s_dispatch_remote;
|
||||
}
|
||||
|
||||
admin_ssh_console_frontend_t admin_ssh_console_dispatch_frontend(void)
|
||||
{
|
||||
return admin_ssh_console_dispatch_is_remote()
|
||||
? s_dispatch_token.frontend
|
||||
: ADMIN_SSH_CONSOLE_FRONTEND_NONE;
|
||||
}
|
||||
|
||||
const user_principal_t *admin_ssh_console_dispatch_principal(void)
|
||||
{
|
||||
return admin_ssh_console_dispatch_is_remote() ? &s_dispatch_principal : NULL;
|
||||
@@ -129,14 +149,29 @@ const user_principal_t *admin_ssh_console_dispatch_principal(void)
|
||||
|
||||
static bool token_valid(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
return token != NULL && token->slot_index < ADMIN_SSH_CONSOLE_MAX_SESSIONS &&
|
||||
token->session_id != 0U && token->slot_generation != 0U;
|
||||
if (token == NULL || token->session_id == 0U || token->slot_generation == 0U) {
|
||||
return false;
|
||||
}
|
||||
if (token->frontend == ADMIN_SSH_CONSOLE_FRONTEND_SSH) {
|
||||
return token->slot_index < ADMIN_SSH_CONSOLE_SSH_SLOT_COUNT;
|
||||
}
|
||||
return token->frontend == ADMIN_SSH_CONSOLE_FRONTEND_WEB &&
|
||||
token->slot_index == ADMIN_SSH_CONSOLE_WEB_SLOT_INDEX;
|
||||
}
|
||||
|
||||
static bool frontend_ops_valid(const admin_ssh_console_frontend_ops_t *frontend_ops)
|
||||
{
|
||||
return frontend_ops != NULL && frontend_ops->binding_is_current != NULL &&
|
||||
frontend_ops->transport_output_is_drained != NULL &&
|
||||
frontend_ops->request_disconnect != NULL;
|
||||
}
|
||||
|
||||
static bool token_identity_matches(const admin_session_t *session,
|
||||
const admin_ssh_console_token_t *token)
|
||||
{
|
||||
return token_valid(token) && session->token.session_id == token->session_id &&
|
||||
return token_valid(token) && session->token.frontend == token->frontend &&
|
||||
session->token.slot_index == token->slot_index &&
|
||||
session->token.session_id == token->session_id &&
|
||||
session->token.slot_generation == token->slot_generation;
|
||||
}
|
||||
|
||||
@@ -357,12 +392,14 @@ esp_err_t admin_ssh_console_dispatch_defer(
|
||||
if (!admin_ssh_console_dispatch_is_remote() || action == ADMIN_SSH_DEFER_NONE) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
admin_ssh_console_frontend_ops_t frontend_ops = {0};
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[s_dispatch_token.slot_index];
|
||||
bool valid = token_matches(session, &s_dispatch_token) &&
|
||||
!session->deferred_action_pending;
|
||||
if (valid) {
|
||||
session->deferred_action_pending = true;
|
||||
frontend_ops = session->frontend_ops;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!valid) {
|
||||
@@ -371,6 +408,7 @@ esp_err_t admin_ssh_console_dispatch_defer(
|
||||
admin_control_request_t request = {
|
||||
.action = action,
|
||||
.token = s_dispatch_token,
|
||||
.frontend_ops = frontend_ops,
|
||||
.argument = argument,
|
||||
};
|
||||
if (xQueueSend(s_control_queue, &request, 0U) == pdTRUE) {
|
||||
@@ -385,7 +423,7 @@ esp_err_t admin_ssh_console_dispatch_defer(
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
static int ssh_output_write(void *cookie, const char *buffer, int length)
|
||||
static int remote_output_write(void *cookie, const char *buffer, int length)
|
||||
{
|
||||
const admin_ssh_console_token_t *token = cookie;
|
||||
if (!token_valid(token) || buffer == NULL || length <= 0) {
|
||||
@@ -468,17 +506,18 @@ static int command_exit(int argc, char **argv)
|
||||
return 1;
|
||||
}
|
||||
if (!admin_ssh_console_dispatch_is_remote()) {
|
||||
printf("The exit command is available only from an administrative SSH session.\n");
|
||||
printf("The exit command is available only from a remote administrative session.\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
esp_err_t error = admin_ssh_console_dispatch_defer(
|
||||
ADMIN_SSH_DEFER_DISCONNECT, s_dispatch_token.session_id);
|
||||
ADMIN_SSH_DEFER_FRONTEND_DISCONNECT, s_dispatch_token.session_id);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not schedule SSH session close: %s\n", esp_err_to_name(error));
|
||||
printf("Could not schedule administrative session close: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("SSH session close scheduled after output drains.\n");
|
||||
printf("Administrative session close scheduled after output drains.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -487,8 +526,8 @@ static void dispatch_registered_command(admin_request_t *request)
|
||||
FILE *saved_stdout = stdout;
|
||||
FILE *saved_stderr = stderr;
|
||||
FILE *remote_stream = NULL;
|
||||
if (request->origin == ADMIN_REQUEST_SSH) {
|
||||
remote_stream = funopen(&request->token, NULL, ssh_output_write, NULL, NULL);
|
||||
if (request->origin == ADMIN_REQUEST_REMOTE) {
|
||||
remote_stream = funopen(&request->token, NULL, remote_output_write, NULL, NULL);
|
||||
if (remote_stream == NULL) {
|
||||
(void)worker_write(&request->token, "Could not create command output stream.\r\n");
|
||||
return;
|
||||
@@ -538,8 +577,16 @@ static void worker_task(void *context)
|
||||
continue;
|
||||
}
|
||||
|
||||
bool current = false;
|
||||
esp_err_t auth_error = user_database_principal_is_current(&request.principal, ¤t);
|
||||
bool principal_current = false;
|
||||
esp_err_t auth_error = user_database_principal_is_current(
|
||||
&request.principal, &principal_current);
|
||||
bool command_allowed = request.principal.role == USER_ROLE_ADMIN &&
|
||||
remote_command_allowed(&request);
|
||||
bool binding_current = frontend_ops_valid(&request.frontend_ops) &&
|
||||
request.frontend_ops.binding_is_current(&request.token);
|
||||
bool authorized = auth_error == ESP_OK && principal_current &&
|
||||
command_allowed && binding_current;
|
||||
|
||||
bool active;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admin_session_t *session = &s_sessions[request.token.slot_index];
|
||||
@@ -549,16 +596,18 @@ static void worker_task(void *context)
|
||||
session->executing = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
bool authorized = active && auth_error == ESP_OK && current &&
|
||||
request.principal.role == USER_ROLE_ADMIN &&
|
||||
remote_command_allowed(&request);
|
||||
if (authorized) {
|
||||
|
||||
if (authorized && active) {
|
||||
dispatch_registered_command(&request);
|
||||
} else if (active) {
|
||||
(void)worker_write(&request.token,
|
||||
auth_error == ESP_OK && current
|
||||
? "Command is restricted to physical UART0.\r\n"
|
||||
: "Administrative authorization is no longer current; closing session.\r\n");
|
||||
const char *message = "Command is restricted to physical UART0.\r\n";
|
||||
if (auth_error != ESP_OK || !principal_current ||
|
||||
request.principal.role != USER_ROLE_ADMIN) {
|
||||
message = "Administrative authorization is no longer current; closing session.\r\n";
|
||||
} else if (!binding_current) {
|
||||
message = "Administrative session binding is no longer current; closing session.\r\n";
|
||||
}
|
||||
(void)worker_write(&request.token, message);
|
||||
}
|
||||
bool prompt = false;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
@@ -566,7 +615,7 @@ static void worker_task(void *context)
|
||||
if (token_matches(session, &request.token)) {
|
||||
session->executing = false;
|
||||
session->command_pending = false;
|
||||
prompt = auth_error == ESP_OK && current &&
|
||||
prompt = auth_error == ESP_OK && principal_current && binding_current &&
|
||||
request.principal.role == USER_ROLE_ADMIN &&
|
||||
!session->deferred_action_pending;
|
||||
} else if (!session->active && session->executing &&
|
||||
@@ -588,13 +637,14 @@ static void finish_deferred_request(const admin_control_request_t *request,
|
||||
char message[160];
|
||||
if (cancelled) {
|
||||
snprintf(message, sizeof(message),
|
||||
"Deferred action cancelled before SSH output drained.\r\nadmin@serial-tool> ");
|
||||
"Deferred action cancelled before administrative output drained.\r\n"
|
||||
"admin@serial-tool> ");
|
||||
} else if (result == ESP_OK) {
|
||||
snprintf(message, sizeof(message),
|
||||
"Deferred SSH action completed.\r\nadmin@serial-tool> ");
|
||||
"Deferred administrative action completed.\r\nadmin@serial-tool> ");
|
||||
} else {
|
||||
snprintf(message, sizeof(message),
|
||||
"Deferred SSH action failed: %s\r\nadmin@serial-tool> ",
|
||||
"Deferred administrative action failed: %s\r\nadmin@serial-tool> ",
|
||||
esp_err_to_name(result));
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
@@ -615,6 +665,9 @@ static void control_task(void *context)
|
||||
continue;
|
||||
}
|
||||
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(10000U);
|
||||
bool must_execute =
|
||||
request.action == ADMIN_SSH_DEFER_WEB_TLS_REFRESH_STOPPED ||
|
||||
request.action == ADMIN_SSH_DEFER_WEB_TLS_REFRESH_RUNNING;
|
||||
bool drained = false;
|
||||
while ((int32_t)(xTaskGetTickCount() - deadline) < 0) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
@@ -624,27 +677,18 @@ static void control_task(void *context)
|
||||
session->output_length == 0U;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!current) {
|
||||
drained = must_execute;
|
||||
break;
|
||||
}
|
||||
bool transport_drained = false;
|
||||
if (console_drained &&
|
||||
ssh_transport_get_snapshot(&s_control_ssh_snapshot) == ESP_OK) {
|
||||
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
const ssh_transport_session_snapshot_t *slot =
|
||||
&s_control_ssh_snapshot.sessions[index];
|
||||
if (slot->active && slot->session_id == request.token.session_id) {
|
||||
transport_drained = !slot->tx_pending;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
bool transport_drained = console_drained &&
|
||||
request.frontend_ops.transport_output_is_drained(&request.token);
|
||||
if (console_drained && transport_drained) {
|
||||
drained = true;
|
||||
break;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(10U));
|
||||
}
|
||||
if (!drained) {
|
||||
if (!drained && !must_execute) {
|
||||
finish_deferred_request(&request, ESP_ERR_TIMEOUT, true);
|
||||
secure_wipe(&request, sizeof(request));
|
||||
continue;
|
||||
@@ -658,7 +702,11 @@ static void control_task(void *context)
|
||||
case ADMIN_SSH_DEFER_STOP:
|
||||
result = ssh_transport_stop();
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_DISCONNECT:
|
||||
case ADMIN_SSH_DEFER_FRONTEND_DISCONNECT:
|
||||
result = request.frontend_ops.request_disconnect(
|
||||
&request.token, request.argument);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_SSH_DISCONNECT:
|
||||
result = ssh_transport_disconnect(request.argument);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_HOST_KEY_ROTATE:
|
||||
@@ -667,6 +715,15 @@ static void control_task(void *context)
|
||||
case ADMIN_SSH_DEFER_HOST_KEY_RESET:
|
||||
result = ssh_transport_replace_host_key(true);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_WEB_STOP:
|
||||
result = web_server_stop_if_generation(request.argument);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_WEB_TLS_REFRESH_STOPPED:
|
||||
result = web_server_refresh_tls(request.argument, false);
|
||||
break;
|
||||
case ADMIN_SSH_DEFER_WEB_TLS_REFRESH_RUNNING:
|
||||
result = web_server_refresh_tls(request.argument, true);
|
||||
break;
|
||||
default:
|
||||
result = ESP_ERR_NOT_SUPPORTED;
|
||||
break;
|
||||
@@ -725,7 +782,9 @@ esp_err_t admin_ssh_console_init(void)
|
||||
sizeof(admin_control_request_t),
|
||||
s_control_queue_bytes, &s_control_queue_storage);
|
||||
s_prompt_done = xSemaphoreCreateBinaryStatic(&s_prompt_done_storage);
|
||||
if (s_request_queue == NULL || s_control_queue == NULL || s_prompt_done == NULL) {
|
||||
s_feed_mutex = xSemaphoreCreateMutexStatic(&s_feed_mutex_storage);
|
||||
if (s_request_queue == NULL || s_control_queue == NULL || s_prompt_done == NULL ||
|
||||
s_feed_mutex == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
if (xTaskCreate(worker_task, "admin_ssh_console", ADMIN_SSH_CONSOLE_TASK_STACK_SIZE,
|
||||
@@ -750,7 +809,7 @@ esp_err_t admin_ssh_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
.command = "exit",
|
||||
.help = "Close the current administrative SSH session",
|
||||
.help = "Close the current remote administrative session",
|
||||
.hint = NULL,
|
||||
.func = &command_exit,
|
||||
.argtable = NULL,
|
||||
@@ -782,16 +841,16 @@ esp_err_t admin_ssh_console_start_uart_frontend(void)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
esp_err_t admin_ssh_console_open(
|
||||
const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_ssh_console_frontend_ops_t *frontend_ops)
|
||||
{
|
||||
if (!token_valid(token) || principal == NULL || principal->role != USER_ROLE_ADMIN) {
|
||||
if (!token_valid(token) || principal == NULL || principal->role != USER_ROLE_ADMIN ||
|
||||
!frontend_ops_valid(frontend_ops)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_initialized && s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) {
|
||||
if (!admin_ssh_console_is_ready()) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
bool current = false;
|
||||
@@ -809,8 +868,9 @@ esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
|
||||
session->history_position = -1;
|
||||
session->token = *token;
|
||||
session->principal = *principal;
|
||||
session->frontend_ops = *frontend_ops;
|
||||
static const char banner[] =
|
||||
"ESP32 Serial Swiss Army Knife administrative SSH shell\r\n";
|
||||
"ESP32 Serial Swiss Army Knife administrative shell\r\n";
|
||||
static const char prompt[] =
|
||||
"Run 'help' for supported remote administrative commands.\r\nadmin@serial-tool> ";
|
||||
(void)append_output_locked(session, (const uint8_t *)banner, sizeof(banner) - 1U);
|
||||
@@ -861,7 +921,7 @@ bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token)
|
||||
return accepts;
|
||||
}
|
||||
|
||||
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
static bool feed_input_serialized(const admin_ssh_console_token_t *token,
|
||||
const uint8_t *data, size_t length,
|
||||
size_t *consumed)
|
||||
{
|
||||
@@ -938,6 +998,23 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
continue;
|
||||
}
|
||||
session->discard_next_lf = false;
|
||||
if (session->discard_overlong_line) {
|
||||
if (value == '\r' || value == '\n' || value == 0x03U) {
|
||||
session->discard_overlong_line = false;
|
||||
session->discard_next_lf = value == '\r';
|
||||
(void)append_output_locked(
|
||||
session,
|
||||
(const uint8_t *)(value == 0x03U
|
||||
? "^C\r\nadmin@serial-tool> "
|
||||
: "admin@serial-tool> "),
|
||||
value == 0x03U
|
||||
? sizeof("^C\r\nadmin@serial-tool> ") - 1U
|
||||
: sizeof("admin@serial-tool> ") - 1U);
|
||||
}
|
||||
++*consumed;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
continue;
|
||||
}
|
||||
if (session->escape_state != 0U) {
|
||||
if (session->escape_state == 1U) {
|
||||
if (value == '[') {
|
||||
@@ -1046,9 +1123,10 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
history_commit_locked(session);
|
||||
memcpy(request.line, session->input, session->input_length);
|
||||
}
|
||||
request.origin = ADMIN_REQUEST_SSH;
|
||||
request.origin = ADMIN_REQUEST_REMOTE;
|
||||
request.token = *token;
|
||||
request.principal = session->principal;
|
||||
request.frontend_ops = session->frontend_ops;
|
||||
secure_wipe(session->input, sizeof(session->input));
|
||||
session->input_length = 0U;
|
||||
session->input_cursor = 0U;
|
||||
@@ -1080,11 +1158,17 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
}
|
||||
} else if (value >= 0x20U && value <= 0x7eU) {
|
||||
if (session->input_length >= ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY) {
|
||||
secure_wipe(session->input, sizeof(session->input));
|
||||
session->input_length = 0U;
|
||||
session->input_cursor = 0U;
|
||||
(void)append_output_locked(session, (const uint8_t *)
|
||||
"\r\nCommand too long; discarded.\r\nadmin@serial-tool> ",
|
||||
sizeof("\r\nCommand too long; discarded.\r\nadmin@serial-tool> ") - 1U);
|
||||
session->history_position = -1;
|
||||
session->escape_state = 0U;
|
||||
session->discard_overlong_line = true;
|
||||
(void)append_output_locked(
|
||||
session,
|
||||
(const uint8_t *)
|
||||
"\r\nCommand too long; discarding until end of line.\r\n",
|
||||
sizeof("\r\nCommand too long; discarding until end of line.\r\n") - 1U);
|
||||
} else {
|
||||
memmove(session->input + session->input_cursor + 1U,
|
||||
session->input + session->input_cursor,
|
||||
@@ -1117,6 +1201,23 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
return true;
|
||||
}
|
||||
|
||||
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
const uint8_t *data, size_t length,
|
||||
size_t *consumed)
|
||||
{
|
||||
if (consumed == NULL) {
|
||||
return false;
|
||||
}
|
||||
*consumed = 0U;
|
||||
if (!token_valid(token) || (data == NULL && length != 0U) || s_feed_mutex == NULL ||
|
||||
xSemaphoreTake(s_feed_mutex, 0U) != pdTRUE) {
|
||||
return false;
|
||||
}
|
||||
bool accepted = feed_input_serialized(token, data, length, consumed);
|
||||
xSemaphoreGive(s_feed_mutex);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
|
||||
uint8_t *data, size_t capacity,
|
||||
size_t *received)
|
||||
|
||||
+35
-6
@@ -1,5 +1,5 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded, transport-neutral administrative command worker for SSH sessions. */
|
||||
/* Bounded, transport-neutral administrative command worker for remote sessions. */
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -17,19 +17,42 @@ extern "C" {
|
||||
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
|
||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||
|
||||
/* SSH retains global slots 0 and 1; the web administration frontend owns slot 2. */
|
||||
#define ADMIN_SSH_CONSOLE_SSH_SLOT_COUNT 2U
|
||||
#define ADMIN_SSH_CONSOLE_WEB_SLOT_INDEX ADMIN_SSH_CONSOLE_SSH_SLOT_COUNT
|
||||
#define ADMIN_SSH_CONSOLE_SLOT_COUNT (ADMIN_SSH_CONSOLE_WEB_SLOT_INDEX + 1U)
|
||||
|
||||
typedef enum {
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_NONE = 0,
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_SSH,
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_WEB,
|
||||
} admin_ssh_console_frontend_t;
|
||||
|
||||
typedef struct {
|
||||
admin_ssh_console_frontend_t frontend;
|
||||
uint8_t slot_index;
|
||||
uint32_t session_id;
|
||||
uint32_t slot_generation;
|
||||
} admin_ssh_console_token_t;
|
||||
|
||||
typedef struct {
|
||||
bool (*binding_is_current)(const admin_ssh_console_token_t *token);
|
||||
bool (*transport_output_is_drained)(const admin_ssh_console_token_t *token);
|
||||
esp_err_t (*request_disconnect)(const admin_ssh_console_token_t *token,
|
||||
uint32_t argument);
|
||||
} admin_ssh_console_frontend_ops_t;
|
||||
|
||||
typedef enum {
|
||||
ADMIN_SSH_DEFER_NONE = 0,
|
||||
ADMIN_SSH_DEFER_REBOOT,
|
||||
ADMIN_SSH_DEFER_STOP,
|
||||
ADMIN_SSH_DEFER_DISCONNECT,
|
||||
ADMIN_SSH_DEFER_FRONTEND_DISCONNECT,
|
||||
ADMIN_SSH_DEFER_SSH_DISCONNECT,
|
||||
ADMIN_SSH_DEFER_HOST_KEY_ROTATE,
|
||||
ADMIN_SSH_DEFER_HOST_KEY_RESET,
|
||||
ADMIN_SSH_DEFER_WEB_STOP,
|
||||
ADMIN_SSH_DEFER_WEB_TLS_REFRESH_STOPPED,
|
||||
ADMIN_SSH_DEFER_WEB_TLS_REFRESH_RUNNING,
|
||||
} admin_ssh_deferred_action_type_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -47,9 +70,11 @@ esp_err_t admin_ssh_console_init(void);
|
||||
esp_err_t admin_ssh_console_register_commands(void);
|
||||
/* Called after all ESP-IDF commands are registered; starts the UART0 frontend. */
|
||||
esp_err_t admin_ssh_console_start_uart_frontend(void);
|
||||
bool admin_ssh_console_is_ready(void);
|
||||
|
||||
/* Valid only while a registered command callback runs on the dispatcher task. */
|
||||
bool admin_ssh_console_dispatch_is_remote(void);
|
||||
admin_ssh_console_frontend_t admin_ssh_console_dispatch_frontend(void);
|
||||
const user_principal_t *admin_ssh_console_dispatch_principal(void);
|
||||
esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
const char *prompt, uint8_t *output, size_t capacity,
|
||||
@@ -57,18 +82,22 @@ esp_err_t admin_ssh_console_dispatch_read_input(
|
||||
esp_err_t admin_ssh_console_dispatch_defer(
|
||||
admin_ssh_deferred_action_type_t action, uint32_t argument);
|
||||
|
||||
/* The token and principal are copied; no SSH or socket objects cross this boundary. */
|
||||
/*
|
||||
* The token, principal, and ops table are copied. Callback code and any state it
|
||||
* references must have static lifetime; the console lock is not held during callbacks.
|
||||
*/
|
||||
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal);
|
||||
const user_principal_t *principal,
|
||||
const admin_ssh_console_frontend_ops_t *frontend_ops);
|
||||
void admin_ssh_console_close(const admin_ssh_console_token_t *token);
|
||||
|
||||
/* Called only by the SSH owner task. Returns false when input must be backpressured. */
|
||||
/* Called only by a frontend owner task. Returns false when input must be retried. */
|
||||
bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token);
|
||||
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
const uint8_t *data, size_t length,
|
||||
size_t *consumed);
|
||||
|
||||
/* Called only by the SSH owner task; copies already-produced output without blocking. */
|
||||
/* Called only by a frontend owner task; copies produced output without blocking. */
|
||||
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
|
||||
uint8_t *data, size_t capacity,
|
||||
size_t *received);
|
||||
|
||||
+15
-1
@@ -8,6 +8,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_command_gate.h"
|
||||
#include "esp_console.h"
|
||||
#include "local_display.h"
|
||||
#include "local_status_ui.h"
|
||||
@@ -113,7 +114,7 @@ static int apply_parameter(const char *parameter, const char *text)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_display(int argc, char **argv)
|
||||
static int command_display_inner(int argc, char **argv)
|
||||
{
|
||||
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0)) {
|
||||
return show_status();
|
||||
@@ -189,6 +190,19 @@ static int command_display(int argc, char **argv)
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int command_display(int argc, char **argv)
|
||||
{
|
||||
esp_err_t error = admin_command_gate_take();
|
||||
if (error != ESP_OK) {
|
||||
printf("Display administration unavailable: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
int result = command_display_inner(argc, argv);
|
||||
admin_command_gate_give();
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t local_ui_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
|
||||
+35
-1
@@ -6,6 +6,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_command_gate.h"
|
||||
#include "esp_console.h"
|
||||
#include "esp_err.h"
|
||||
#include "rs232_port_owner.h"
|
||||
@@ -191,7 +192,7 @@ static int set_parameter(const char *parameter, const char *value)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_serial(int argc, char **argv)
|
||||
static int command_serial_impl(int argc, char **argv)
|
||||
{
|
||||
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0)) {
|
||||
return show_status();
|
||||
@@ -290,6 +291,39 @@ static int command_serial(int argc, char **argv)
|
||||
return 1;
|
||||
}
|
||||
|
||||
static bool command_serial_requires_gate(int argc, char **argv)
|
||||
{
|
||||
if (argc == 4 && strcmp(argv[1], "set") == 0) {
|
||||
return true;
|
||||
}
|
||||
if (argc != 2) {
|
||||
return false;
|
||||
}
|
||||
return strcmp(argv[1], "start") == 0 ||
|
||||
strcmp(argv[1], "stop") == 0 ||
|
||||
strcmp(argv[1], "save") == 0 ||
|
||||
strcmp(argv[1], "load") == 0 ||
|
||||
strcmp(argv[1], "defaults") == 0 ||
|
||||
strcmp(argv[1], "reset") == 0;
|
||||
}
|
||||
|
||||
static int command_serial(int argc, char **argv)
|
||||
{
|
||||
if (!command_serial_requires_gate(argc, argv)) {
|
||||
return command_serial_impl(argc, argv);
|
||||
}
|
||||
|
||||
esp_err_t error = admin_command_gate_take();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not serialize serial administration: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
int result = command_serial_impl(argc, argv);
|
||||
admin_command_gate_give();
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t serial_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
|
||||
@@ -518,6 +518,60 @@ esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_compare_exchange_writer(
|
||||
session_broker_client_id_t expected_writer_id,
|
||||
session_broker_client_id_t target_client_id)
|
||||
{
|
||||
if (!s_initialized) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (target_client_id == SESSION_BROKER_NO_CLIENT) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
if (s_writer_id != expected_writer_id) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
session_broker_slot_t *new_writer = find_slot_locked(target_client_id);
|
||||
if (new_writer == NULL) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
if (expected_writer_id == target_client_id) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
session_broker_slot_t *old_writer =
|
||||
find_slot_locked(expected_writer_id);
|
||||
if (expected_writer_id != SESSION_BROKER_NO_CLIENT && old_writer == NULL) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
s_writer_id = target_client_id;
|
||||
if (old_writer != NULL) {
|
||||
++old_writer->counters.writer_revocations;
|
||||
++old_writer->counters.writer_changes;
|
||||
++s_counters.writer_revocations;
|
||||
}
|
||||
++new_writer->counters.writer_grants;
|
||||
++new_writer->counters.writer_changes;
|
||||
++s_counters.writer_grants;
|
||||
++s_counters.writer_changes;
|
||||
|
||||
if (old_writer != NULL) {
|
||||
broadcast_event_locked(SESSION_BROKER_EVENT_WRITER_REVOKED,
|
||||
expected_writer_id, s_writer_id);
|
||||
}
|
||||
broadcast_event_locked(SESSION_BROKER_EVENT_WRITER_GRANTED,
|
||||
target_client_id, s_writer_id);
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_force_release_writer(
|
||||
session_broker_client_id_t expected_writer_id)
|
||||
{
|
||||
|
||||
@@ -130,6 +130,14 @@ esp_err_t session_broker_disconnect(session_broker_client_id_t client_id);
|
||||
esp_err_t session_broker_request_writer(session_broker_client_id_t client_id);
|
||||
esp_err_t session_broker_release_writer(session_broker_client_id_t client_id);
|
||||
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id);
|
||||
/*
|
||||
* Atomically replace exactly the expected current writer with a connected target.
|
||||
* expected_writer_id may be zero; target_client_id must identify a live client.
|
||||
* A changed current writer returns ESP_ERR_INVALID_STATE without altering the lease.
|
||||
*/
|
||||
esp_err_t session_broker_compare_exchange_writer(
|
||||
session_broker_client_id_t expected_writer_id,
|
||||
session_broker_client_id_t target_client_id);
|
||||
/* Revoke only if the expected client still owns the writer lease. */
|
||||
esp_err_t session_broker_force_release_writer(session_broker_client_id_t expected_writer_id);
|
||||
session_broker_client_id_t session_broker_get_writer_id(void);
|
||||
|
||||
+1
-1
@@ -324,7 +324,7 @@ static int command_ssh(int argc, char **argv)
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = admin_ssh_console_dispatch_defer(
|
||||
ADMIN_SSH_DEFER_DISCONNECT, session_id);
|
||||
ADMIN_SSH_DEFER_SSH_DISCONNECT, session_id);
|
||||
}
|
||||
} else {
|
||||
error = ssh_transport_disconnect(session_id);
|
||||
|
||||
+70
-1
@@ -49,6 +49,9 @@
|
||||
#define SSH_TRANSPORT_GENERATION_MAX 0x3fffffffU
|
||||
#define SSH_TRANSPORT_WOLFSSH_READ_BUDGET 2048U
|
||||
|
||||
_Static_assert(SSH_TRANSPORT_MAX_SESSIONS == ADMIN_SSH_CONSOLE_SSH_SLOT_COUNT,
|
||||
"SSH admin slots must retain global indexes 0 and 1");
|
||||
|
||||
static const char *TAG = "ssh_transport";
|
||||
|
||||
typedef struct {
|
||||
@@ -132,12 +135,77 @@ static admin_ssh_console_token_t admin_console_token(const ssh_slot_t *slot,
|
||||
size_t slot_index)
|
||||
{
|
||||
return (admin_ssh_console_token_t){
|
||||
.frontend = ADMIN_SSH_CONSOLE_FRONTEND_SSH,
|
||||
.slot_index = (uint8_t)slot_index,
|
||||
.session_id = slot->session_id,
|
||||
.slot_generation = slot->generation,
|
||||
};
|
||||
}
|
||||
|
||||
static bool admin_console_token_is_ssh(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
return token != NULL && token->frontend == ADMIN_SSH_CONSOLE_FRONTEND_SSH &&
|
||||
token->slot_index < SSH_TRANSPORT_MAX_SESSIONS &&
|
||||
token->session_id != 0U && token->slot_generation != 0U;
|
||||
}
|
||||
|
||||
static bool admin_console_snapshot_matches_locked(
|
||||
const admin_ssh_console_token_t *token)
|
||||
{
|
||||
const ssh_transport_session_snapshot_t *snapshot =
|
||||
&s_session_snapshots[token->slot_index];
|
||||
return s_initialized && snapshot->active &&
|
||||
snapshot->session_id == token->session_id &&
|
||||
snapshot->generation == token->slot_generation &&
|
||||
snapshot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
|
||||
}
|
||||
|
||||
static bool admin_console_binding_is_current(
|
||||
const admin_ssh_console_token_t *token)
|
||||
{
|
||||
if (!admin_console_token_is_ssh(token)) {
|
||||
return false;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
const ssh_transport_session_snapshot_t *snapshot =
|
||||
&s_session_snapshots[token->slot_index];
|
||||
bool current = admin_console_snapshot_matches_locked(token) &&
|
||||
snapshot->state == SSH_TRANSPORT_SESSION_ACTIVE &&
|
||||
snapshot->authenticated && snapshot->principal_valid &&
|
||||
!snapshot->close_requested &&
|
||||
s_external_close_id[token->slot_index] != token->session_id;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return current;
|
||||
}
|
||||
|
||||
static bool admin_console_transport_output_is_drained(
|
||||
const admin_ssh_console_token_t *token)
|
||||
{
|
||||
if (!admin_console_token_is_ssh(token)) {
|
||||
return false;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool drained = admin_console_snapshot_matches_locked(token) &&
|
||||
!s_session_snapshots[token->slot_index].tx_pending;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return drained;
|
||||
}
|
||||
|
||||
static esp_err_t admin_console_request_disconnect(
|
||||
const admin_ssh_console_token_t *token, uint32_t session_id)
|
||||
{
|
||||
if (!admin_console_token_is_ssh(token)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
return ssh_transport_disconnect(session_id);
|
||||
}
|
||||
|
||||
static const admin_ssh_console_frontend_ops_t s_admin_console_frontend_ops = {
|
||||
.binding_is_current = admin_console_binding_is_current,
|
||||
.transport_output_is_drained = admin_console_transport_output_is_drained,
|
||||
.request_disconnect = admin_console_request_disconnect,
|
||||
};
|
||||
|
||||
static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
|
||||
{
|
||||
ssh_transport_session_snapshot_t snapshot = {
|
||||
@@ -924,7 +992,8 @@ static void process_handshake(ssh_slot_t *slot, size_t slot_index)
|
||||
slot->route = SSH_TRANSPORT_ROUTE_BROKER;
|
||||
} else if (slot->principal.role == USER_ROLE_ADMIN) {
|
||||
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
|
||||
error = admin_ssh_console_open(&token, &slot->principal);
|
||||
error = admin_ssh_console_open(
|
||||
&token, &slot->principal, &s_admin_console_frontend_ops);
|
||||
if (error != ESP_OK) {
|
||||
add_counter(&s_counters.admin_console_admission_failures, 1U);
|
||||
request_slot_close(slot, false);
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Typed, serialized user-administration mutations with transport revocation. */
|
||||
|
||||
#include "user_admin_service.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_command_gate.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "web_server.h"
|
||||
|
||||
typedef enum {
|
||||
USER_ADMIN_OPERATION_CREATE = 0,
|
||||
USER_ADMIN_OPERATION_CREATE_GENERATED,
|
||||
USER_ADMIN_OPERATION_DELETE,
|
||||
USER_ADMIN_OPERATION_SET_ROLE,
|
||||
USER_ADMIN_OPERATION_SET_PASSWORD,
|
||||
USER_ADMIN_OPERATION_GENERATE_PASSWORD,
|
||||
USER_ADMIN_OPERATION_ADD_SSH_KEY,
|
||||
USER_ADMIN_OPERATION_REMOVE_SSH_KEY,
|
||||
USER_ADMIN_OPERATION_CLEAR_SSH_KEYS,
|
||||
} user_admin_operation_type_t;
|
||||
|
||||
typedef struct {
|
||||
user_admin_operation_type_t type;
|
||||
const uint8_t *username;
|
||||
size_t username_length;
|
||||
user_role_t role;
|
||||
const uint8_t *password;
|
||||
size_t password_length;
|
||||
const uint8_t *key_type;
|
||||
size_t key_type_length;
|
||||
const uint8_t *key_blob;
|
||||
size_t key_blob_length;
|
||||
uint8_t key_index;
|
||||
uint8_t *added_key_index;
|
||||
user_database_generated_password_t *generated_password;
|
||||
} user_admin_operation_t;
|
||||
|
||||
/* Every access is protected by admin_command_gate. Keep this large snapshot off task stacks. */
|
||||
static user_database_snapshot_t s_snapshot;
|
||||
|
||||
static const user_database_user_snapshot_t *find_snapshot_user(
|
||||
const user_admin_operation_t *operation)
|
||||
{
|
||||
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) {
|
||||
const user_database_user_snapshot_t *user = &s_snapshot.users[index];
|
||||
if (user->active && user->username_length == operation->username_length &&
|
||||
memcmp(user->username, operation->username, operation->username_length) == 0) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool role_valid(user_role_t role)
|
||||
{
|
||||
return role == USER_ROLE_USER || role == USER_ROLE_ADMIN;
|
||||
}
|
||||
|
||||
static bool operation_arguments_valid(const user_admin_operation_t *operation)
|
||||
{
|
||||
if (operation == NULL ||
|
||||
!user_database_username_valid(operation->username, operation->username_length)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operation->type) {
|
||||
case USER_ADMIN_OPERATION_CREATE:
|
||||
return role_valid(operation->role) &&
|
||||
user_database_password_valid(operation->password,
|
||||
operation->password_length);
|
||||
case USER_ADMIN_OPERATION_CREATE_GENERATED:
|
||||
return role_valid(operation->role) && operation->generated_password != NULL;
|
||||
case USER_ADMIN_OPERATION_DELETE:
|
||||
return true;
|
||||
case USER_ADMIN_OPERATION_SET_ROLE:
|
||||
return role_valid(operation->role);
|
||||
case USER_ADMIN_OPERATION_SET_PASSWORD:
|
||||
return user_database_password_valid(operation->password,
|
||||
operation->password_length);
|
||||
case USER_ADMIN_OPERATION_GENERATE_PASSWORD:
|
||||
return operation->generated_password != NULL;
|
||||
case USER_ADMIN_OPERATION_ADD_SSH_KEY:
|
||||
return operation->added_key_index != NULL;
|
||||
case USER_ADMIN_OPERATION_REMOVE_SSH_KEY:
|
||||
return operation->key_index < USER_DATABASE_MAX_SSH_KEYS_PER_USER;
|
||||
case USER_ADMIN_OPERATION_CLEAR_SSH_KEYS:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool operation_expected_to_commit(
|
||||
const user_admin_operation_t *operation,
|
||||
const user_database_user_snapshot_t *user)
|
||||
{
|
||||
if (operation->type == USER_ADMIN_OPERATION_SET_ROLE && user != NULL) {
|
||||
return user->role != operation->role;
|
||||
}
|
||||
if (operation->type == USER_ADMIN_OPERATION_CLEAR_SSH_KEYS && user != NULL) {
|
||||
return user->public_key_count != 0U;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static esp_err_t apply_database_operation(const user_admin_operation_t *operation)
|
||||
{
|
||||
switch (operation->type) {
|
||||
case USER_ADMIN_OPERATION_CREATE:
|
||||
return user_database_create(
|
||||
operation->username, operation->username_length, operation->role,
|
||||
operation->password, operation->password_length);
|
||||
case USER_ADMIN_OPERATION_CREATE_GENERATED:
|
||||
return user_database_create_generated(
|
||||
operation->username, operation->username_length, operation->role,
|
||||
operation->generated_password);
|
||||
case USER_ADMIN_OPERATION_DELETE:
|
||||
return user_database_delete(operation->username, operation->username_length);
|
||||
case USER_ADMIN_OPERATION_SET_ROLE:
|
||||
return user_database_set_role(operation->username, operation->username_length,
|
||||
operation->role);
|
||||
case USER_ADMIN_OPERATION_SET_PASSWORD:
|
||||
return user_database_set_password(
|
||||
operation->username, operation->username_length,
|
||||
operation->password, operation->password_length);
|
||||
case USER_ADMIN_OPERATION_GENERATE_PASSWORD:
|
||||
return user_database_generate_password(
|
||||
operation->username, operation->username_length,
|
||||
operation->generated_password);
|
||||
case USER_ADMIN_OPERATION_ADD_SSH_KEY:
|
||||
return user_database_add_ssh_key(
|
||||
operation->username, operation->username_length,
|
||||
operation->key_type, operation->key_type_length,
|
||||
operation->key_blob, operation->key_blob_length,
|
||||
operation->added_key_index);
|
||||
case USER_ADMIN_OPERATION_REMOVE_SSH_KEY:
|
||||
return user_database_remove_ssh_key(
|
||||
operation->username, operation->username_length, operation->key_index);
|
||||
case USER_ADMIN_OPERATION_CLEAR_SSH_KEYS:
|
||||
return user_database_clear_ssh_keys(
|
||||
operation->username, operation->username_length);
|
||||
default:
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
}
|
||||
|
||||
static void revoke_committed_user(const user_admin_operation_t *operation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
result->revocation.attempted = true;
|
||||
result->revocation.web_error = web_server_revoke_user(
|
||||
operation->username, operation->username_length);
|
||||
result->revocation.ssh_error = ssh_transport_revoke_user(
|
||||
operation->username, operation->username_length);
|
||||
}
|
||||
|
||||
static bool operation_is_create(const user_admin_operation_t *operation)
|
||||
{
|
||||
return operation->type == USER_ADMIN_OPERATION_CREATE ||
|
||||
operation->type == USER_ADMIN_OPERATION_CREATE_GENERATED;
|
||||
}
|
||||
|
||||
static bool operation_advances_auth_generation(
|
||||
const user_admin_operation_t *operation)
|
||||
{
|
||||
return operation->type == USER_ADMIN_OPERATION_SET_ROLE ||
|
||||
operation->type == USER_ADMIN_OPERATION_SET_PASSWORD ||
|
||||
operation->type == USER_ADMIN_OPERATION_GENERATE_PASSWORD ||
|
||||
operation->type == USER_ADMIN_OPERATION_ADD_SSH_KEY ||
|
||||
operation->type == USER_ADMIN_OPERATION_REMOVE_SSH_KEY ||
|
||||
operation->type == USER_ADMIN_OPERATION_CLEAR_SSH_KEYS;
|
||||
}
|
||||
|
||||
static user_admin_service_failure_t classify_failure(
|
||||
const user_admin_operation_t *operation,
|
||||
const user_database_user_snapshot_t *user, esp_err_t error)
|
||||
{
|
||||
if (error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_DUPLICATE_KEY;
|
||||
}
|
||||
if (error == ESP_ERR_NO_MEM) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_CAPACITY;
|
||||
}
|
||||
if (error != ESP_ERR_INVALID_STATE) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_NONE;
|
||||
}
|
||||
if (operation_is_create(operation) && user != NULL) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_DUPLICATE_USERNAME;
|
||||
}
|
||||
if (user != NULL && user->role == USER_ROLE_ADMIN &&
|
||||
s_snapshot.admin_count <= 1U &&
|
||||
(operation->type == USER_ADMIN_OPERATION_DELETE ||
|
||||
(operation->type == USER_ADMIN_OPERATION_SET_ROLE &&
|
||||
operation->role != USER_ROLE_ADMIN))) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_FINAL_ADMIN;
|
||||
}
|
||||
if (s_snapshot.generation == UINT32_MAX ||
|
||||
(user != NULL && operation_advances_auth_generation(operation) &&
|
||||
user->auth_generation == UINT32_MAX)) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_GENERATION_EXHAUSTED;
|
||||
}
|
||||
return USER_ADMIN_SERVICE_FAILURE_STATE;
|
||||
}
|
||||
|
||||
static esp_err_t execute_operation(
|
||||
const user_admin_operation_t *operation,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
if (result == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
memset(result, 0, sizeof(*result));
|
||||
if (!operation_arguments_valid(operation) ||
|
||||
(operation_is_create(operation) && expectation != NULL &&
|
||||
expectation->user_id != 0U)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (operation->added_key_index != NULL) {
|
||||
*operation->added_key_index = 0U;
|
||||
}
|
||||
|
||||
esp_err_t error = admin_command_gate_take();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
error = user_database_get_snapshot(&s_snapshot);
|
||||
if (error != ESP_OK) {
|
||||
admin_command_gate_give();
|
||||
return error;
|
||||
}
|
||||
|
||||
const uint32_t before_generation = s_snapshot.generation;
|
||||
const user_database_user_snapshot_t *user = find_snapshot_user(operation);
|
||||
result->database_generation = before_generation;
|
||||
if (user != NULL) {
|
||||
result->user_id = user->user_id;
|
||||
}
|
||||
|
||||
if (expectation != NULL && expectation->database_generation != 0U &&
|
||||
expectation->database_generation != before_generation) {
|
||||
result->conflict = USER_ADMIN_SERVICE_CONFLICT_DATABASE_GENERATION;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
} else if (!operation_is_create(operation) && expectation != NULL &&
|
||||
expectation->user_id != 0U &&
|
||||
(user == NULL || expectation->user_id != user->user_id)) {
|
||||
result->conflict = USER_ADMIN_SERVICE_CONFLICT_USER_ID;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
} else {
|
||||
const bool expected_commit = operation_expected_to_commit(operation, user);
|
||||
error = apply_database_operation(operation);
|
||||
if (error != ESP_OK) {
|
||||
result->failure = classify_failure(operation, user, error);
|
||||
} else {
|
||||
esp_err_t snapshot_error = user_database_get_snapshot(&s_snapshot);
|
||||
if (snapshot_error == ESP_OK) {
|
||||
result->database_generation = s_snapshot.generation;
|
||||
result->mutation_committed =
|
||||
s_snapshot.generation != before_generation;
|
||||
const user_database_user_snapshot_t *updated_user =
|
||||
find_snapshot_user(operation);
|
||||
if (updated_user != NULL) {
|
||||
result->user_id = updated_user->user_id;
|
||||
}
|
||||
} else {
|
||||
result->mutation_committed = expected_commit;
|
||||
if (expected_commit) {
|
||||
result->database_generation = before_generation + 1U;
|
||||
}
|
||||
}
|
||||
if (result->mutation_committed) {
|
||||
revoke_committed_user(operation, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
admin_command_gate_give();
|
||||
if (error != ESP_OK && operation->generated_password != NULL) {
|
||||
secure_wipe(operation->generated_password,
|
||||
sizeof(*operation->generated_password));
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_create(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_CREATE,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.role = role,
|
||||
.password = password,
|
||||
.password_length = password_length,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_create_generated(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_database_generated_password_t *generated_password,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
if (generated_password != NULL) {
|
||||
secure_wipe(generated_password, sizeof(*generated_password));
|
||||
}
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_CREATE_GENERATED,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.role = role,
|
||||
.generated_password = generated_password,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_delete(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_DELETE,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_set_role(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_SET_ROLE,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.role = role,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_set_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_SET_PASSWORD,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.password = password,
|
||||
.password_length = password_length,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_generate_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_database_generated_password_t *generated_password,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
if (generated_password != NULL) {
|
||||
secure_wipe(generated_password, sizeof(*generated_password));
|
||||
}
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_GENERATE_PASSWORD,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.generated_password = generated_password,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_add_ssh_key(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *key_type, size_t key_type_length,
|
||||
const uint8_t *key_blob, size_t key_blob_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
uint8_t *key_index, user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_ADD_SSH_KEY,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.key_type = key_type,
|
||||
.key_type_length = key_type_length,
|
||||
.key_blob = key_blob,
|
||||
.key_blob_length = key_blob_length,
|
||||
.added_key_index = key_index,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_remove_ssh_key(
|
||||
const uint8_t *username, size_t username_length, uint8_t key_index,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_REMOVE_SSH_KEY,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.key_index = key_index,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_clear_ssh_keys(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_CLEAR_SSH_KEYS,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Typed, serialized user-administration mutations with transport revocation. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "user_database.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
/* Zero disables the corresponding optimistic check. */
|
||||
uint32_t database_generation;
|
||||
uint32_t user_id;
|
||||
} user_admin_service_expectation_t;
|
||||
|
||||
typedef enum {
|
||||
USER_ADMIN_SERVICE_CONFLICT_NONE = 0,
|
||||
USER_ADMIN_SERVICE_CONFLICT_DATABASE_GENERATION,
|
||||
USER_ADMIN_SERVICE_CONFLICT_USER_ID,
|
||||
} user_admin_service_conflict_t;
|
||||
|
||||
typedef struct {
|
||||
bool attempted;
|
||||
esp_err_t web_error;
|
||||
esp_err_t ssh_error;
|
||||
} user_admin_service_revocation_t;
|
||||
|
||||
typedef enum {
|
||||
USER_ADMIN_SERVICE_FAILURE_NONE = 0,
|
||||
USER_ADMIN_SERVICE_FAILURE_DUPLICATE_USERNAME,
|
||||
USER_ADMIN_SERVICE_FAILURE_DUPLICATE_KEY,
|
||||
USER_ADMIN_SERVICE_FAILURE_CAPACITY,
|
||||
USER_ADMIN_SERVICE_FAILURE_FINAL_ADMIN,
|
||||
USER_ADMIN_SERVICE_FAILURE_GENERATION_EXHAUSTED,
|
||||
USER_ADMIN_SERVICE_FAILURE_STATE,
|
||||
} user_admin_service_failure_t;
|
||||
|
||||
typedef struct {
|
||||
/* True only when the database generation advanced for this operation. */
|
||||
bool mutation_committed;
|
||||
/* Current generation observed before, or immediately after, the operation. */
|
||||
uint32_t database_generation;
|
||||
/* Target account ID observed before the operation, or after a successful create. */
|
||||
uint32_t user_id;
|
||||
user_admin_service_conflict_t conflict;
|
||||
user_admin_service_failure_t failure;
|
||||
user_admin_service_revocation_t revocation;
|
||||
} user_admin_service_result_t;
|
||||
|
||||
/*
|
||||
* Every operation serializes its snapshot check and database mutation with
|
||||
* admin_command_gate. A generation or user-ID mismatch returns
|
||||
* ESP_ERR_INVALID_STATE and identifies the mismatch in result->conflict.
|
||||
*
|
||||
* A NULL expectation disables both optimistic checks. For existing-account
|
||||
* operations, zero fields also disable their individual checks. Create
|
||||
* operations require expectation->user_id to be zero because no prior account
|
||||
* identity can be targeted.
|
||||
*
|
||||
* After a committed mutation, both transport revocation hooks are attempted.
|
||||
* Their exact outcomes are returned separately and never replace ESP_OK from a
|
||||
* successful database mutation. No-op role and key-clear requests do not
|
||||
* advance the generation and do not trigger revocation.
|
||||
*/
|
||||
esp_err_t user_admin_service_create(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
/* On success, the caller owns generated_password and must securely wipe it. */
|
||||
esp_err_t user_admin_service_create_generated(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_database_generated_password_t *generated_password,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_delete(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_set_role(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_set_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
/* On success, the caller owns generated_password and must securely wipe it. */
|
||||
esp_err_t user_admin_service_generate_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_database_generated_password_t *generated_password,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_add_ssh_key(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *key_type, size_t key_type_length,
|
||||
const uint8_t *key_blob, size_t key_blob_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
uint8_t *key_index, user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_remove_ssh_key(
|
||||
const uint8_t *username, size_t username_length, uint8_t key_index,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_clear_ssh_keys(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+58
-33
@@ -14,9 +14,10 @@
|
||||
#include "mbedtls/base64.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "user_admin_service.h"
|
||||
#include "user_database.h"
|
||||
#include "web_security.h"
|
||||
#include "web_serial_transport.h"
|
||||
#include "web_server.h"
|
||||
|
||||
#define USER_CONSOLE_KEY_LINE_CAPACITY 256U
|
||||
|
||||
@@ -40,15 +41,10 @@ static void print_usage(void)
|
||||
printf(" user key clear <username> --force\n");
|
||||
}
|
||||
|
||||
static void revoke_user_network_sessions(const char *username)
|
||||
static void print_revocation_warnings(esp_err_t web_error, esp_err_t ssh_error)
|
||||
{
|
||||
size_t username_length = strlen(username);
|
||||
esp_err_t web_error = web_serial_transport_revoke_user(
|
||||
(const uint8_t *)username, username_length);
|
||||
esp_err_t ssh_error = ssh_transport_revoke_user(
|
||||
(const uint8_t *)username, username_length);
|
||||
if (web_error != ESP_OK && web_error != ESP_ERR_INVALID_STATE) {
|
||||
printf("Warning: WebSocket revocation failed: %s\n",
|
||||
printf("Warning: Web session revocation failed: %s\n",
|
||||
esp_err_to_name(web_error));
|
||||
}
|
||||
if (ssh_error != ESP_OK && ssh_error != ESP_ERR_INVALID_STATE) {
|
||||
@@ -56,6 +52,25 @@ static void revoke_user_network_sessions(const char *username)
|
||||
}
|
||||
}
|
||||
|
||||
static void print_service_revocation_warnings(
|
||||
const user_admin_service_result_t *result)
|
||||
{
|
||||
if (result->revocation.attempted) {
|
||||
print_revocation_warnings(result->revocation.web_error,
|
||||
result->revocation.ssh_error);
|
||||
}
|
||||
}
|
||||
|
||||
static void revoke_user_network_sessions(const char *username)
|
||||
{
|
||||
size_t username_length = strlen(username);
|
||||
esp_err_t web_error = web_server_revoke_user(
|
||||
(const uint8_t *)username, username_length);
|
||||
esp_err_t ssh_error = ssh_transport_revoke_user(
|
||||
(const uint8_t *)username, username_length);
|
||||
print_revocation_warnings(web_error, ssh_error);
|
||||
}
|
||||
|
||||
static void print_fingerprint(const uint8_t fingerprint[USER_DATABASE_SHA256_LENGTH])
|
||||
{
|
||||
uint8_t encoded[48] = {0};
|
||||
@@ -228,10 +243,12 @@ static int add_user(const char *username, const char *role_text, bool generated)
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error;
|
||||
user_admin_service_result_t result;
|
||||
if (generated) {
|
||||
user_database_generated_password_t password;
|
||||
error = user_database_create_generated((const uint8_t *)username,
|
||||
strlen(username), role, &password);
|
||||
error = user_admin_service_create_generated(
|
||||
(const uint8_t *)username, strlen(username), role, NULL,
|
||||
&password, &result);
|
||||
if (error == ESP_OK) {
|
||||
show_generated_password(username, &password);
|
||||
}
|
||||
@@ -240,8 +257,9 @@ static int add_user(const char *username, const char *role_text, bool generated)
|
||||
size_t password_length = 0U;
|
||||
error = read_password(password, &password_length);
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_create((const uint8_t *)username, strlen(username),
|
||||
role, password, password_length);
|
||||
error = user_admin_service_create(
|
||||
(const uint8_t *)username, strlen(username), role,
|
||||
password, password_length, NULL, &result);
|
||||
}
|
||||
secure_wipe(password, sizeof(password));
|
||||
}
|
||||
@@ -249,7 +267,7 @@ static int add_user(const char *username, const char *role_text, bool generated)
|
||||
printf("Could not add user: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(username);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("User '%s' added with role %s.\n", username, user_role_to_string(role));
|
||||
return 0;
|
||||
}
|
||||
@@ -257,10 +275,11 @@ static int add_user(const char *username, const char *role_text, bool generated)
|
||||
static int change_password(const char *username, bool generated)
|
||||
{
|
||||
esp_err_t error;
|
||||
user_admin_service_result_t result;
|
||||
if (generated) {
|
||||
user_database_generated_password_t password;
|
||||
error = user_database_generate_password((const uint8_t *)username,
|
||||
strlen(username), &password);
|
||||
error = user_admin_service_generate_password(
|
||||
(const uint8_t *)username, strlen(username), NULL, &password, &result);
|
||||
if (error == ESP_OK) {
|
||||
show_generated_password(username, &password);
|
||||
}
|
||||
@@ -269,9 +288,9 @@ static int change_password(const char *username, bool generated)
|
||||
size_t password_length = 0U;
|
||||
error = read_password(password, &password_length);
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_set_password((const uint8_t *)username,
|
||||
strlen(username),
|
||||
password, password_length);
|
||||
error = user_admin_service_set_password(
|
||||
(const uint8_t *)username, strlen(username),
|
||||
password, password_length, NULL, &result);
|
||||
}
|
||||
secure_wipe(password, sizeof(password));
|
||||
}
|
||||
@@ -279,7 +298,7 @@ static int change_password(const char *username, bool generated)
|
||||
printf("Could not change password: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(username);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("Password changed; affected network sessions are now stale and will be revoked.\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -317,9 +336,10 @@ static int add_key_parts(const char *username,
|
||||
}
|
||||
|
||||
uint8_t key_index = 0U;
|
||||
esp_err_t error = user_database_add_ssh_key(
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_add_ssh_key(
|
||||
(const uint8_t *)username, strlen(username), type, type_length,
|
||||
blob, blob_length, &key_index);
|
||||
blob, blob_length, NULL, &key_index, &result);
|
||||
secure_wipe(blob, sizeof(blob));
|
||||
if (error != ESP_OK) {
|
||||
if (error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY) {
|
||||
@@ -332,7 +352,7 @@ static int add_key_parts(const char *username,
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(username);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("SSH public key added at index %u. Public-key login is active.\n",
|
||||
(unsigned int)key_index);
|
||||
return 0;
|
||||
@@ -425,13 +445,15 @@ static int command_user_inner(int argc, char **argv)
|
||||
}
|
||||
if (argc == 4 && strcmp(argv[1], "delete") == 0 &&
|
||||
strcmp(argv[3], "--force") == 0) {
|
||||
esp_err_t error = user_database_delete((const uint8_t *)argv[2], strlen(argv[2]));
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_delete(
|
||||
(const uint8_t *)argv[2], strlen(argv[2]), NULL, &result);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not delete user (the migrated or final admin is protected): %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(argv[2]);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("User '%s' deleted.\n", argv[2]);
|
||||
return 0;
|
||||
}
|
||||
@@ -443,14 +465,15 @@ static int command_user_inner(int argc, char **argv)
|
||||
printf("Role must be user or admin.\n");
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error = user_database_set_role((const uint8_t *)argv[2],
|
||||
strlen(argv[2]), role);
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_set_role(
|
||||
(const uint8_t *)argv[2], strlen(argv[2]), role, NULL, &result);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not change role (the final admin is protected): %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(argv[2]);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("User '%s' role changed to %s.\n", argv[2], user_role_to_string(role));
|
||||
return 0;
|
||||
}
|
||||
@@ -484,25 +507,27 @@ static int command_user_inner(int argc, char **argv)
|
||||
printf("Key index must be 0..2.\n");
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error = user_database_remove_ssh_key(
|
||||
(const uint8_t *)argv[3], strlen(argv[3]), index);
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_remove_ssh_key(
|
||||
(const uint8_t *)argv[3], strlen(argv[3]), index, NULL, &result);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not delete SSH key: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(argv[3]);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("SSH key %u deleted for '%s'.\n", (unsigned int)index, argv[3]);
|
||||
return 0;
|
||||
}
|
||||
if (argc == 5 && strcmp(argv[1], "key") == 0 &&
|
||||
strcmp(argv[2], "clear") == 0 && strcmp(argv[4], "--force") == 0) {
|
||||
esp_err_t error = user_database_clear_ssh_keys(
|
||||
(const uint8_t *)argv[3], strlen(argv[3]));
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_clear_ssh_keys(
|
||||
(const uint8_t *)argv[3], strlen(argv[3]), NULL, &result);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not clear SSH keys: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(argv[3]);
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("SSH keys cleared for '%s'.\n", argv[3]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
+160
-27
@@ -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) {
|
||||
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;
|
||||
}
|
||||
esp_err_t error = web_server_stop();
|
||||
if (error != ESP_OK) {
|
||||
printf("Material changed, but the old TLS server could not stop: %s\n",
|
||||
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;
|
||||
|
||||
+220
-54
@@ -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, ¤t);
|
||||
result = authentication_binding_is_current(
|
||||
&candidate, &candidate_reference, ¤t);
|
||||
if (result == ESP_OK && current) {
|
||||
*principal = candidate;
|
||||
*session_reference = candidate_reference;
|
||||
*consumed = true;
|
||||
add_counter(&s_counters.tickets_consumed, 1U);
|
||||
}
|
||||
@@ -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 &&
|
||||
bool binding_staged = slot->state == WEB_SERIAL_SLOT_RESERVED &&
|
||||
slot->generation == slot_generation &&
|
||||
!slot->close_requested;
|
||||
if (principal_staged) {
|
||||
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, ¤t);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
esp_err_t currentness_result = authentication_binding_is_current(
|
||||
&principal, &session_reference, ¤t);
|
||||
if (currentness_result != ESP_OK || !current) {
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
secure_wipe(&session_reference, sizeof(session_reference));
|
||||
request_handler_close(slot, generation);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
@@ -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, ¤t);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
if (result == ESP_OK && current) {
|
||||
return;
|
||||
}
|
||||
|
||||
esp_err_t result = authentication_binding_is_current(
|
||||
&principal, &session_reference, ¤t);
|
||||
if (result != ESP_OK || !current) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (slot->state == WEB_SERIAL_SLOT_ACTIVE &&
|
||||
slot->generation == generation) {
|
||||
slot->generation == generation &&
|
||||
session_reference_equal(&slot->session_reference,
|
||||
&session_reference)) {
|
||||
slot->close_requested = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
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, ¤t);
|
||||
esp_err_t result = authentication_binding_is_current(
|
||||
principal, session_reference, ¤t);
|
||||
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, ¤t);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
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);
|
||||
|
||||
+3464
-192
File diff suppressed because it is too large
Load Diff
+40
-1
@@ -1,12 +1,14 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Authenticated HTTPS administration foundation. */
|
||||
/* Session-authenticated HTTPS administration foundation. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "web_session.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -22,17 +24,39 @@ typedef struct {
|
||||
uint64_t root_requests;
|
||||
uint64_t status_requests;
|
||||
uint64_t ticket_requests;
|
||||
uint64_t admin_ticket_requests;
|
||||
uint64_t admin_authorization_failures;
|
||||
uint64_t admin_api_requests;
|
||||
uint64_t admin_request_rejections;
|
||||
uint64_t admin_operation_failures;
|
||||
uint64_t writer_transfer_attempts;
|
||||
uint64_t writer_transfer_successes;
|
||||
uint64_t writer_transfer_conflicts;
|
||||
uint64_t asset_requests;
|
||||
uint64_t response_errors;
|
||||
uint64_t login_requests;
|
||||
uint64_t login_successes;
|
||||
uint64_t login_failures;
|
||||
uint64_t login_throttled;
|
||||
uint64_t logout_requests;
|
||||
uint64_t session_requests;
|
||||
uint64_t cookie_rejections;
|
||||
uint64_t origin_rejections;
|
||||
uint64_t csrf_rejections;
|
||||
} web_server_counters_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
bool running;
|
||||
bool transitioning;
|
||||
bool desired_running;
|
||||
uint32_t lifecycle_generation;
|
||||
uint16_t port;
|
||||
esp_err_t last_error;
|
||||
esp_err_t serial_transport_error;
|
||||
esp_err_t admin_transport_error;
|
||||
uint32_t active_sessions;
|
||||
web_session_counters_t session_counters;
|
||||
web_server_counters_t counters;
|
||||
} web_server_snapshot_t;
|
||||
|
||||
@@ -42,10 +66,25 @@ esp_err_t web_server_init(void);
|
||||
/* Start one TLS-only server on all active network interfaces. */
|
||||
esp_err_t web_server_start(void);
|
||||
esp_err_t web_server_stop(void);
|
||||
/*
|
||||
* Stop only while expected_lifecycle_generation still names the latest
|
||||
* explicit HTTPS intent. A newer start/stop/refresh wins and returns
|
||||
* ESP_ERR_INVALID_STATE without changing server state.
|
||||
*/
|
||||
esp_err_t web_server_stop_if_generation(uint32_t expected_lifecycle_generation);
|
||||
/*
|
||||
* Apply current TLS material to a live server. If it is stopped, start only
|
||||
* when requested and no newer explicit lifecycle intent superseded the caller.
|
||||
*/
|
||||
esp_err_t web_server_refresh_tls(uint32_t expected_lifecycle_generation,
|
||||
bool start_if_unchanged);
|
||||
|
||||
esp_err_t web_server_get_snapshot(web_server_snapshot_t *snapshot);
|
||||
esp_err_t web_server_clear_counters(void);
|
||||
|
||||
/* Revoke one account's browser sessions and both WebSocket transports. */
|
||||
esp_err_t web_server_revoke_user(const uint8_t *username, size_t username_length);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
+1097
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded opaque browser sessions with principal and CSRF validation. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "user_database.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define WEB_SESSION_MAX_SESSIONS 8U
|
||||
#define WEB_SESSION_MAX_SESSIONS_PER_ACCOUNT 2U
|
||||
#define WEB_SESSION_BASE64URL_SOURCE_LENGTH 24U
|
||||
#define WEB_SESSION_TOKEN_RANDOM_LENGTH WEB_SESSION_BASE64URL_SOURCE_LENGTH
|
||||
#define WEB_SESSION_TOKEN_LENGTH 32U
|
||||
#define WEB_SESSION_TOKEN_CAPACITY (WEB_SESSION_TOKEN_LENGTH + 1U)
|
||||
#define WEB_SESSION_TOKEN_DIGEST_LENGTH 32U
|
||||
#define WEB_SESSION_CSRF_KEY_LENGTH 32U
|
||||
#define WEB_SESSION_HMAC_BLOCK_LENGTH 64U
|
||||
#define WEB_SESSION_HMAC_DIGEST_LENGTH 32U
|
||||
#define WEB_SESSION_CSRF_SOURCE_LENGTH WEB_SESSION_BASE64URL_SOURCE_LENGTH
|
||||
#define WEB_SESSION_CSRF_TOKEN_LENGTH 32U
|
||||
#define WEB_SESSION_CSRF_TOKEN_CAPACITY (WEB_SESSION_CSRF_TOKEN_LENGTH + 1U)
|
||||
#define WEB_SESSION_LIFETIME_SECONDS (8U * 60U * 60U)
|
||||
#define WEB_SESSION_LIFETIME_US \
|
||||
((int64_t)WEB_SESSION_LIFETIME_SECONDS * 1000000LL)
|
||||
#define WEB_SESSION_ERR_CAPACITY ESP_ERR_NO_MEM
|
||||
|
||||
typedef struct {
|
||||
uint8_t slot_index;
|
||||
uint32_t generation;
|
||||
} web_session_ref_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t created;
|
||||
uint64_t create_failures;
|
||||
uint64_t capacity_failures;
|
||||
uint64_t authenticated;
|
||||
uint64_t rejected;
|
||||
uint64_t expired;
|
||||
uint64_t stale_principal;
|
||||
uint64_t destroyed;
|
||||
uint64_t revocations;
|
||||
uint64_t csrf_accepted;
|
||||
uint64_t csrf_rejected;
|
||||
} web_session_counters_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
uint32_t active_sessions;
|
||||
web_session_counters_t counters;
|
||||
} web_session_snapshot_t;
|
||||
|
||||
/*
|
||||
* Generate the boot-local CSRF key and initialize the fixed session table.
|
||||
* secure_random_init() and user_database_init() must already have succeeded.
|
||||
* Repeated calls after successful initialization return ESP_OK without changing
|
||||
* sessions, the boot-local key, or counters.
|
||||
*/
|
||||
esp_err_t web_session_init(void);
|
||||
|
||||
/*
|
||||
* Authenticate bounded username/password input and create a fixed eight-hour
|
||||
* session. On success, session_token contains exactly 32 Base64URL characters
|
||||
* plus a terminator and principal is a copied secret-free value. The caller must send
|
||||
* the session token only through a suitably protected host-only cookie and must
|
||||
* never log either token. Invalid credentials return ESP_OK with created=false;
|
||||
* a full table of current, unexpired sessions returns WEB_SESSION_ERR_CAPACITY.
|
||||
* At most two sessions are retained per account; a later login replaces that
|
||||
* account's oldest session. All secret-bearing outputs are cleared on failure.
|
||||
*/
|
||||
esp_err_t web_session_create(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
char *session_token, size_t session_token_capacity,
|
||||
user_principal_t *principal, bool *created);
|
||||
|
||||
/*
|
||||
* Authenticate an exact length-delimited session token. Missing, malformed,
|
||||
* expired, destroyed, or unknown tokens return ESP_OK with authenticated=false.
|
||||
* Database/currentness failures fail closed and are returned to the caller.
|
||||
*/
|
||||
esp_err_t web_session_authenticate(const char *session_token,
|
||||
size_t session_token_length,
|
||||
user_principal_t *principal,
|
||||
bool *authenticated);
|
||||
|
||||
|
||||
/* A syntactically valid reference names a bounded slot and nonzero generation. */
|
||||
bool web_session_ref_valid(const web_session_ref_t *reference);
|
||||
|
||||
/*
|
||||
* Authenticate exactly as web_session_authenticate() and additionally return the
|
||||
* reference of that exact live browser login session. reference is cleared unless
|
||||
* authenticated is true. No raw session token is retained.
|
||||
*/
|
||||
esp_err_t web_session_get_reference(
|
||||
const char *session_token, size_t session_token_length,
|
||||
user_principal_t *principal, web_session_ref_t *reference,
|
||||
bool *authenticated);
|
||||
|
||||
/*
|
||||
* Check that reference still names the same live session carrying principal and
|
||||
* that the copied principal remains current in the user database. The database
|
||||
* check is performed without holding the browser-session mutex, followed by
|
||||
* locked reference revalidation. Database failures fail closed.
|
||||
*/
|
||||
esp_err_t web_session_ref_is_current(const web_session_ref_t *reference,
|
||||
const user_principal_t *principal,
|
||||
bool *current);
|
||||
|
||||
/*
|
||||
* Authenticate a session and reproduce its deterministic boot-local CSRF token.
|
||||
* This supports rendering a fresh page without storing separate per-session CSRF
|
||||
* material. csrf_token is cleared unless authenticated is true.
|
||||
*/
|
||||
esp_err_t web_session_copy_csrf_token(
|
||||
const char *session_token, size_t session_token_length,
|
||||
char *csrf_token, size_t csrf_token_capacity,
|
||||
user_principal_t *principal, bool *authenticated);
|
||||
|
||||
/*
|
||||
* Authenticate the session and compare an exact length-delimited CSRF token in
|
||||
* constant time. accepted is true only when both the current session and its
|
||||
* session-bound CSRF token are valid. The copied principal is cleared otherwise.
|
||||
*/
|
||||
esp_err_t web_session_authenticate_csrf(
|
||||
const char *session_token, size_t session_token_length,
|
||||
const char *csrf_token, size_t csrf_token_length,
|
||||
user_principal_t *principal, bool *accepted);
|
||||
|
||||
/* Explicitly destroy every entry matching one opaque token digest. */
|
||||
esp_err_t web_session_destroy(const char *session_token,
|
||||
size_t session_token_length,
|
||||
bool *destroyed);
|
||||
|
||||
/* Revoke sessions for one bounded username, or every browser session. */
|
||||
esp_err_t web_session_revoke_username(const uint8_t *username,
|
||||
size_t username_length);
|
||||
esp_err_t web_session_revoke_all(void);
|
||||
|
||||
/* The snapshot contains no token, digest, CSRF key, expiry, or principal data. */
|
||||
esp_err_t web_session_get_snapshot(web_session_snapshot_t *snapshot);
|
||||
|
||||
/* Clearing counters never changes live sessions or the boot-local CSRF key. */
|
||||
esp_err_t web_session_clear_counters(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+1169
-59
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -1,5 +1,5 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Offline browser UI response helpers for authenticated HTTPS routes. */
|
||||
/* Offline browser UI response helpers for public and session-authenticated HTTPS routes. */
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -12,6 +12,8 @@ extern "C" {
|
||||
|
||||
typedef enum {
|
||||
WEB_UI_RESOURCE_INDEX = 0,
|
||||
WEB_UI_RESOURCE_LOGIN,
|
||||
WEB_UI_RESOURCE_LOGIN_ERROR,
|
||||
WEB_UI_RESOURCE_XTERM_JS,
|
||||
WEB_UI_RESOURCE_XTERM_CSS,
|
||||
WEB_UI_RESOURCE_ADDON_FIT_JS,
|
||||
@@ -20,8 +22,9 @@ typedef enum {
|
||||
} web_ui_resource_t;
|
||||
|
||||
/*
|
||||
* Send one UI resource after the caller has authenticated the request.
|
||||
* This module deliberately performs no authentication or URI dispatch.
|
||||
* Send one UI resource after the caller has applied the route's public or
|
||||
* session-authenticated access policy. This module deliberately performs no
|
||||
* authentication, authorization, or URI dispatch.
|
||||
*/
|
||||
esp_err_t web_ui_send_response(httpd_req_t *request,
|
||||
web_ui_resource_t resource);
|
||||
|
||||
@@ -352,17 +352,4 @@ esp_err_t wifi_config_save(const wifi_app_config_t *config)
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t wifi_config_reset_storage(const wifi_app_config_t *defaults)
|
||||
{
|
||||
if (defaults != NULL) {
|
||||
return wifi_config_save(defaults);
|
||||
}
|
||||
|
||||
wifi_app_config_t generated_defaults;
|
||||
esp_err_t err = wifi_config_defaults(&generated_defaults);
|
||||
if (err == ESP_OK) {
|
||||
err = wifi_config_save(&generated_defaults);
|
||||
}
|
||||
wifi_config_secure_wipe(&generated_defaults, sizeof(generated_defaults));
|
||||
return err;
|
||||
}
|
||||
|
||||
@@ -104,8 +104,6 @@ esp_err_t wifi_config_load(wifi_app_config_t *config,
|
||||
wifi_config_load_source_t *source);
|
||||
esp_err_t wifi_config_save(const wifi_app_config_t *config);
|
||||
|
||||
/* Pass NULL to generate fresh defaults, or supply validated defaults to save. */
|
||||
esp_err_t wifi_config_reset_storage(const wifi_app_config_t *defaults);
|
||||
|
||||
/* Compatibility wrapper around the shared secure_wipe() implementation. */
|
||||
void wifi_config_secure_wipe(void *data, size_t size);
|
||||
|
||||
+84
-40
@@ -120,13 +120,16 @@ static void print_ipv4(uint32_t address)
|
||||
|
||||
static int show_status(void)
|
||||
{
|
||||
wifi_manager_snapshot_t snapshot;
|
||||
wifi_app_config_t config;
|
||||
wifi_manager_snapshot_t snapshot = {0};
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t config_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_snapshot(&snapshot);
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_get_working_config(&config);
|
||||
error = wifi_manager_get_working_config_versioned(
|
||||
&config, &config_generation);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Wi-Fi manager unavailable: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -136,7 +139,7 @@ static int show_status(void)
|
||||
config.enabled_at_boot ? "yes" : "no",
|
||||
snapshot.started ? "yes" : "no",
|
||||
wifi_manager_state_to_string(snapshot.state),
|
||||
snapshot.config_generation);
|
||||
config_generation);
|
||||
|
||||
if (snapshot.active_profile >= 0) {
|
||||
printf("STA: profile=%d SSID=", snapshot.active_profile);
|
||||
@@ -177,7 +180,7 @@ static int show_status(void)
|
||||
|
||||
static int show_profiles(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
wifi_app_config_t config = {0};
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read Wi-Fi profiles: %s\n", esp_err_to_name(error));
|
||||
@@ -231,9 +234,12 @@ static int show_counters(void)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static esp_err_t apply_candidate(wifi_app_config_t *candidate)
|
||||
static esp_err_t apply_candidate(wifi_app_config_t *candidate,
|
||||
uint32_t expected_generation,
|
||||
uint32_t *resulting_generation)
|
||||
{
|
||||
esp_err_t error = wifi_manager_apply_working_config(candidate);
|
||||
esp_err_t error = wifi_manager_compare_exchange_working_config(
|
||||
candidate, expected_generation, resulting_generation);
|
||||
wifi_config_secure_wipe(candidate, sizeof(*candidate));
|
||||
return error;
|
||||
}
|
||||
@@ -270,9 +276,12 @@ static int set_profile(char **argv)
|
||||
return 1;
|
||||
}
|
||||
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -283,7 +292,7 @@ static int set_profile(char **argv)
|
||||
profile->ssid_len = (uint8_t)ssid_len;
|
||||
profile->priority = (uint8_t)priority;
|
||||
profile->security = security;
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not apply profile: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
@@ -302,9 +311,12 @@ static int set_profile_secret(const char *slot_text)
|
||||
return 1;
|
||||
}
|
||||
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -317,7 +329,7 @@ static int set_profile_secret(const char *slot_text)
|
||||
error = read_secret_no_echo(config.profiles[slot].psk,
|
||||
&config.profiles[slot].psk_len);
|
||||
if (error == ESP_OK) {
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
} else {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
}
|
||||
@@ -341,9 +353,12 @@ static int change_profile_state(const char *operation, const char *slot_text)
|
||||
return 1;
|
||||
}
|
||||
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -355,7 +370,7 @@ static int change_profile_state(const char *operation, const char *slot_text)
|
||||
profile->enabled = strcmp(operation, "enable") == 0 ? 1U : 0U;
|
||||
}
|
||||
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not %s profile %u: %s\n", operation,
|
||||
(unsigned int)slot, esp_err_to_name(error));
|
||||
@@ -368,9 +383,12 @@ static int change_profile_state(const char *operation, const char *slot_text)
|
||||
|
||||
static int set_ap_parameter(const char *parameter, const char *value)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -403,7 +421,7 @@ static int set_ap_parameter(const char *parameter, const char *value)
|
||||
config.ap_channel = (uint8_t)channel;
|
||||
}
|
||||
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not apply AP configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
@@ -414,16 +432,19 @@ static int set_ap_parameter(const char *parameter, const char *value)
|
||||
|
||||
static int set_ap_secret(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
error = read_secret_no_echo(config.ap_psk, &config.ap_psk_len);
|
||||
if (error == ESP_OK) {
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
} else {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
}
|
||||
@@ -439,7 +460,7 @@ static int set_ap_secret(void)
|
||||
|
||||
static int show_ap_secret(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
wifi_app_config_t config = {0};
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read AP secret: %s\n", esp_err_to_name(error));
|
||||
@@ -456,12 +477,15 @@ static int show_ap_secret(void)
|
||||
|
||||
static int save_config(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_config_save(&config);
|
||||
}
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_save_working_config_if_generation(
|
||||
expected_generation);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not save Wi-Fi configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
@@ -472,11 +496,19 @@ static int save_config(void)
|
||||
|
||||
static int load_config(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
wifi_config_load_source_t source;
|
||||
esp_err_t error = wifi_config_load(&config, &source);
|
||||
wifi_app_config_t current = {0};
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
wifi_config_load_source_t source = WIFI_CONFIG_LOAD_GENERATED_MISSING;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
¤t, &expected_generation);
|
||||
wifi_config_secure_wipe(¤t, sizeof(current));
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_apply_working_config(&config);
|
||||
error = wifi_config_load(&config, &source);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_compare_exchange_working_config(
|
||||
&config, expected_generation, NULL);
|
||||
}
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
if (error != ESP_OK) {
|
||||
@@ -494,20 +526,32 @@ static int load_config(void)
|
||||
|
||||
static int apply_defaults(bool persist)
|
||||
{
|
||||
wifi_app_config_t previous;
|
||||
wifi_app_config_t defaults;
|
||||
esp_err_t error = wifi_manager_get_working_config(&previous);
|
||||
wifi_app_config_t previous = {0};
|
||||
wifi_app_config_t defaults = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
uint32_t defaults_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&previous, &expected_generation);
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_config_defaults(&defaults);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_apply_working_config(&defaults);
|
||||
error = wifi_manager_compare_exchange_working_config(
|
||||
&defaults, expected_generation, &defaults_generation);
|
||||
}
|
||||
if (error == ESP_OK && persist) {
|
||||
error = wifi_config_reset_storage(&defaults);
|
||||
if (error != ESP_OK) {
|
||||
/* Restore RAM behavior if persistence failed. */
|
||||
(void)wifi_manager_apply_working_config(&previous);
|
||||
error = wifi_manager_save_working_config_if_generation(
|
||||
defaults_generation);
|
||||
if (error != ESP_OK && error != WIFI_MANAGER_ERR_CONFIG_CONFLICT) {
|
||||
/* Restore RAM behavior only if no newer writer has won. */
|
||||
esp_err_t rollback_error =
|
||||
wifi_manager_compare_exchange_working_config(
|
||||
&previous, defaults_generation, NULL);
|
||||
if (rollback_error != ESP_OK &&
|
||||
rollback_error != WIFI_MANAGER_ERR_CONFIG_CONFLICT) {
|
||||
printf("Warning: could not restore the prior RAM configuration: %s\n",
|
||||
esp_err_to_name(rollback_error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+137
-19
@@ -94,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;
|
||||
@@ -130,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;
|
||||
@@ -1277,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;
|
||||
}
|
||||
|
||||
@@ -1290,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;
|
||||
}
|
||||
|
||||
@@ -1298,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);
|
||||
@@ -1401,30 +1423,51 @@ esp_err_t wifi_manager_init(const wifi_app_config_t *config)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config)
|
||||
esp_err_t wifi_manager_get_working_config_versioned(wifi_app_config_t *config,
|
||||
uint32_t *generation)
|
||||
{
|
||||
if (config == NULL) {
|
||||
if (config != NULL) {
|
||||
wifi_config_secure_wipe(config, sizeof(*config));
|
||||
}
|
||||
if (generation != NULL) {
|
||||
*generation = 0U;
|
||||
}
|
||||
if (config == NULL || generation == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
copy_working_config(config);
|
||||
lock_shared();
|
||||
*config = s_shared.config;
|
||||
*generation = s_shared.snapshot.config_generation;
|
||||
unlock_shared();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
|
||||
esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config)
|
||||
{
|
||||
esp_err_t error = wifi_config_validate(config);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
uint32_t generation = 0U;
|
||||
return wifi_manager_get_working_config_versioned(config, &generation);
|
||||
}
|
||||
|
||||
/* s_config_writer_mutex must be held by the caller. */
|
||||
static esp_err_t apply_working_config_serialized(
|
||||
const wifi_app_config_t *config, bool compare_generation,
|
||||
uint32_t expected_generation, uint32_t *resulting_generation)
|
||||
{
|
||||
lock_shared();
|
||||
if (compare_generation &&
|
||||
s_shared.snapshot.config_generation != expected_generation) {
|
||||
unlock_shared();
|
||||
return WIFI_MANAGER_ERR_CONFIG_CONFLICT;
|
||||
}
|
||||
if (s_mutex == NULL) {
|
||||
if (s_shared.snapshot.config_generation == UINT32_MAX) {
|
||||
unlock_shared();
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
lock_shared();
|
||||
bool restart_radio = config_requires_radio_restart(&s_shared.config, config);
|
||||
if (restart_radio) {
|
||||
manager_message_t message = {.type = MESSAGE_COMMAND_APPLY};
|
||||
@@ -1433,40 +1476,115 @@ esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
}
|
||||
|
||||
s_shared.config = *config;
|
||||
++s_shared.snapshot.config_generation;
|
||||
if (s_shared.snapshot.config_generation == 0U) {
|
||||
s_shared.snapshot.config_generation = 1U;
|
||||
}
|
||||
s_shared.snapshot.ap_policy = config->ap_policy;
|
||||
++s_shared.snapshot.counters.applies;
|
||||
if (resulting_generation != NULL) {
|
||||
*resulting_generation = s_shared.snapshot.config_generation;
|
||||
}
|
||||
unlock_shared();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
esp_err_t wifi_manager_compare_exchange_working_config(
|
||||
const wifi_app_config_t *config, uint32_t expected_generation,
|
||||
uint32_t *resulting_generation)
|
||||
{
|
||||
if (resulting_generation != NULL) {
|
||||
*resulting_generation = 0U;
|
||||
}
|
||||
if (expected_generation == 0U) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
esp_err_t error = wifi_config_validate(config);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
if (s_mutex == NULL || s_config_writer_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
lock_config_writer();
|
||||
error = apply_working_config_serialized(
|
||||
config, true, expected_generation, resulting_generation);
|
||||
unlock_config_writer();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_save_working_config_if_generation(
|
||||
uint32_t expected_generation)
|
||||
{
|
||||
if (expected_generation == 0U) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (expected_generation == UINT32_MAX) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (s_mutex == NULL || s_config_writer_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
wifi_app_config_t config = {0};
|
||||
lock_config_writer();
|
||||
lock_shared();
|
||||
if (s_shared.snapshot.config_generation != expected_generation) {
|
||||
unlock_shared();
|
||||
unlock_config_writer();
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
return WIFI_MANAGER_ERR_CONFIG_CONFLICT;
|
||||
}
|
||||
config = s_shared.config;
|
||||
unlock_shared();
|
||||
|
||||
esp_err_t error = wifi_config_save(&config);
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
unlock_config_writer();
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
|
||||
int enabled_at_boot)
|
||||
{
|
||||
if (s_mutex == NULL) {
|
||||
if (s_mutex == NULL || s_config_writer_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
bool writes_config = enabled_at_boot >= 0;
|
||||
if (writes_config) {
|
||||
lock_config_writer();
|
||||
}
|
||||
|
||||
manager_message_t message = {.type = type};
|
||||
lock_shared();
|
||||
bool changes_config =
|
||||
writes_config &&
|
||||
s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot;
|
||||
if (changes_config && s_shared.snapshot.config_generation == UINT32_MAX) {
|
||||
unlock_shared();
|
||||
if (writes_config) {
|
||||
unlock_config_writer();
|
||||
}
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (!enqueue_message(&message)) {
|
||||
unlock_shared();
|
||||
if (writes_config) {
|
||||
unlock_config_writer();
|
||||
}
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
if (enabled_at_boot >= 0 &&
|
||||
s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot) {
|
||||
if (changes_config) {
|
||||
s_shared.config.enabled_at_boot = (uint8_t)enabled_at_boot;
|
||||
++s_shared.snapshot.config_generation;
|
||||
if (s_shared.snapshot.config_generation == 0U) {
|
||||
s_shared.snapshot.config_generation = 1U;
|
||||
}
|
||||
}
|
||||
unlock_shared();
|
||||
if (writes_config) {
|
||||
unlock_config_writer();
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
+34
-5
@@ -72,6 +72,9 @@ typedef struct {
|
||||
wifi_manager_counters_t counters;
|
||||
} wifi_manager_snapshot_t;
|
||||
|
||||
/* A stale expected working-config generation maps cleanly to HTTP 409. */
|
||||
#define WIFI_MANAGER_ERR_CONFIG_CONFLICT ESP_ERR_INVALID_VERSION
|
||||
|
||||
/*
|
||||
* Initializes ESP-NETIF, the default event loop, both default Wi-Fi netifs,
|
||||
* Wi-Fi itself, and the permanent policy task. The manager never aborts the
|
||||
@@ -79,15 +82,41 @@ typedef struct {
|
||||
*/
|
||||
esp_err_t wifi_manager_init(const wifi_app_config_t *config);
|
||||
|
||||
/* Returns a copy of the RAM working configuration, including credentials. */
|
||||
/*
|
||||
* Returns a copy of the RAM working configuration, including credentials.
|
||||
* The caller owns the returned copy and must securely wipe it after use.
|
||||
*/
|
||||
esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config);
|
||||
|
||||
/*
|
||||
* Replaces the RAM working configuration. Disabled-profile-only edits do not
|
||||
* interrupt a running radio; changes to effective station/AP policy are
|
||||
* applied asynchronously by restarting with the newest generation.
|
||||
* Atomically copies the credential-bearing working configuration and the exact
|
||||
* nonzero generation that identified it. Both outputs are cleared on failure;
|
||||
* the caller must securely wipe config after every successful call.
|
||||
*/
|
||||
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config);
|
||||
esp_err_t wifi_manager_get_working_config_versioned(wifi_app_config_t *config,
|
||||
uint32_t *generation);
|
||||
|
||||
|
||||
/*
|
||||
* Atomically replace the complete validated working configuration only when
|
||||
* expected_generation still identifies the current configuration. A stale
|
||||
* expectation returns WIFI_MANAGER_ERR_CONFIG_CONFLICT without queueing a
|
||||
* restart or changing state. resulting_generation is optional and is set to
|
||||
* zero on failure. Generation exhaustion returns ESP_ERR_INVALID_STATE.
|
||||
*/
|
||||
esp_err_t wifi_manager_compare_exchange_working_config(
|
||||
const wifi_app_config_t *config, uint32_t expected_generation,
|
||||
uint32_t *resulting_generation);
|
||||
|
||||
/*
|
||||
* Persist exactly the working configuration identified by expected_generation.
|
||||
* All config writers are excluded through the NVS operation. A stale
|
||||
* expectation returns WIFI_MANAGER_ERR_CONFIG_CONFLICT. Generation exhaustion
|
||||
* returns ESP_ERR_INVALID_STATE. The saved generation is not incremented because
|
||||
* the RAM working configuration is unchanged.
|
||||
*/
|
||||
esp_err_t wifi_manager_save_working_config_if_generation(
|
||||
uint32_t expected_generation);
|
||||
|
||||
/* Lifecycle requests are asynchronous and serialized by the manager task. */
|
||||
esp_err_t wifi_manager_start(void);
|
||||
|
||||
Reference in New Issue
Block a user