Add Project Guidance For Agents

This commit is contained in:
2026-08-30 18:55:11 +02:00
parent c2c11fee4e
commit f227a2026f
5 changed files with 638 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
# 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:
```text
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
```text
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
```text
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:
```text
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.