21 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)
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 user database using the legacy web credential for first migration when available. 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. Both gates also require HTTPS security readiness, and SSH additionally requires its own security/runtime readiness. The HTTPS-security gate on SSH is an implemented dependency even though SSH has a separate host key.
- 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.
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.
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.
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.
Phase 8D.1 adds dormant web_session_store primitives alongside Basic auth: four static records with token/origin digests, copied principal, separate CSRF state, one-hour absolute expiry and non-reused 64-bit session IDs. No HTTP handler uses these sessions yet. 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. Store-init failure cannot fail existing Basic HTTPS. Session/user invalidation APIs do not yet close transports or receive account-mutation notifications (8D.2); HTTP Origin/CSRF enforcement is not implemented by these storage primitives. Sensitive views must be wiped by callers; snapshots contain only counts and storage sizes. Focused host checks live in tests/web_session_store/.
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.
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. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. web_assets_data.c contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets.
SSH
ssh_transport uses wolfSSH on port 22 with two fixed session/handshake slots. 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 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.
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 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.
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.
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.
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.