Files
ESP32_Serial_Swiss_Army_Knife/docs/agent/architecture.md
T

15 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:

  1. Report PSRAM and initialize the sole secure DRBG before Wi-Fi or other radio use.
  2. Initialize boot-critical RGB LED state, RS-232 ownership/static-safe hardware, diagnostics, and the shared administration dispatcher resources.
  3. Attempt optional OLED initialization and a bounded boot animation. Display failure is nonfatal; a working display can delay later recovery services by about five seconds.
  4. Initialize button diagnostics and load local-UI and serial configurations, falling back to RAM defaults on load failure.
  5. Initialize the serial service, session broker, and permanent USB transport task. UART1 is not started automatically here.
  6. Load/generate HTTPS material, then initialize the user database using the legacy web credential for first migration when available. User-database failure makes network authentication fail closed.
  7. Initialize the HTTPS runtime, SSH host-key material, and permanent SSH owner task.
  8. Load Wi-Fi configuration, persist generated first-boot defaults when appropriate, initialize its manager, and start it when configured for boot.
  9. Start HTTPS and SSH only when their startup gates pass. Current code requires Wi-Fi and HTTPS security readiness for both; SSH additionally requires its own security/runtime readiness. The HTTPS-security gate on SSH is an implemented dependency even though SSH has a separate host key.
  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.

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.

Diagnostics and the production service must claim this owner before manipulating UART/GPIO state. Unsafe cleanup keeps the transceiver disabled and marks a fault rather than attempting continued operation.

Serial configuration is a working RAM value. Applying it while running performs a stop/restart and attempts rollback on failure; queued data may be discarded. Persistence is explicit through save/reset commands.

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.

Transport architecture

USB CDC

usb_cdc_transport has a permanent transport task and TinyUSB callbacks. Attached plus host DTR asserted is treated as open. Opening starts the serial service if necessary, creates the usb-cdc broker client, and opportunistically requests writer ownership; otherwise USB observes.

TinyUSB callbacks enqueue/copy data and state; the transport task owns broker lifecycle and forwarding. Supported host line coding can update the RAM serial configuration only while USB is writer, the serial service is running, and TX is empty. It is not automatically persisted.

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, not the legacy recovery credential. Both user and admin roles currently receive the same web status/terminal experience; web administration is not implemented.

A WebSocket connection requires a one-time, 30-second, principal-bound ticket. Ticket issuance and upgrade also validate a supplied Origin against https://<Host>; absence of Origin is accepted for non-browser clients. Tickets are stored as digests, consumed before currentness validation, and are never persisted. An admitted session starts the serial service if necessary, creates a broker client, and opportunistically requests writer ownership. The web transport has two fixed session slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the web transport task mediates broker work through bounded scheduling.

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. web_assets_data.c contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets.

SSH

ssh_transport uses wolfSSH on port 22 with two fixed session/handshake slots. One owner task pinned to core 1 is the only project task that calls wolfSSH APIs. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.

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.

There is no exec, SFTP, SCP, subsystem, agent forwarding, or TCP forwarding support.

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:

  1. command-layer account mutations explicitly request targeted WebSocket/SSH revocation;
  2. transports periodically and at sensitive boundaries recheck principal currentness, providing fail-safe closure if notification fails.

The final administrator cannot be deleted or demoted. UART0 is trusted for initial administrator bootstrap and explicit unavailable-database recovery. Authenticated admin SSH can run the operational registry but is denied those two recovery operations. The legacy web_sec username/password remains migration/recovery material after bootstrap and no longer authenticates HTTPS or SSH.

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. 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. 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.

Self-affecting remote actions such as reboot, stopping SSH, disconnecting sessions, or replacing the host key are deferred until acknowledgement output drains. UART0 invokes these synchronously. Completion candidates are manually maintained and can drift from command registration.

Wi-Fi and persistence

wifi_config owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy off, fallback, or always. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used.

wifi_manager is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP deadlines, profile failover, AP policy, retries/backoff, and next-profile requests. It also reconciles against authoritative driver/netif state so dropped events do not permanently wedge policy. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save.

Persistent namespaces/blobs include:

  • serial/config;
  • wifi_app/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 GPIO/UART/I2C assignments. local_display solely owns I2C0, the SSD1315-compatible OLED, its static framebuffer, and display mutex. Display frames belong to the initiating task. Dirty-page commits and I2C transactions are bounded.

local_status_ui is a permanent low-priority task that polls/debounces buttons, renders copied public snapshots, implements aging/wake behavior, and invokes a constrained set of public service APIs for local controls. It collects snapshots before opening a display frame, so service/broker locks are not held across I2C. It never parses CLI output, becomes a broker client, edits credentials, or assigns a writer; emergency action can only release the expected current writer.

A missing or failed OLED is nonfatal. A fresh button press can request one bounded reprobe. Long confirmation holds protect disruptive local actions, and stuck buttons are quarantined.

Hardware diagnostics are synchronous console commands. RS-232 tests own the physical port exclusively and restore safe GPIO state; OLED tests reuse the display service rather than taking independent I2C ownership.

Concurrency and lifecycle constraints

  • Broker, USB, web-transport, Wi-Fi, local-UI, and SSH owner tasks are firmware-lifetime tasks; stopping a service generally stops its runtime/listener, not the owner task.
  • Bounded queues, stream buffers, work bursts, and drop counters are part of slow-client and watchdog isolation.
  • Transport slot generations and account authentication generations solve different stale-reference problems; preserve both.
  • Library/hardware ownership is centralized: serial task owns UART1 while running, display service owns I2C/framebuffer, SSH owner task owns wolfSSH calls, and the console dispatcher owns esp_console_run().
  • Password authentication performs PBKDF2 outside the user-database mutex and revalidates afterward. Some password mutation paths currently derive verifiers while holding the mutation lock; do not generalize the authentication locking pattern without checking the exact path.
  • Avoid holding service/database/broker locks across I2C, network sends, or other potentially long operations unless the existing contract explicitly requires it.
  • Broker and selected cryptographic allocations prefer PSRAM but can fall back to internal RAM. FreeRTOS control structures and task stacks intentionally remain internal where flash/cache-disable safety matters.
  • 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.