Reuse the existing settings view and typed controllers for accessible quick editing while keeping network credentials out of quick mode. Expand browser and layout coverage for focus, dismissal, bounds, and expiry.
48 KiB
Architecture
Purpose and system shape
This ESP32-S3 firmware exposes one MAX3243-backed UART1 RS-232 port through three bounded transport families:
- native USB CDC-ACM, which is local and unauthenticated;
- authenticated HTTPS with a browser WebSocket terminal;
- authenticated SSH.
UART0 remains a separate trusted administration and recovery console. A local OLED and three buttons provide status and a deliberately limited control surface. Persistent application configuration and security material are stored in versioned NVS blobs.
The central data-path invariant is one serial writer with multiple observers:
USB CDC ---------\
WebSocket --------> session broker <--> serial service <--> UART1 <--> MAX3243
SSH role=user ---/ |
+-- one writer lease
+-- bounded output per observer
SSH role=admin ------> shared administration dispatcher <------ UART0
(does not join the broker)
Typed Network settings (8D.12/8D.13)
web_network_settings provides admin-only secret-free GET snapshot and GET/POST operation routes; web_ui.c adds Network without changing broker/terminal ownership. Wi-Fi working/runtime projection is zero-wait under one mutex; mDNS is a separate consistent projection. SSIDs use reversible byte JSON, with explicit UTF-8-text/hex UI conversion; passwords are never returned/prefilled, only password_configured. Omitted passwords preserve current secrets; explicit replacement and disabled-STA clear are distinct, AP clear is always denied.
Only an ID enters the existing administration dispatcher. Its session/deadline revalidation precedes canonical generation-checked mutations; wifi_manager remains radio and reannouncement owner. Wi-Fi generation compare/merge/validation and queue-before-publication occur under its mutex; Save holds selected bytes stable, Load is stored-only without default-secret generation. mDNS independently checks generation for Set/Save/Load/Defaults and queues reannouncement; changed RAM with queue failure is explicitly reported, not rolled back. Edits require explicit Save. Next profile follows canonical enabled-priority order, not the profile editor's selected index.
One static login-bound pending/result slot and one firmware-lifetime one-second timer bound queued secret retention to 30 seconds plus scheduling latency. Dequeued locals wipe on return; admitted work is not hard-cancelled by logout/deadline. accepted means apply/owner admission, not online/DNS completion. Response delivery before disruption is not guaranteed; recovery is STA/AP inspection plus independent UART0 administration/native USB UART1 access, never automatic mutation replay. UI navigation preserves serial traffic/lease; actual network loss can disconnect network clients.
Bounds: 768-byte/four-receive request, 2,048-byte snapshot, 128-byte result; 27 handlers/six sockets, no task/stack-size/dispatcher-item/queue-depth/schema growth. Optional staged Network registration failures preserve unrelated routes; timer failure denies mutation without gating snapshot reads. Timer heap and target HTTPD/dispatcher stack/memory floors remain unmeasured. Full fields, registration rollback, states, UI and validation limits: docs/phase8d12_13_implementation.md. Browser-shell restrictions remain unchanged.
Startup and initialization
app_main() in src/main.c is the composition root. The implemented order matters:
- Report PSRAM and initialize the sole project-owned application DRBG before Wi-Fi or other radio use.
- Initialize boot-critical RGB LED state, RS-232 ownership/static-safe hardware, diagnostics, and the shared administration dispatcher resources. RGB LED initialization is currently guarded by
ESP_ERROR_CHECKand is therefore boot-fatal. - Attempt optional OLED initialization and a bounded boot animation. Display failure is nonfatal; a working display can delay later recovery services by about five seconds.
- Initialize button diagnostics and load local-UI and serial configurations, falling back to RAM defaults on load failure.
- Initialize the serial service, session broker, and permanent USB transport task. UART1 is not started automatically here.
- Load/generate HTTPS material, then initialize the independent user database, committing an empty database when storage is missing. User-database failure makes network authentication fail closed.
- Initialize the HTTPS runtime, SSH host-key material, and permanent SSH owner task.
- Load Wi-Fi configuration and the independent mDNS hostname configuration, persist generated first-boot Wi-Fi defaults when appropriate, initialize the nonfatal mDNS configuration service and Wi-Fi manager, and start Wi-Fi when configured for boot. The Wi-Fi manager owns subsequent mDNS announcement transitions.
- Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. HTTPS additionally requires its own security/runtime readiness; SSH independently requires its own security/runtime readiness, not HTTPS identity readiness. This reflects
main.cafter accepted legacy-credential cleanup. - Start the local status/control task if button initialization succeeded.
- 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.
Serial service and physical ownership
serial_service owns the UART1 driver while running. It exposes bounded RX/TX streams and a task that:
- continuously drains UART RX, even if UART event notification is incomplete;
- moves pending TX to the UART FIFO without blocking indefinitely on CTS;
- discards and accounts queued traffic during shutdown/reconfiguration.
rs232_port_owner separately protects the physical UART/MAX3243 resource:
NONE: available;PHASE0: hardware diagnostics own it;SERVICE: serial service owns it;FAULT: cleanup could not establish a safe state; reboot is required.
The owner is cooperative rather than an interceptor for UART/GPIO APIs. Active diagnostic commands claim PHASE0, and the running service claims SERVICE; boot-time static-safe GPIO initialization and service-owned restoration of that static mode are explicit exceptions. Unsafe cleanup keeps the transceiver disabled and marks a fault rather than attempting continued operation.
Serial configuration is a working RAM value. Applying it while running performs a stop/restart and attempts rollback on failure. Stop/reconfiguration discards and accounts serial-service RX/TX streams and task-local pending TX, but does not disconnect broker clients or clear their writer lease, events, or already-fanned output. An open USB session retries service start after a stop; existing WebSocket and role-user SSH sessions do not independently restart it. Persistence is explicit through save/reset commands.
Session broker and data flow
The permanent session_broker task is the intended sole consumer/producer of serial-service data.
RS-232 to clients
UART RX -> serial-service RX stream -> broker task
-> independent bounded output stream for every connected client
-> USB / WebSocket / SSH transport output
The broker drains serial input even with no clients. A full client output stream drops only that client's copy and updates drop counters; it does not block UART reception or other clients.
Binary serial WebSocket output uses the IDF-5.5.0-pinned web_httpd_ws_send_binary adapter: one owner-only session-override send of a bounded header+payload copy (516 bytes of local scratch, 512-byte payload). Existing generation validation and one outstanding work item per slot remain required. Non-full sends immediately replace that session's send override with a reject-only guard, mark it closing and shut down the socket before deferred cleanup; this prevents automatic SDK control replies from reentering TLS after incomplete output. HTTPD retains TLS destruction ownership. Text/control and admin output retain the SDK sender. One send call does not imply one TLS record, packet or peer receipt; the user signed off drop-free230400-baud full-client-mix operation at160MHz. Runtime stack-margin validation remains a separate follow-up; evidence and acceptance scope are in current-state.md.
Active-client counter snapshots expose ID/type/pending/HWM/UART/queued/read/dropped. HWM is maintained independently of web tracing and counter clear seeds current occupancy. Read means transport handoff, not peer receipt. Disconnect removes the client row while global totals retain traffic and unread-output discards. Independent default-disabled web performance capture uses two fixed slot records and nonwrapping epoch/generation fences; toggles fence in-flight samples, disable freezes aggregates, clear preserves enable state. Binary-only timestamps bound reservation-entry to callback-entry before locking, synchronous send calls and completion to broker-read return; idle and intervening work prevent scheduler-only/backlog-at-completion claims. No new instrumentation allocations or scheduling/buffer changes. Exact fields, overhead and capture contracts: docs/web_throughput_diagnostics.md.
Clients to RS-232
transport input -> broker write check -> serial-service TX stream -> UART TX
Only the generation-safe client ID holding the current writer lease may enqueue input. All connected clients, including the writer, observe UART output. Normal requests acquire the lease only when free; disconnect releases it. Administrative APIs can force reassignment or compare-and-release an expected writer.
Broker events are advisory bounded notifications. Transports reconcile against authoritative snapshots because an event queue can overflow. DTR_ON_CONNECT follows whether any broker client is connected, not writer ownership.
The broker currently enters nonblocking serial read/write APIs while holding its mutex and takes the serial-service state mutex during first-connect/last-disconnect DTR changes. Keep this ordering acyclic: serial-service code must not call broker APIs while holding its state mutex.
Transport architecture
USB CDC
usb_cdc_transport has a permanent transport task and TinyUSB callbacks. Attached plus host DTR asserted is treated as open. Opening starts the serial service if necessary, creates the usb-cdc broker client, and opportunistically requests writer ownership; otherwise USB observes.
TinyUSB callbacks enqueue/copy data and state; the transport task owns broker lifecycle and forwarding. The line-coding callback records the latest host setting for diagnostics only. It never reconfigures UART1: physical framing and speed remain controlled by the explicit serial configuration, regardless of USB writer ownership.
HTTPS, WebSocket, and web serial
web_server runs HTTPS only on port 443 using the device-specific self-signed P-256 certificate from web_security. Current routes provide the UI, static assets, status, ticket issuance, and serial WebSocket upgrade.
HTTPS login uses user_database and opaque server-side cookie sessions; Basic authentication and its cache are removed in 8D.3. No legacy credential is imported, synchronized or consulted for authentication. Both roles retain the same shipped web status/serial UI. 8D.5 adds an admin-only backend without a normal UI entry.
web_cookie_auth owns login/session/logout policy: four 120-second digest-only pre-login challenges, explicit same-origin bootstrap, five credential verifications per 60-second global window, and no live-record eviction. Host-only __Host- Secure/HttpOnly/SameSite=Strict cookies have absolute lifetimes. Login consumes a challenge, validates bounded JSON and issues a fresh session; logout invalidates only its originating session. Mutations require CSRF and strict canonical HTTPS Origin; serial/admin upgrades require matching cookie/Origin/ticket, with admin role additionally required by the admin endpoints.
web_session_store holds four static records with token/origin digests, copied principal, separate CSRF state, one-hour absolute expiry and non-reused 64-bit session IDs. These are live cookie sessions in 8D.3, with no sliding renewal. A portMUX protects short state copies/mutations; database/RNG/SHA calls occur outside it. Resolution rechecks ID/expiry after database validation; issuance also checks an invalidation epoch. Stop wipes records without resetting IDs/epochs. Only admitted HTTPS starts initialize the store; failed starts and accepted stops disable it before cleanup. Authentication/store-init failure now gates HTTPS startup rather than falling back to Basic. Sensitive views must be wiped by callers; snapshots contain only counts and storage sizes. Focused host checks live in tests/web_session_store/.
Serial tickets/slots bind to distinct originating web-session IDs; 8D.3 rejects zero instead of treating it as Basic. Trusted internal mint/upgrade callers supply the ID; bound checks also compare the session's copied principal, with no CSRF export. Mint/consume/admission/input and existing 250 ms owner checks validate session liveness/currentness. Session-specific transport revocation invalidates the store first, then clears matching tickets and flags matching reserved/active slots for existing HTTPD/broker cleanup. Account/global transport revocation now invalidates cookie records even if serial initialization failed; existing console mutation callers reach these hooks unchanged. A non-wrapping transport epoch cancels in-flight ticket publication across revocation and server detach/re-attach. Store/database checks remain authoritative if notification is missed. 8D.3 activates these checks for all browser routes, with five added authentication handlers (14 total), unchanged six HTTPS sockets and no new task.
A WebSocket connection requires a one-time, principal-bound ticket with a maximum 30-second lifetime. Only four tickets can be outstanding; expired/stale identities are reclaimed and live capacity is rejected with 503/Retry-After, not eviction. Ticket issuance and upgrade require Origin matching validated Host after host-case/default-port normalization; missing Origin fails even for non-browser clients. Tickets are stored as digests, consumed before currentness validation, and are never persisted. An admitted session starts the serial service if necessary, creates a broker client, and opportunistically requests writer ownership. The web transport has two fixed session slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the web transport task mediates broker work through bounded scheduling. The browser's combined Connect/Disconnect control closes the WebSocket and pauses automatic reconnect; after a user-paused disconnect it changes to Connect, which resumes connection attempts.
web_httpd_adapter is the sole private ESP-IDF 5.5.0 boundary. Its compile-time version guard requires review on upgrades. It validates NUL-separated parsed headers because public getters return only the first field, and rejects duplicates/ambiguous framing. The serial URI is registered as ordinary HTTP GET so cookie/ticket/principal/broker admission precedes explicit 101 and frame-handler installation; automatic IDF WebSocket routing would send 101 too early. Cleanup wipes consumed scratch but preserves right-aligned unread pending data. CMake compiles HTTPD logs above ERROR out to prevent header/ticket logging. No SDK patch or component copy exists. See docs/phase8d3_implementation.md for source verification, tests and pending on-wire checks.
web_diagnostics observes public synchronous HTTPS create/close callbacks without replacing socket/transport cleanup, and wraps only the four serial/admin ticket/upgrade handlers. Six always-maintained post-TLS metadata records supply a console-safe occupancy snapshot without querying HTTPD off-owner; an opt-in 32-entry numeric ring adds timing/heap/HTTPD stack samples. Firmware-lifetime connection sequences and capture epochs survive clear/restart and fence fd reuse/toggle races. No tasks/probes/event subscriptions; no authentication/request data retained. UART0/admin SSH commands never wait for HTTPD; browser policy remains unchanged. This is successful-TLS occupancy, not preaccept or failed/in-progress handshake instrumentation; exact limits/overhead in docs/phase8d11_implementation.md.
Ordinary HTTPS idle retention is independently enforced by web_httpd_idle: one persistent one-second ESP timer, at most one generation-qualified HTTPD work reservation and six owner-only rows. The private adapter observes IDF's all-route successful req_new/req_delete completion marker, checks actual WS/async flags and pending/readable input, and shuts down only the current expired ordinary fd after 15 seconds of observed idle. TLS-create resets reused-fd observations before diagnostic publication. No TLS cleanup override, LRU eviction, in-progress request interruption or diagnostics dependency. Stop fences submissions before destroying HTTPD; only successful stop retires queued state. Slow owner work and accepted-but-lost nonblocking UDP work preclude a hard wall-clock guarantee; loss stays bounded until successful restart. Timer preparation failure gates HTTPS start. SDK audit, tests and exact limits: docs/https_idle_cleanup.md.
Web serial initialization is failure-isolated from the base HTTPS service: if the transport cannot initialize, web_server_init() can still succeed and serve authenticated non-WebSocket routes.
web_ui.c contains authored index/application strings and response policy; it validates /api/session before connect/restore, adds explicit Sign out, and cancels stale work on 401/logout/page exit. web_login_ui is a standalone public page without protected-asset dependencies. Both authentication documents and app script are no-store. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. web_assets_data.c contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets.
Browser admin backend
8D.17 adds UI-only Serial/Wi-Fi quick presentation in web_ui.c: one existing settings DOM host and the same validators/request controllers, never parallel quick/full editors. Quick mode logically selects Settings (terminal input disabled) while showing the previous receiving terminal behind a bounded nonmodal panel. Promotion retains the single draft/controller; dismissal clears drafts/fences reads and automatic checks, retains pending uncertainty and never replays a mutation. Full-page hover is non-destructive; explicit activation navigates the full page instead. Network quick mode hides/disables password controls and clears transient secrets, with only existing secret-free snapshots. No backend/transport responsibility changes. Contracts and validation limits: docs/phase8d17_implementation.md.
8D.8 adds an in-document admin-only Settings/Serial view and optional GET /api/settings/serial. It requires current cookie/principal/admin authorization, rejects bodies/queries and inherits ordinary-GET Origin/no-store policy. Eight working config/running fields fit a 256-byte response; serial_service_get_snapshot() takes the existing state mutex with zero wait, releasing it before encoding/send and returning unavailable on contention. No mutation, NVS, broker or socket-lifecycle operation occurs. Both hidden terminals continue draining; Settings input is disabled, refresh is explicit/single-flight with session identity checks that cannot supersede serial admission, and cancellation clears/fences the settings view. URI budget is now 17, sockets remain six/no LRU. The private adapter's startup-only exact-GET registration stages descriptor/name allocation before table publication, unlike installed IDF's public failure path; HTTPD retains normal free ownership. Only Settings uses that helper; existing registration callers remain unchanged. See docs/phase8d8_implementation.md for bounds, tests and target-pending evidence.
The 8D.6 document binds retained terminal state to its first validated username/role/session-stable CSRF tuple. Every later session adoption must match, otherwise both terminal hosts are hidden, both sockets/work are closed/fenced and a clean / document is required. Pagehide hides scrollback until same-session revalidation; no clearing is needed for unchanged-session restore or mode changes. Terminal-fit readiness uses successful-bounds caching and at most three generation-fenced animation-frame retries per external request, never unbounded polling.
8D.6 web_ui.c now supplies the admin-only Serial/Admin selector using this backend. Selection leaves serial and any open admin socket connected and draining; only focus, displayed terminal and keyboard destination change. Broker identity/lease and writer controls remain serial-owned in both views. Admin opens/reopens explicitly and closes independently. Two page-lifetime terminals have separate 5,000-line scrollbacks and 64 KiB callback-accounted pending output each; browser overflow is dropped with visible counts. Admin input is bounded to 4 KiB admission and 512-byte frames. Logout/expiry/page exit closes both with generation fencing and socket-listener cleanup; bfcache revalidates serial/session but never automatically reopens admin. No server policy/capacity changes or 8D.7 lifecycle parity. See docs/phase8d6_implementation.md; the following paragraph describes the original backend boundary, before its UI entry was added.
8D.5 additionally supplies web_admin_transport and web_admin_tickets: one optional admin socket, two 30-second digest-only tickets bound to current originating session/principal, the same two shared console slots, no serial broker client. Ticket POST requires cookie/Origin/CSRF/admin; ordinary GET upgrade requires cookie/Origin/admin/ticket and console admission before 101. Six total HTTPS sockets remain, LRU purge is disabled, and two routes bring the handler budget to 16. Optional admin registration/PSRAM allocation failures do not take down M1. A 20 ms ESP timer queues at most one HTTPD poll, with no new task; only HTTPD accesses the 1,552-byte PSRAM-only RX/TX payload or socket IO. Notifiers close the generation-qualified console and flag the socket. HTTPD shuts down the verified current fd directly and owns subsequent read cleanup, avoiding IDF's queued reusable sock_db * close race. Detach fences submissions; failed stop retains ownership, and queued state is retired only after successful HTTPD stop. Console dispatcher/prompt and owner input/output/idle checks enforce session and principal currentness. WEB supports deferred self-close only; parsed canonical policy denies unsupported lifecycle/network/account mutations before handler side effects. No normal UI entry, typed settings or lifecycle parity is included. See docs/phase8d5_implementation.md for validation limits and exact restrictions.
SSH
ssh_transport uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls wolfSSH_Init() in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by ssh_security.
Routing follows the authenticated role:
user: start the serial service if necessary, then create a broker-backed binary-transparent serial stream and opportunistically request writer ownership;admin: bounded administration console, with no broker client or writer lease.
A shell request is required, but project code does not explicitly require a PTY. Exec and subsystem requests are rejected, and there is no project SFTP, SCP, agent-forwarding, or TCP-forwarding route.
Authentication, authorization, and revocation
user_database is a fixed-capacity, mutex-protected store: at most eight accounts and three authorized keys per account. Accounts have user or admin role, random account ID, and authentication generation. Passwords are salted PBKDF2-HMAC-SHA256 verifiers; plaintext passwords are not retained in the database.
Network code holds copied, secret-free principals rather than pointers into database records. Principal currentness requires matching username, account ID, role, and authentication generation. Password, role, or key changes increment the generation; deletion/recreation also changes the account ID.
Revocation has two layers:
- after a database mutation commits, the command layer makes best-effort targeted WebSocket/SSH revocation calls; notification failure does not roll back the mutation;
- transports periodically and at sensitive boundaries recheck principal currentness, providing authoritative fail-safe closure if notification fails.
The final administrator cannot be deleted or demoted. UART0 establishes the first administrator through normal user add <username> admin and owns explicit unavailable-database recovery to empty. Recovery refuses a healthy database. No bootstrap API or command remains. Authenticated admin SSH can run the operational registry but is denied recovery; other secret-bearing commands are remotely available unless their handlers deny them.
NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials and TLS/SSH private keys remain recoverable under physical flash extraction.
Typed Accounts settings (8D.10)
8D.11 extension: Authorized-key operations share the Accounts slot/dispatcher and canonical database key transactions with mutation-lock account ID/generation checks. A zero-wait per-account projection returns only key slot/type/SHA256 fingerprint. Independently optional admin/Origin/CSRF JSON POST /api/settings/accounts/keys reads this projection; existing operation POST admits bounded public-key text or key-delete/key-clear. Three stable slots may be sparse. Successful mutations target-revoke, including self; uncertain acknowledgement never triggers automatic replay. 24 handlers, unchanged socket/task/stack-size/queue budgets. Runtime stack margins remain pending. See docs/phase8d11_implementation.md; older no-8D.11 statements below are historical.
Current slice 2 extends the same dispatcher slot to create/password and self role/delete/password. Mutation bodies are 768 bytes/four receives; results stay secret-free, 96 bytes, replaceable and session-bound. Conditional password mutation checks identity under the canonical database mutex. A one-second firmware-lifetime ESP timer cancels/wipes non-executing queued credentials at their 30-second deadline plus timer/scheduling latency; dequeue wipes shared inputs after copying, and dispatcher-local credentials persist until admitted work returns. This is not a hard execution/erasure deadline. Separate bodyless admin/Origin/CSRF POST /api/settings/accounts/generate-password returns a 24-character value before any commit, without retained retrieval. UI generation has a 60-second best-effort lifetime and context-bound saved acknowledgement before separate submission; JavaScript cannot securely wipe strings. Self revocation may prevent final response/result access; disconnect/401 proves neither success nor cancellation. Browser-shell restrictions and UART0 first-admin provisioning/recovery remain unchanged. The generated endpoint is independently optionally registered, with failure isolation/restart coverage and 23 handlers. Implementation is complete, host-tested/build-verified; target validation/signoff remains pending. Parent build: 25.61 s, 95,908 B RAM / 1,694,237 B flash; timer runtime costs and stack/heap margins remain unmeasured. No task/stack/queue depth/socket expansion or 8D.11 work. Current contracts and attributed host evidence: docs/phase8d10_implementation.md.
Historical slice 1 architecture (superseded scope/counts, retained evidence):
web_account_settings supplies an optional admin-only compact account list and one session-bound role/delete operation/result slot, separate from Serial's slot but executed on the same dispatcher queue. HTTPD authorizes/parses/queues; the dispatcher revalidates the initiating login/admin and 30-second dequeue deadline, then calls conditional database mutations and best-effort target web/SSH revocation after success. user_database_get_accounts() copies at most eight username/role/ID/auth-generation records under the existing mutex with zero wait and no key/password fields. user_database_delete_current() and user_database_set_role_current() compare target identity under the mutation lock and share canonical CLI commit/invariant logic; stale selection never intentionally mutates a replacement account. Results are replaceable, not durable/idempotent, and already-admitted work can complete after logout. Self-target, create/password/generated-secret workflows remain unavailable in this slice; first-admin provisioning/recovery remain UART0-only. Accounts UI confirms mutations, retains visible stale lists/outcomes during bounded auto-check/refresh and fences navigation/session changes without changing broker ownership. Three optional routes bring HTTPD handlers to 22; six sockets/no LRU and existing tasks/stacks/queue depth remain. See docs/phase8d10_implementation.md for limits and pending target checks.
Typed Serial settings (8D.9)
web_serial_settings admits strict bounded admin cookie/Origin/CSRF JSON into one static session-bound operation/result slot, queuing only a non-reused ID on the existing administration dispatcher. HTTPD never runs serial/NVS mutations. The dispatcher checks session/principal currentness and a 30-second dequeue deadline before canonical Apply/Start/Stop/Save/Load/Defaults/Reset APIs; already admitted work may complete after logout. A blocked dispatcher retains the slot, not a timed job cancellation. Results are login-isolated and replaceable after completion; no durable history/idempotent retry guarantee. After acknowledgement the UI checks at one-second intervals, bounded to 10 GET attempts and a 15-second overall deadline including session checks, then automatically refreshes working values for known terminal outcomes. Errors/exhaustion use manual recovery; no automatic mutation retries or navigation resumption. Settings remain visible with stale/pending labels; only Reset confirms saved-NVS overwrite. Selecting the current view is a no-op. Settings UI retains uncertain-result warnings, explicit RAM/NVS/discard explanations and both terminal sockets/lease. Two optional exact GET/POST registrations bring the URI budget to 19, with six sockets and unchanged tasks/stacks/queue depth. /api/status also uses the zero-wait serial snapshot and emits running:null when unavailable. See docs/phase8d9_implementation.md for resource and target-pending evidence.
Console architecture
UART0 and admin SSH share canonical command implementations:
UART0 linenoise frontend --\
> fixed request queue -> one dispatcher -> esp_console_run()
admin SSH 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.
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 8D.4/8D.5 boundary retains admin_ssh_console_open_owned() and adds available-slot admission for runtime SSH/browser owners: copied transport-qualified slot/session/generation identity plus a firmware-lifetime immutable owner adapter. The existing two console slots are shared, not multiplied per frontend; active/executing slots cannot be replaced. Owners serialize per-session input, consume output and enforce transport liveness; completion scratch is claimed nonblockingly across owners. The existing control task calls drain/lifecycle adapters outside console locks. SSH uses generation-checked published snapshots, principal copies and its assigned console index, never wolfSSH from the control task. SELF_CLOSE is owner-relative; legacy SSH actions remain SSH-specific and unsupported owner actions are rejected. Dispatcher-side owner is_current checks run outside console locks, with full identity recheck after validation. Commands revalidate immediately before the runner; prompts revalidate before publication and after waits (250 ms polling plus check/scheduling latency), rejecting revoked submitted input and stale wakes. SSH preserves close intent through external-close consumption. Consumed output is wiped. These checks do not cancel arbitrary executing handlers or replace owner-side input/output and lifecycle validation.
For SSH, standard output/error is redirected to the invoking session's bounded output ring. console_input routes visible or hidden prompts to UART0 or the active SSH session. exit and Ctrl+D on an empty admin SSH line use bounded deferred self-disconnect after their acknowledgement drains; role-user SSH remains a binary-transparent serial stream. Session tokens include slot and generation so late queued work cannot attach to a reused SSH slot. Only the SSH owner task moves ring output through wolfSSH.
Admin SSH exit, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
The first 8D.7 slice enables browser-admin reboot and HTTPS stop through the same control task. web stop is deferred only for browser origin; UART0/admin SSH keep their synchronous HTTPS-stop path. WEB performs authoritative cookie/principal/token validation after drain and delay, then calls lifecycle APIs outside console locks, never socket IO. Console snapshots expose pending deferral; HTTPD discards buffered/new input observed during it and latches each frame's discard decision across payload reception/cancellation. HTTPS stop intentionally closes both browser routes.
The second 8D.7 slice additionally permits exact parsed browser web certificate rotate --force. The request queue has a typed command-line/deferred-action union with unchanged capacity. An immutable owner dispatcher_actions mask sends certificate work, after the bounded drain and 200 ms delay, nonblockingly to the existing 12 KiB dispatcher rather than the 4 KiB control stack; zero mask retains SSH control-task behavior. Pending input remains gated through queueing/execution. Dispatcher token/principal/session/owner revalidation and an executing-slot reservation prevent stale execution or reuse during self-detach; WEB validates currentness again before lifecycle APIs. Transactional certificate generation/persistence commits before stop → start; generation/commit error skips lifecycle calls, stop error skips start and retains HTTPD ownership, and later lifecycle failure does not roll back committed material. HTTPD alone owns socket IO. Successful restart invalidates browser sessions and both routes; certificate trust and login must be renewed, while USB/UART0/SSH remain available. Account/legacy-credential/network/restricted SSH mutations remain blocked. No new tasks, depths, routes, assets or stack sizes. Drain/acknowledgement bounds are not execution deadlines or delivery guarantees; owner-mask/local-scratch target layout and control/dispatcher stack margins remain unmeasured (host sizeof is not target proof).
The third 8D.7 slice permits browser other-account interactive add/password and forced delete/role mutations, with shared parsed policy at dispatcher admission and canonical-handler defense. Self-target, generated-secret, key, bootstrap and recovery workflows remain blocked. Account/owner/session/token currentness is checked after password prompts and before database API operation admission. This is operation-admission currentness, not an atomic liveness/NVS-commit guarantee: an admitted derivation/mutation may finish and target-revoke after disconnect or expiry; subsequent stale operations reject. Reconnecting administrators must inspect uncertain account outcomes rather than assume cancellation. Existing transaction cleanup, account invariants and best-effort targeted notifications remain unchanged.
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.
Persistent namespaces/blobs include:
serial/config;wifi_app/config;mdns_cfg/config;local_ui/config;web_sec/material;user_db/database;ssh_sec/material.
Configuration modules generally choose RAM defaults without erasing incompatible storage. Security-material modules fail closed on malformed existing material and require explicit reset. OTA slots, coredump space, an NVS-key partition, and storage are reserved in partitions.csv; OTA, NVS encryption, coredump handling, and filesystem mounting are not implemented.
Typed Display settings (8D.14)
web_display_settings adds admin-only bodyless GET snapshot and GET/POST operation routes. HTTPD validates bounded typed JSON and queues only an ID on the existing administration dispatcher. One login-bound slot and non-reused operation IDs follow Serial's30-second dequeue admission deadline, without a new timer; already-admitted operations may finish after logout. Results are replaceable, not durable or idempotent. The UI preserves both terminals/lease, shows Serial-style labels/values, validates timeout limits, explicitly distinguishes RAM/drafts/NVS and uses at most10 one-second result GETs/15seconds overall, then manual recovery without mutation replay.
local_status_ui provides a zero-wait RAM/generation projection and conditional public update API. A short critical-section reservation serializes every config writer, including legacy Apply and CLI, across NVS IO performed outside the critical section. Stale nonzero generation rejects before mutation; successful RAM publication advances a nonwrapping generation and activity. Save stabilizes selected working bytes, not browser drafts. Load keeps canonical missing/incompatible-storage defaults without rewriting NVS. Reset now commits defaults before publishing RAM (CLI too); failure leaves RAM unchanged without rollback. Buttons/diagnostic holds affect independent activity, not configuration generation; renderer reads its previous consistent RAM copy during storage work. Absent panel does not prevent config when UI task is available; no panel/I2C IO is called by settings.
Bounds:256-byte/four-receive request,128-byte snapshot,96-byte result, one slot;30 URI handlers/six sockets, no new task/timer/stack-size/queue/schema expansion. Optional staged allocation failures retain other routes. Host tests/build pass; target and memory/stack margins pending. Full contract, resource evidence and checklist: docs/phase8d14_implementation.md.
Typed Broker management (8D.16)
web_broker_settings provides admin-only bodyless GET management snapshot and GET/POST assignment/result routes, using Display-style bounded parsing, one login-isolated result slot and the existing typed dispatcher. Only a numeric ID is queued. Original principal/session and30-second dequeue deadline are checked before owner admission; already-admitted work can finish after logout. Optional staged registration preserves other services. Bounds:256-byte/four-receive request,2048-byte snapshot,96-byte result;33 handlers/six sockets, no new timer/task/queue/stack-size/assets changes.
The broker copies compact client rows and writer/lease generation together under its existing mutex with zero wait. Conditional assignment compares generation and connected non-reused target under the same force-writer lock before any effect. Lease grant/release/revoke events advance a separate saturating32-bit generation before delivery attempts, including same-writer ABA; counter clears and dropped events cannot invalidate this fence. UINT32_MAX disables conditional assignment, not ordinary transport/console recovery. Client IDs retain three slot/29 generation bits, but exhausted slots now retire until reboot rather than reusing IDs. Reboot invalidates originating web sessions. Existing unconditional force APIs and one-writer/multiple-observer policy remain unchanged.
Browser Settings/Broker shows safe ID/type/name/role/pending/HWM/drop rows and clears selection on Refresh. Native confirmation captures exact target/generation before session revalidation. Viewing/selecting never mutates; completion checks are bounded to10 one-second GETs/15seconds, no mutation replay. There are no contextual popovers or diagnostic/service controls. Complete contracts, wrap analysis, evidence and pending parent/target review: docs/phase8d16_implementation.md.
Local UI and hardware boundaries
board_pins.h centralizes project-assigned RS-232, diagnostic, RGB LED, and local-UI hardware resources; UART0 GPIOs remain local to main.c, and native USB uses platform wiring. local_display solely owns I2C0, the SSD1315-compatible OLED, its static framebuffer, and display mutex. Display frames belong to the initiating task. Dirty-page commits and I2C transactions are bounded.
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.
Hardware diagnostics are synchronous console commands. RS-232 tests own the physical port exclusively and restore safe GPIO state; OLED tests reuse the display service rather than taking independent I2C ownership.
Concurrency and lifecycle constraints
- Broker, USB, web-transport, Wi-Fi, and SSH owner tasks are firmware-lifetime tasks; the local-UI task is also firmware-lifetime when button initialization allowed it to start. Stopping a service generally stops its runtime/listener, not the owner task.
- Bounded queues, stream buffers, work bursts, and drop counters are part of slow-client and watchdog isolation.
- Transport slot generations and account authentication generations solve different stale-reference problems; preserve both.
- Library/hardware ownership is centralized: serial task owns UART1 while running, display service owns I2C/framebuffer, 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.
- Avoid holding service/database/broker locks across I2C, network sends, or other potentially long operations unless the existing contract explicitly requires it. Preserve the existing broker-before-serial lock order.
- Serial RX/TX stream payloads, broker per-client payloads, the transactional user-database candidate, and selected cryptographic allocations prefer PSRAM with internal fallback. The live user database, FreeRTOS control structures, UART driver buffers, and task stacks remain internal where deterministic/cache-disable access matters.
- The build disables wolfSSL ESP32 AES/SHA acceleration, and the HTTPS path uses software AES for PSRAM-backed records. This preserves the validated workaround for uncoordinated mbedTLS/wolfSSL hardware-crypto locks and a prior mbedTLS external-RAM DMA watchdog stall.
Legacy credential removal storage boundary
user_database_init(load_result) has no credential input. Missing storage is persisted empty; user_database_recover_empty() is the unavailable-only destructive recovery API. Valid v1 user bytes load without rewriting or account changes. The private v1_admin_marker retains its byte position and is derived from administrator count during mutations; it is not a public bootstrap state, new role or schema change. No user migration/bootstrap/synchronization API remains.
web_security owns TLS only. A private reader validates 1,392-byte v1 web_sec/material, copies exact key/certificate DER, fingerprint and generation into 1,340-byte v2, commits, then publishes. Temporary v1 credential-bearing input is wiped; no public legacy credential type/getter/rotation remains. Malformed/unknown records and read/validation/commit failures fail closed, with no fallback regeneration or overwrite of rejected records. Missing material may be generated; explicit reset replaces TLS only. Downgrade to v1-only firmware is incompatible. Logical NVS replacement is not secure flash erasure. Contracts/evidence: docs/legacy_credential_removal.md.