3 Commits
Author SHA1 Message Date
Commander1024 f227a2026f Add Project Guidance For Agents 2026-08-30 18:55:11 +02:00
Commander1024 c2c11fee4e Expand admin SSH command capabilities
Add per-session history, tab completion, interactive prompts, and
bounded input handling. Support deferred lifecycle and host-key actions
after output drains, and document the expanded administration workflow.
2026-08-30 18:34:01 +02:00
Commander1024 0a1bbd6782 Add Serialized SSH Administrative Console 2026-08-30 18:07:02 +02:00
26 changed files with 2379 additions and 230 deletions
+44
View File
@@ -0,0 +1,44 @@
# Agent instructions
## Start with project memory
1. Read `docs/agent/code-map.md` before broad repository exploration.
2. Read the relevant sections of `docs/agent/architecture.md` and `docs/agent/design-decisions.md` before changing cross-cutting behavior.
3. Read `docs/agent/current-state.md` when resuming work or investigating recent changes.
4. Use these files to identify the smallest relevant source set before searching or reading code.
5. Verify stored knowledge against implementation whenever it may be stale or correctness depends on exact behavior. Source code is authoritative.
6. Do not repeatedly scan unrelated modules. Prefer targeted symbol searches and representative header/implementation reads.
7. Update durable agent documentation only when architecture, contracts, ownership, or module responsibilities genuinely change.
8. During long-running tasks, keep `docs/agent/current-state.md` current and update it before handoff or context compaction.
9. Keep temporary debugging notes and speculative hypotheses out of `architecture.md` and `design-decisions.md`; use `current-state.md` instead.
10. Treat `GPT-logs/` as non-authoritative history. Confirm any useful claim against current source.
11. Avoid `managed_components/`, `third_party/`, generated `src/web_assets_data.*`, compressed assets, minified libraries, `compile_commands.json`, `dependencies.lock`, and broad `sdkconfig.*` inspection unless the task specifically requires them.
## Project constraints
- This is ESP-IDF firmware for one physical UART1/MAX3243 RS-232 port shared through USB CDC, HTTPS/WebSocket, and SSH.
- Preserve the broker model: exactly one writer, multiple isolated observers.
- Preserve UART0 as the administrative recovery path and native USB as network-independent UART1 access when network services fail.
- Keep serial transport binary-transparent; do not add in-band control sequences.
- Treat bounded queues, buffers, task ownership, generation tokens, and failure isolation as correctness properties, not incidental implementation details.
- Never expose passwords, private keys, Wi-Fi secrets, ticket values, or verifier material through routine status, logs, completion, or the local display.
- Do not regenerate embedded web assets unless the task explicitly requires it. See `web_assets/SOURCES.md` for provenance and generation policy.
## Build and device commands
The normal build, verified from `platformio.ini` and `README.md`, is:
```sh
pio run
```
Upload and monitor commands documented by the project are:
```sh
pio run --target upload
pio device monitor -b 115200
```
A first migration to the custom partition table may require `pio run --target erase`, but erasing destroys persisted configuration and credentials. Never run it without explicit user approval.
No automated host test command is defined in the repository. Hardware validation procedures live in `docs/electrical_tests.md` and `docs/user_administration_tests.md`; do not claim they passed unless actually performed.
+2 -2
View File
@@ -14,7 +14,7 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
## Development status ## Development status
Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration are complete. Phase 8B's role-aware HTTPS passwords, SSH passwords/public keys, and per-account session revocation are target-hardware validated. See the [Roadmap](docs/roadmap.md) for phase status and validation details. Hardware characterization, the serial core, USB CDC-ACM, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and the local display/control interface are implemented and Phase 7 target-hardware validated. Phase 8A's bounded role-based user database and UART0 administration are complete. Phase 8B's role-aware HTTPS passwords, SSH passwords/public keys, and per-account session revocation are target-hardware validated. Phase 8C adds a bounded administrator SSH shell backed by the same serialized command registry as UART0 and awaits target-hardware validation. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
## Documentation ## Documentation
@@ -73,7 +73,7 @@ The firmware provides an interactive UART0 console at `serial-tool>`. Run `help`
The console supports session history, line editing, cursor movement, and hierarchical Tab completion. After an unattended boot, attach an ANSI-capable terminal and press Enter once to enable enhanced editing; this avoids blocking while no terminal is attached. The console supports session history, line editing, cursor movement, and hierarchical Tab completion. After an unattended boot, attach an ANSI-capable terminal and press Enter once to enable enhanced editing; this avoids blocking while no terminal is attached.
Serial configuration and Wi-Fi edits remain in RAM until explicitly saved with `serial save` or `wifi save`. Manage role-based HTTPS/SSH passwords and SSH public keys with the physical UART0 `user` command group. `web credentials show` now exposes only the legacy migration/recovery credential, not an active Phase 8B network login. Serial configuration and Wi-Fi edits remain in RAM until explicitly saved with `serial save` or `wifi save`. Authenticated admin SSH sessions expose the shared operational administration registry, including interactive secrets, recovery-material management, network diagnostics, and deferred reboot/SSH lifecycle commands. Only initial administrator bootstrap and explicit recovery of an unavailable user database remain UART0-only. `web credentials show` exposes only the legacy migration/recovery credential, not an active Phase 8B network login.
## Security notes ## Security notes
+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.
+162
View File
@@ -0,0 +1,162 @@
# Code map
This is a semantic map, not a complete file inventory. Start here, then read the listed headers and only the implementation paths relevant to the task.
## Bootstrap and system composition
**Responsibility:** establish startup order, recovery behavior, configuration loading, service dependencies, and command registration.
- Files: `src/main.c`, `src/CMakeLists.txt`, root `CMakeLists.txt`, `platformio.ini`, `partitions.csv`, `src/idf_component.yml`; inspect targeted settings in `sdkconfig.defaults` when crypto, PSRAM, HTTPS/HTTPD, USB, or socket capacity matters
- Entry point: `app_main()`
- Called by: ESP-IDF runtime
- Dependencies: every subsystem initializer
- Lifecycle constraint: optional display/network failures should not remove UART0 administrative recovery or USB UART1 access; the custom administration frontend starts only after command registration.
## Secure randomness
**Responsibility:** provide the sole mutex-serialized device DRBG, seeded before Wi-Fi/radio use.
- Files: `src/secure_random.{h,c}`
- Interfaces: `secure_random_init()`, random-byte helpers, `secure_wipe()`
- Called by: HTTPS material, SSH keys, users, Wi-Fi defaults, tickets and authentication cache
- Constraint: initialization order is security-significant; do not add independent weak RNGs or radio-dependent early entropy paths.
## Physical RS-232 and serial service
**Responsibility:** protect the MAX3243/UART resource, own UART1 while running, buffer binary RX/TX, apply serial configuration, and expose status/counters.
- Files: `src/rs232_port_owner.{h,c}`, `src/serial_service.{h,c}`, `src/serial_config.{h,c}`, `src/serial_console.{h,c}`
- Interfaces: owner claim/release/fault; serial init/start/stop/read/write/configuration/snapshots; versioned NVS load/save
- Normal data caller: `session_broker`; USB, WebSocket, role-`user` SSH, console, and local UI also call serial lifecycle/configuration APIs as appropriate
- Dependencies: ESP-IDF UART driver, `board_pins.h`, NVS
- Ownership: diagnostics use `PHASE0`; service uses `SERVICE`; unsafe cleanup marks `FAULT` until reboot.
- Lifecycle: runtime reconfiguration stops/restarts UART and may discard bounded queued data.
## Session broker
**Responsibility:** mediate all transport access to the serial service; provide one writer lease and multiple isolated observers.
- Files: `src/session_broker.{h,c}`, `src/session_console.{h,c}`
- Interfaces: connect/disconnect, request/release/force writer, nonblocking read/write/event APIs, snapshots and counters
- Called by: USB, web serial, role-`user` SSH, console tests, local UI snapshots/actions
- Dependencies: `serial_service`
- Data path: `transport -> broker -> serial service -> UART1`; reverse data is fanned out per client.
- Ownership: client IDs are slot/generation-safe; events are advisory and can drop, so use snapshots as authority.
- Lifecycle: one permanent task and eight preallocated client slots; slow output drops only for the affected client.
## Native USB CDC
**Responsibility:** adapt TinyUSB CDC host state/data to one broker client.
- Files: `src/usb_cdc_transport.{h,c}`, `src/usb_console.{h,c}`
- Interfaces: `usb_cdc_transport_init()`, snapshots/counters, queued writer request/release
- Called by: startup, TinyUSB callbacks, console/local UI
- Dependencies: TinyUSB, broker, serial service
- Flow: `USB host <-> USB task <-> broker`
- Lifecycle: permanent owner task; broker client exists only while attached with host DTR asserted.
- Constraint: host line coding is accepted only while USB is writer and is RAM-only.
## Web and WebSocket serial
**Responsibility:** serve authenticated HTTPS UI/API, issue WebSocket tickets, and adapt browser serial sessions to broker clients.
- Files: `src/web_server.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}`
- Security files: `src/web_security.{h,c}`
- Asset files: authored/generated boundary in `src/web_assets_data.{h,c}`, `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py`
- Interfaces: web init/start/stop/snapshots; HTTP handlers; ticket mint/consume; attach/detach; targeted session revocation
- Called by: startup, ESP-IDF HTTPS server, user administration revocation, console/local UI
- Dependencies: user database, secure random, broker, Wi-Fi reachability, mbedTLS/HTTPS server
- Flow: `browser -> HTTPS Basic auth -> ticket -> WebSocket -> web transport -> broker`
- Ownership: HTTPD owns socket send/close work; transport task owns broker mediation; two fixed WebSocket slots.
- Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
## SSH
**Responsibility:** authenticate SSH, route users to serial and administrators to the command dispatcher, and own wolfSSH lifecycle.
- Files: `src/ssh_transport.{h,c}`, `src/ssh_security.{h,c}`, `src/ssh_console.{h,c}`
- Interfaces: init/start/stop, session snapshots/disconnect/revocation, host-key replacement, counters
- Called by: startup, network clients, user revocation, console/local UI
- Dependencies: user database, broker, admin SSH console, secure random, wolfSSH/wolfSSL; current boot start gate also depends on `web_security` readiness
- Flow: role `user` -> broker; role `admin` -> `admin_ssh_console`
- Ownership: one task pinned to core 1 is the sole wolfSSH caller; two fixed generation-tagged slots.
- Security constraint: shell/PTY only; no exec, file transfer, forwarding, or subsystems.
## Users, authentication, and authorization
**Responsibility:** persist bounded accounts, verify passwords/SSH keys, issue secret-free principals, and enforce account invariants.
- Files: `src/user_database.{h,c}`, `src/user_console.{h,c}`, `src/admin_command_gate.{h,c}`
- Interfaces: init/migration/recovery, authenticate, principal-currentness, account/password/role/key mutations, snapshots
- Called by: web and SSH authentication; console administration; transport revocation checks
- Dependencies: NVS, secure random, mbedTLS cryptography, web/SSH revocation hooks at command layer
- Ownership: database mutex protects live records; password authentication runs PBKDF2 outside it and revalidates afterward, while mutation locking must be checked per operation.
- Authorization: UART0 owns bootstrap/recovery; current admins may use admin SSH for operational commands; HTTPS currently treats both roles alike.
- Constraint: final administrator cannot be deleted or demoted; transport principals must be rechecked after mutations.
## Administration console infrastructure
**Responsibility:** provide one canonical command registry and serialized execution for UART0 and admin SSH.
- Files: `src/admin_ssh_console.{h,c}`, `src/console_input.{h,c}`, `src/console_completion.{h,c}`, `src/system_console.{h,c}`, `src/network_console.{h,c}` and all `*_console.{h,c}` modules
- Entry points: `admin_ssh_console_init()`, `admin_ssh_console_start_uart_frontend()`, command registration functions
- Called by: startup, UART0 frontend, role-`admin` SSH transport
- Dependencies: ESP-IDF console/linenoise, all command handlers, user-principal currentness
- Flow: `UART0/admin SSH -> bounded request queue -> one dispatcher -> esp_console_run()`
- Ownership: dispatcher is sole `esp_console_run()` caller; SSH owner remains sole wolfSSH caller.
- Lifecycle: remote session tokens include slot generation; output/history/prompt buffers are fixed and wiped on close.
- Constraint: one slow command or prompt serializes all administration. Remote self-affecting actions use deferred control after output drain.
## Wi-Fi
**Responsibility:** persist station/AP policy and own asynchronous ESP-NETIF/Wi-Fi state transitions.
- Files: `src/wifi_config.{h,c}`, `src/wifi_manager.{h,c}`, `src/wifi_console.{h,c}`, `src/network_console.{h,c}`
- Interfaces: config defaults/validate/load/save; manager init/start/stop/apply/reconnect/next-profile/snapshot
- Called by: startup, console, local UI, ESP event callbacks
- Dependencies: secure random for default AP password, NVS, ESP-NETIF/Wi-Fi/events, lwIP diagnostics
- Lifecycle: permanent manager task and bounded queue; callbacks enqueue compact events only.
- Constraint: application NVS is authoritative (`WIFI_STORAGE_RAM`); working edits are not persisted until save.
## Local display and controls
**Responsibility:** own OLED I2C/framebuffer operations and present read-only status plus constrained button actions.
- Files: `src/local_display.{h,c}`, `src/local_status_ui.{h,c}`, `src/local_boot_animation.{h,c}`, `src/local_ui_config.{h,c}`, `src/local_ui_console.{h,c}`
- Interfaces: display init/frame/draw/commit/snapshot; UI start/activity/config; versioned NVS settings
- Called by: startup, local UI task, diagnostics, display console
- Dependencies: copied snapshots/public APIs from serial, broker, USB, Wi-Fi, web, SSH
- Ownership: `local_display` solely owns I2C0 and framebuffer mutex; a frame belongs to its initiating task.
- Lifecycle: low-priority permanent UI task; optional OLED failures are nonfatal and recover through a bounded reprobe.
- Constraint: collect service snapshots before I2C; local UI never joins broker or handles secrets.
## Hardware and diagnostics
**Responsibility:** centralize board wiring and provide bounded electrical tests with safe cleanup.
- Files: `src/board_pins.h`, `src/rs232_hw_test.{h,c}`, `src/local_ui_hw_test.{h,c}`, `src/status_led.{h,c}`
- Documentation: `docs/wiring.md`, `docs/electrical_tests.md`
- Called by: startup and `debug`/`status` commands
- Dependencies: physical RS-232 owner, serial/display services, ESP-IDF GPIO/UART/I2C/LED drivers
- Ownership: RS-232 diagnostics refuse to run while the service owns the port; display diagnostics reuse `local_display`.
- Constraint: wiring and voltage assumptions are safety-relevant; verify target hardware before running diagnostics.
## Where should I look?
| Task | Start here |
|---|---|
| Change boot order or failure behavior | `src/main.c`, then affected subsystem `init/start` contracts |
| Change serial framing, flow control, or persistence | `serial_config.*`, `serial_service.*`, `serial_console.*` |
| Change writer/observer policy | `session_broker.*`, then all three transports |
| Debug missing or duplicated serial bytes | `serial_service.c` -> `session_broker.c` -> relevant transport task |
| Change USB open/DTR or line coding | `usb_cdc_transport.*` |
| Change browser terminal protocol | `web_serial_transport.*`, `web_ui.c`, `web_server.c` |
| Change HTTPS endpoints/authentication | `web_server.*`, `web_security.*`, `user_database.*` |
| Change SSH login or role routing | `ssh_transport.*`, `ssh_security.*`, `user_database.*` |
| Add or change a command | relevant `*_console.c`, `console_completion.c`, `admin_ssh_console.c` policy/deferred handling |
| Change account roles/passwords/keys | `user_database.*`, `user_console.c`, transport revocation APIs |
| Change Wi-Fi policy or profile persistence | `wifi_manager.*`, `wifi_config.*`, `wifi_console.c` |
| Change OLED rendering or buttons | `local_status_ui.c`, `local_display.*`, `local_ui_config.*` |
| Change board GPIO or electrical tests | `board_pins.h`, hardware test module, `docs/wiring.md` |
| Change embedded browser assets | `web_assets/SOURCES.md`, generator, then generated data only as an explicit regeneration task |
| Investigate memory/watchdog regressions | broker/web/SSH bounded loops, allocation placement, root `CMakeLists.txt`, relevant roadmap Phase 6 history |
+79
View File
@@ -0,0 +1,79 @@
# Current project state
This file is working memory. Update it during active work and before handoff; do not treat it as a permanent design record.
## Development state
Based on current source plus `README.md` and `docs/roadmap.md`:
- Hardware characterization, serial service, session broker, USB CDC, Wi-Fi, HTTPS/WebSocket, SSH serial transport, and local display/control are implemented and documented as target-hardware validated.
- Phase 8A role-based user storage/UART0 administration and Phase 8B role-aware HTTPS/SSH authentication and targeted revocation are documented as target-hardware validated.
- Phase 8C admin SSH is implemented in source and uses the shared `esp_console` registry. Target-hardware validation is explicitly pending.
- Phase 8D web user administration and Phase 8E browser login/session integration are planned, not implemented.
- Security/production hardening, OTA, BLE evaluation, advanced networking, and optional filesystem features remain future roadmap work.
- Reserved OTA, coredump, NVS-key, and storage partitions do not imply those runtime features are implemented.
The normal build is `pio run`. No automated host/unit-test command is defined in the repository; important validation is hardware-oriented.
## Implemented capability summary
- One UART1/MAX3243 RS-232 service with RAM working configuration, explicit persistence commands, and explicit start/stop.
- Generation-safe broker with up to eight clients, one writer, multiple observers, bounded per-client output/events, and drop accounting.
- Native USB CDC-ACM, two browser WebSocket sessions over HTTPS, and two SSH slots.
- SSH role routing: users receive serial; administrators receive the shared bounded administration shell.
- Four-profile station Wi-Fi plus off/fallback/always AP policies and network diagnostics.
- Eight-user role database, three Ed25519/P-256 keys per user, PBKDF2 password verifiers, copied principals, and targeted revocation.
- Self-signed HTTPS identity, separate SSH host key, one-time WebSocket tickets, and fail-closed authentication when user storage is unavailable.
- UART0/admin SSH serialized command registry with transport-aware prompts, bounded remote output/history, and deferred self-affecting SSH actions.
- Optional SSD1315-compatible OLED status/control interface with persisted inactivity settings and bounded failure recovery.
- Hardware diagnostics for MAX3243/UART flow control and OLED/buttons.
## Clearly incomplete or transitional areas
- Phase 8C hardware-validation matrix remains pending. It includes route separation, shared command serialization, history/completion, prompts, output backpressure, revocation during queued work, deferred SSH lifecycle/reboot actions, and full concurrent transport operation.
- Current HTTPS has no web-based user administration and gives both roles the same status/terminal routes.
- Browser authentication still uses HTTP Basic; integrated login/logout sessions are planned.
- NVS encryption, secure boot/flash encryption review, authentication rate limiting, production certificate/provisioning policy, and OTA are not implemented.
## TODO/FIXME survey
No authored `src/*.{c,h}` `TODO`, `FIXME`, `XXX`, or `HACK` markers were found during the initial architecture analysis. A TODO inside vendored `web_assets/xterm.css` is upstream asset content and not project work.
## Known inconsistencies
These observations should be checked when touching the relevant area; they are not automatically bugs requiring unrelated cleanup.
- `src/main.c` logs a Phase 7E startup banner although the implementation/roadmap is at Phase 8C.
- `docs/command_reference.md` calls `web credentials show` physical-console-only in one row, while current source and surrounding text allow it to authenticated admin SSH.
- The same reference describes interactive `user key add <username>` as a physical UART0 prompt, but transport-neutral prompt code allows it over admin SSH.
- Historical Phase 7 electrical-test steps expect Select to remain read-only and describe a powered state before automatic OLED initialization; current firmware has local controls and initializes the display during boot.
- `README.md`'s NVS partition-purpose list omits the `local_ui/config` blob.
- `README.md` links Adafruit product 6253, while `src/board_pins.h` and `docs/wiring.md` identify product 5988. **Needs verification:** whether this is an intentional male/female breakout distinction.
- `docs/user_administration_tests.md` contains historical Phase 8A/8B behavior that differs from current Phase 8C admin SSH routing. Treat phase-specific sections as historical procedures.
- Manual completion candidates omit implemented `wifi next-profile`.
- Some source comments still call shared commands UART0-only or call the current local status/control task read-only.
- `USER_DATABASE_LOAD_EMPTY` and the corresponding `main.c` log branch appear reserved or vestigial; the current missing-storage success path migrates valid legacy credentials. **Needs verification** before removing or repurposing.
- SSH startup is currently gated on successful `web_security` initialization even though SSH uses separate host-key material. **Needs verification:** whether this coupling is intentional recovery policy or an accidental startup dependency.
## Items to verify in future work
- Complete the documented Phase 8C target-hardware validation before marking it complete.
- Confirm task-local Newlib standard-stream behavior if ESP-IDF/Newlib configuration changes; admin SSH command output relies on dispatcher-task stream redirection.
- Revalidate software-crypto/watchdog behavior before changing crypto acceleration or PSRAM placement.
- Verify the Adafruit breakout product identity and reconcile hardware documentation.
- If HTTPD concurrency configuration changes, verify whether the boot-local Basic-authentication cache needs explicit locking.
- Treat serial-service exclusivity as an architectural contract: its public read/write APIs do not themselves prove that only the broker calls them.
## Active Task
No active task recorded.
### Handoff template
- **Objective:**
- **Relevant files:**
- **Findings:**
- **Decisions made:**
- **Changes completed:**
- **Remaining work:**
- **Risks / things to remember:**
+163
View File
@@ -0,0 +1,163 @@
# Durable design constraints and decisions
Only constraints supported by implementation or current project documentation belong here. When original rationale is unknown, the entry describes the observable constraint without inventing intent.
## One broker mediates all production serial transports
**Decision:** USB CDC, WebSocket, and role-`user` SSH access UART1 through `session_broker`; transports do not independently own the serial service.
**Rationale/evidence:** The broker is initialized after the serial service and all transport implementations connect broker clients. It is the normal serial RX consumer and TX gate. Project documentation requires one writer and multiple observers.
**Consequence for future changes:** New serial transports must become broker clients. Do not bypass writer checks or consume `serial_service` RX directly. Preserve binary transparency and avoid in-band ownership control.
**Relevant files:** `src/session_broker.{h,c}`, `src/serial_service.{h,c}`, `src/usb_cdc_transport.c`, `src/web_serial_transport.c`, `src/ssh_transport.c`
## Slow clients are isolated by bounded per-client storage
**Decision:** UART RX is drained and copied into independent bounded broker output streams; a full observer loses only its own copy.
**Rationale/evidence:** `session_broker` accounts per-client dropped bytes instead of blocking fan-out. The roadmap records slow-client isolation as a project-wide constraint.
**Consequence for future changes:** Do not replace fan-out with a blocking shared queue. Any added transport must tolerate partial/no-progress reads and expose drop/backpressure counters.
**Relevant files:** `src/session_broker.c`, `src/session_broker.h`, `docs/roadmap.md`
## Physical UART ownership and logical writer ownership remain separate
**Decision:** `rs232_port_owner` controls whether diagnostics or the serial service may manipulate UART/MAX3243 hardware; the broker separately controls which connected client may write.
**Rationale/evidence:** The code has explicit `NONE`, `PHASE0`, `SERVICE`, and `FAULT` hardware states plus broker client/writer IDs.
**Consequence for future changes:** A writer lease never authorizes direct UART/GPIO access. Hardware tests must claim `PHASE0`; production service must claim `SERVICE`. Ambiguous cleanup must keep the transceiver safe and require reboot rather than clearing fault casually.
**Relevant files:** `src/rs232_port_owner.{h,c}`, `src/rs232_hw_test.c`, `src/serial_service.c`, `src/session_broker.c`
## Resource IDs are generation-safe
**Decision:** Broker clients, SSH/WebSocket slots, queued admin work, and user principals carry generations or random stable IDs to reject stale references and slot reuse.
**Rationale/evidence:** Broker IDs encode slot generation; transports track slot generations; admin tokens include session/slot generation; user principal currentness includes account ID and authentication generation.
**Consequence for future changes:** Preserve transport-slot generations and account-authentication generations as distinct concepts. Validate tokens immediately before side effects and discard late work after disconnect/reuse/revocation.
**Relevant files:** `src/session_broker.{h,c}`, `src/ssh_transport.c`, `src/web_serial_transport.c`, `src/admin_ssh_console.c`, `src/user_database.{h,c}`
## UART0 is the physical recovery authority
**Decision:** UART0 remains independent of UART1 and networking. Initial administrator bootstrap and explicit unavailable-user-database recovery are restricted to UART0.
**Rationale/evidence:** `main.c` configures UART0 separately; command policy and user handlers deny these operations remotely. README/roadmap identify UART0 as the trusted recovery console.
**Consequence for future changes:** Network failures or credential corruption must not remove UART0 recovery. Do not expose bootstrap/recovery through web or admin SSH without an explicit security redesign.
**Relevant files:** `src/main.c`, `src/admin_ssh_console.c`, `src/user_console.c`, `docs/roadmap.md`
## Admin SSH and user SSH are different routes
**Decision:** A role-`user` SSH session becomes a broker serial client. A role-`admin` session enters the administration console and never obtains a broker client/writer lease.
**Rationale/evidence:** Role routing is explicit after SSH authentication. The administrative shell is intended for command execution, not multiplexed serial data.
**Consequence for future changes:** Do not silently give administrators both streams or infer that higher privilege means UART1 ownership. A route-switch feature would require explicit protocol, lifecycle, and authorization design.
**Relevant files:** `src/ssh_transport.c`, `src/admin_ssh_console.{h,c}`, `src/session_broker.c`
## One dispatcher executes the canonical command registry
**Decision:** UART0 and admin SSH submit complete lines to one fixed queue; one task is the sole caller of `esp_console_run()`.
**Rationale/evidence:** The implementation treats ESP-IDF console execution as non-reentrant and removes the need for separate remote command implementations.
**Consequence for future changes:** Register one canonical handler rather than creating a second SSH dispatcher. Long commands/prompts block all administration, so keep handlers bounded or explicitly asynchronous. Preserve output routing and remote principal checks.
**Relevant files:** `src/admin_ssh_console.c`, `src/main.c`, `src/console_input.c`, all `src/*_console.c`
## Self-affecting admin SSH actions drain output before execution
**Decision:** Remote reboot, SSH stop/disconnect, and host-key replacement are deferred until acknowledgement output leaves the administration and transport buffers.
**Rationale/evidence:** `admin_ssh_console` has a separate bounded control task and pending-action state. Immediate execution would sever the session before confirmation is delivered.
**Consequence for future changes:** Commands that invalidate their own transport/session must integrate with deferred control rather than acting synchronously from the dispatcher. Prevent new input while the action is pending.
**Relevant files:** `src/admin_ssh_console.c`, `src/system_console.c`, `src/ssh_console.c`, `src/ssh_transport.c`
## Authentication uses copied principals and fail-safe currentness checks
**Decision:** Network sessions retain secret-free copied principals. Account mutations invalidate generations/IDs, explicitly request targeted transport revocation at the command layer, and rely on ongoing currentness checks as the fail-safe.
**Rationale/evidence:** `user_database` issues principals without secrets; web/SSH check currentness during admission and active sessions. Mutating console paths call transport revocation hooks.
**Consequence for future changes:** Do not retain pointers to database records or treat login as permanently authoritative. New authenticated sessions/transports must revalidate at admission, before sensitive input, and periodically or on relevant events. Database mutation APIs alone do not perform transport notification.
**Relevant files:** `src/user_database.{h,c}`, `src/user_console.c`, `src/web_server.c`, `src/web_serial_transport.c`, `src/ssh_transport.c`
## Security material and configuration use bounded, versioned NVS records
**Decision:** Application settings, users, and identities use separate fixed/versioned NVS blobs. Serial, Wi-Fi, and local-UI working edits are RAM-only until explicitly saved. User mutations and HTTPS/SSH identity changes commit directly as part of the operation. Invalid ordinary configuration generally selects RAM defaults without erasing storage; malformed security material fails closed and needs explicit reset.
**Rationale/evidence:** Serial, Wi-Fi, local UI, web security, users, and SSH security each validate schema/size and own their namespace. User/security mutations build and validate candidate state before committing it; security modules avoid silently replacing an established identity.
**Consequence for future changes:** Add schema versions and transactional candidate validation. Do not overwrite unknown records automatically; provide explicit migration/reset behavior. Preserve the distinct persistence contracts: explicit save/load/default/reset for working configuration, atomic commit-or-fail for user and identity mutation.
**Relevant files:** `src/serial_config.c`, `src/wifi_config.c`, `src/local_ui_config.c`, `src/web_security.c`, `src/user_database.c`, `src/ssh_security.c`
## NVS is persistence, not a physical security boundary
**Decision:** The current firmware stores Wi-Fi credentials, recovery credentials, and TLS/SSH private keys in unencrypted application NVS. The reserved NVS-key partition does not enable encryption.
**Rationale/evidence:** `partitions.csv`, README security notes, and current code show no NVS-encryption setup. Original rationale for deferring encryption is outside the implementation; the observable limitation is explicit.
**Consequence for future changes:** Do not claim resistance to flash extraction. Avoid increasing stored secret exposure. Enabling encryption requires migration/recovery planning, not just changing the partition table.
**Relevant files:** `partitions.csv`, `README.md`, `src/web_security.c`, `src/ssh_security.c`, `src/wifi_config.c`
## Wi-Fi callbacks enqueue; the manager owns policy
**Decision:** ESP event callbacks copy bounded event data into the Wi-Fi manager queue. A permanent manager task performs driver operations, profile/AP policy, deadlines, and reconciliation.
**Rationale/evidence:** Callback paths avoid blocking, NVS, and policy work. Manager deadlines consult authoritative driver/netif state so dropped events are recoverable.
**Consequence for future changes:** Keep callbacks short and nonblocking. Add state transitions to the manager rather than directly invoking Wi-Fi policy from consoles, UI, or callbacks. Preserve queue-drop observability.
**Relevant files:** `src/wifi_manager.{h,c}`, `src/wifi_config.{h,c}`
## Optional local UI cannot become a core dependency
**Decision:** The OLED/display may fail without stopping serial, UART0, USB, or networking. The UI consumes copied snapshots and calls public APIs; it never parses CLI output or joins the broker.
**Rationale/evidence:** `main.c` logs display failures and continues. `local_status_ui` collects snapshots before display frames and exposes limited confirmed controls.
**Consequence for future changes:** Keep OLED/I2C work bounded and outside service locks. Do not put credentials or core ownership into UI state. A missing display must remain nonfatal.
**Relevant files:** `src/main.c`, `src/local_display.{h,c}`, `src/local_status_ui.c`, `src/local_ui_config.c`
## Hardware and library access has designated owners
**Decision:** The serial task owns UART1 while active, `local_display` owns I2C/framebuffer access, the SSH owner task alone calls wolfSSH, and the console dispatcher alone runs registered commands.
**Rationale/evidence:** These constraints are enforced by module structure, mutex/task assertions, and transport indirection. Original rationale varies; the observable effect is serialized library/hardware access.
**Consequence for future changes:** Cross-task requests should use existing queues/public APIs. Do not call wolfSSH, mutate display frames, or run console handlers from arbitrary tasks.
**Relevant files:** `src/serial_service.c`, `src/local_display.c`, `src/ssh_transport.c`, `src/admin_ssh_console.c`
## Software cryptography settings are a validated concurrency workaround
**Decision:** wolfSSL ESP32 AES/SHA acceleration is disabled, and HTTPS uses software AES for PSRAM-backed TLS records. Internal task stacks are retained where cache-disable safety matters.
**Rationale/evidence:** Root `CMakeLists.txt` disables wolfSSL hardware crypto. The roadmap records a reproduced watchdog stall in mbedTLS external-RAM hardware-AES DMA and uncoordinated mbedTLS/wolfSSL hardware locks; the software-crypto build passed the documented concurrency retest.
**Consequence for future changes:** Do not remove these definitions as a performance cleanup. Any re-enablement needs target-hardware concurrency testing with simultaneous USB, WebSocket, SSH, and serial traffic plus watchdog/stack telemetry.
**Relevant files:** `CMakeLists.txt`, `src/CMakeLists.txt`, `docs/roadmap.md`, relevant `sdkconfig.defaults` crypto settings
## Embedded web assets are checked-in generated artifacts
**Decision:** Vendored xterm assets are compressed and embedded ahead of the normal firmware build; `src/web_assets_data.c` is compiled directly.
**Rationale/evidence:** `src/CMakeLists.txt` lists generated data as a source, and `web_assets/SOURCES.md` documents pinned versions, hashes, and deterministic gzip inputs.
**Consequence for future changes:** Edit authored web UI separately. When dependency assets change, follow the documented provenance/generation process and review generated diffs; do not hand-edit arrays or regenerate assets during unrelated work.
**Relevant files:** `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py`, `src/web_assets_data.{h,c}`, `src/web_ui.c`
+12 -5
View File
@@ -1,6 +1,6 @@
# Command reference # Command reference
Use these commands from the UART0 `serial-tool>` administration console. Run `help` for the available root commands and `<group> help` for a group summary. Configuration changes are RAM-only unless explicitly saved. UART0 and authenticated `admin` SSH sessions use the same registered command implementations through one serialized dispatcher. Admin SSH exposes the full operational registry, including interactive prompts, recovery-secret display, network diagnostics, reboot, and HTTPS/SSH material mutation. Only initial administrator bootstrap and explicit recovery of an unavailable user database remain physically bound to UART0. Run `help` for root commands and `<group> help` for a group summary. Configuration changes are RAM-only unless explicitly saved.
## System ## System
@@ -24,12 +24,13 @@ Use these commands from the UART0 `serial-tool>` administration console. Run `he
| `user role <username> <user|admin> --force` | Change a role; the final administrator cannot be demoted. | | `user role <username> <user|admin> --force` | Change a role; the final administrator cannot be demoted. |
| `user password <username>` | Set and confirm a new password without echo. | | `user password <username>` | Set and confirm a new password without echo. |
| `user password <username> --generate` | Replace a password with a generated value displayed once. | | `user password <username> --generate` | Replace a password with a generated value displayed once. |
| `user key add <username>` | Prompt for one bounded OpenSSH public-key line. | | `user key add <username>` | Prompt on physical UART0 for one bounded OpenSSH public-key line. |
| `user key add <username> <type> <base64>` | Import a key non-interactively; intended for authenticated admin SSH and also accepted on UART0. |
| `user key delete <username> <0..2> --force` | Delete one key by the index shown by `user show`. | | `user key delete <username> <0..2> --force` | Delete one key by the index shown by `user show`. |
| `user key clear <username> --force` | Delete all public keys for an account. | | `user key clear <username> --force` | Delete all public keys for an account. |
| `user recover --force` | When normal user-database initialization failed, explicitly replace its blob from the current legacy network credential. | | `user recover --force` | When normal user-database initialization failed, explicitly replace its blob from the current legacy network credential. |
Usernames must match `[a-z][a-z0-9_-]{0,15}`. Passwords contain 1264 printable ASCII characters. The fixed database supports eight users and three SSH keys per user; initial key types are `ssh-ed25519` and `ecdsa-sha2-nistp256`. A key may belong to only one account. Password verifiers, salts, raw key blobs, and passwords are absent from ordinary status output. `Ctrl-C` cancels a password or key prompt, and generated passwords are shown once. Usernames must match `[a-z][a-z0-9_-]{0,15}`. Passwords contain 1264 printable ASCII characters. The fixed database supports eight users and three SSH keys per user; initial key types are `ssh-ed25519` and `ecdsa-sha2-nistp256`. A key may be assigned to multiple accounts but cannot be duplicated within one account. Password verifiers, salts, raw key blobs, and passwords are absent from ordinary status output. `Ctrl-C` cancels a password or key prompt, and generated passwords are shown once.
On the first Phase 8A boot, the old shared `admin` credential is imported as a role-`user` account, not silently granted administrator rights. Run `user bootstrap` from physical UART0 to establish the administrator. Phase 8B now authenticates HTTPS and SSH passwords through this database and enables stored SSH public keys. Before bootstrap, `web credentials rotate --force` and `web reset --force` synchronize the migrated verifier; after bootstrap, that legacy credential is recovery-only and does not authenticate or alter role-based users. On the first Phase 8A boot, the old shared `admin` credential is imported as a role-`user` account, not silently granted administrator rights. Run `user bootstrap` from physical UART0 to establish the administrator. Phase 8B now authenticates HTTPS and SSH passwords through this database and enables stored SSH public keys. Before bootstrap, `web credentials rotate --force` and `web reset --force` synchronize the migrated verifier; after bootstrap, that legacy credential is recovery-only and does not authenticate or alter role-based users.
@@ -130,14 +131,20 @@ HTTPS listens on port 443 only. Authenticate with any current user-database user
| `ssh` / `ssh help` | Show SSH command usage. | | `ssh` / `ssh help` | Show SSH command usage. |
| `ssh status` | Show service state and resource information. | | `ssh status` | Show service state and resource information. |
| `ssh start` / `ssh stop` | Start or stop the SSH server. | | `ssh start` / `ssh stop` | Start or stop the SSH server. |
| `ssh sessions` | List active SSH sessions with account, user role, authentication method, and broker role. | | `ssh sessions` | List active SSH sessions with account, user role, authentication method, route, broker role where applicable, and admin-worker state. |
| `ssh disconnect <session-id>` | Disconnect one SSH session. | | `ssh disconnect <session-id>` | Disconnect one SSH session. |
| `ssh counters` / `ssh clear-counters` | Show or clear SSH counters. | | `ssh counters` / `ssh clear-counters` | Show or clear SSH counters. |
| `ssh host-key info` | Display the OpenSSH host-key fingerprint. | | `ssh host-key info` | Display the OpenSSH host-key fingerprint. |
| `ssh host-key rotate --force` | Replace the persistent SSH host key. | | `ssh host-key rotate --force` | Replace the persistent SSH host key. |
| `ssh reset --force` | Explicitly replace invalid or missing SSH material. | | `ssh reset --force` | Explicitly replace invalid or missing SSH material. |
SSH listens on port 22 and accepts user-database passwords plus stored `ssh-ed25519` and `ecdsa-sha2-nistp256` public keys. wolfSSH verifies key possession after the database authorizes the username/key pair; unsigned key probes do not complete authentication. Both `user` and `admin` currently receive the same broker-backed interactive shell/PTY serial stream. The administrative SSH shell arrives in Phase 8C. SSH does not provide `exec`, SFTP, SCP, forwarding, or subsystems. Verify the host fingerprint from `ssh host-key info` before connecting. SSH listens on port 22 and accepts user-database passwords plus stored `ssh-ed25519` and `ecdsa-sha2-nistp256` public keys. wolfSSH verifies key possession after the database authorizes the username/key pair; unsigned key probes do not complete authentication. A `user` receives the broker-backed UART1 serial stream. An `admin` receives the administration shell instead, does not become a broker client, and cannot acquire a UART1 writer lease.
UART0 and admin SSH submit to one bounded queue, and one dispatcher task is the sole caller of `esp_console_run()`. Consequently, SSH commands execute the canonical UART0 handlers and produce the same status and mutation behavior rather than using a second command implementation. Remote output is routed into the authenticated session's bounded output ring; only the SSH transport task accesses wolfSSH.
Admin SSH supports four-entry per-session command history with Up/Down, bounded whole-line Tab completion, Backspace/Ctrl-C, and visible or no-echo interactive prompts. History is RAM-only, private to the session, and wiped on disconnect. Ping callbacks enqueue bounded typed results so all formatting remains on the dispatcher task.
`reboot`, `ssh stop`, session disconnect, and SSH host-key reset/rotation are deferred until the command acknowledgement has left both the administration output ring and transport TX buffer. The shell stops accepting another command while such an action is pending. SSH host-key replacement or service stop closes all SSH sessions; reconnect and verify the new fingerprint where applicable. Web recovery credentials/certificates, Wi-Fi secrets, and interactive user passwords/keys are available to authenticated administrators and must therefore be treated as remotely accessible administrative material. Only `user bootstrap` and `user recover --force` remain UART0-only. A connected administrator still cannot generate its own replacement password remotely, preventing the one-time password from being lost during self-revocation. SSH does not provide `exec`, SFTP, SCP, forwarding, or subsystems.
## Hardware diagnostics ## Hardware diagnostics
+10 -5
View File
@@ -38,7 +38,7 @@ These constraints apply across all phases:
| 5B | Offline xterm.js WebSocket serial terminal | **Complete** | | 5B | Offline xterm.js WebSocket serial terminal | **Complete** |
| 6 | Authenticated SSH serial transport | **Complete** | | 6 | Authenticated SSH serial transport | **Complete** |
| 7 | Local display and button interface | **Complete** | | 7 | Local display and button interface | **Complete** |
| 8 | Role-based users and administrative access | **In progress (8A8B complete; 8C8E planned)** | | 8 | Role-based users and administrative access | **In progress (8A8B complete; 8C validation pending; 8D8E planned)** |
| 9 | Security and production hardening | **Planned** | | 9 | Security and production hardening | **Planned** |
| 10 | Authenticated, rollback-capable OTA | **Planned** | | 10 | Authenticated, rollback-capable OTA | **Planned** |
| 11 | BLE serial transport and provisioning evaluation | **Planned** | | 11 | BLE serial transport and provisioning evaluation | **Planned** |
@@ -179,7 +179,7 @@ The software-crypto build no longer reproduces the HTTPD watchdog stall. This va
## Current and planned phases ## Current and planned phases
The order below is the current plan. Phase 7, Phase 8A, and Phase 8B are complete; later work remains planned or under evaluation. Detailed requirements should be finalized at the start of each phase, and optional features must not weaken the completed serial and recovery paths. The order below is the current plan. Phase 7, Phase 8A, and Phase 8B are complete; Phase 8C is implemented and awaiting target-hardware validation; later work remains planned or under evaluation. Detailed requirements should be finalized at the start of each phase, and optional features must not weaken the completed serial and recovery paths.
### Phase 7 — Local display and buttons ### Phase 7 — Local display and buttons
@@ -263,10 +263,15 @@ Implementation sequence:
- **Completed SSH-key validation:** Ed25519 and ECDSA P-256 public-key login work for both roles, including normal unsigned probe followed by signed proof-of-possession. A public key may be assigned to multiple accounts but cannot be duplicated within one account; the SSH username selects the account principal. Unsupported or incorrect credentials remain rejected without granting a broker client. - **Completed SSH-key validation:** Ed25519 and ECDSA P-256 public-key login work for both roles, including normal unsigned probe followed by signed proof-of-possession. A public key may be assigned to multiple accounts but cannot be duplicated within one account; the SSH username selects the account principal. Unsupported or incorrect credentials remain rejected without granting a broker client.
- **Completed ticket and revocation validation:** WebSocket tickets are account-bound, one-time, and expire as intended. Password, role, key, delete, and username-recreation mutations promptly revoke only the affected user's tickets and active network sessions, release any affected writer lease, and leave unrelated users connected. - **Completed ticket and revocation validation:** WebSocket tickets are account-bound, one-time, and expire as intended. Password, role, key, delete, and username-recreation mutations promptly revoke only the affected user's tickets and active network sessions, release any affected writer lease, and leave unrelated users connected.
- **Completed recovery and concurrency validation:** The database-unavailable path fails closed and retains UART0 recovery. Concurrent USB CDC, WebSocket, SSH, UART1, and UART0 operation preserves normal serial writer/observer behavior. `web credentials rotate --force` remains recovery-only after bootstrap. `web reset --force` restarts HTTPS with a replacement certificate without revoking unrelated SSH sessions; browsers correctly require a fresh TLS certificate acceptance before reconnecting. - **Completed recovery and concurrency validation:** The database-unavailable path fails closed and retains UART0 recovery. Concurrent USB CDC, WebSocket, SSH, UART1, and UART0 operation preserves normal serial writer/observer behavior. `web credentials rotate --force` remains recovery-only after bootstrap. `web reset --force` restarts HTTPS with a replacement certificate without revoking unrelated SSH sessions; browsers correctly require a fresh TLS certificate acceptance before reconnecting.
3. **Phase 8C — SSH administrative shell — Planned** 3. **Phase 8C — SSH administrative shell — Implemented; validation pending**
- Route authenticated `admin` SSH shell sessions to the same registered administrative command set as UART0, without creating a broker client. Normal users continue to receive the existing broker-backed serial stream. - Authenticated `admin` SSH shell sessions route to a bounded administration worker and never create a broker client or acquire a serial writer lease. Normal `user` sessions retain the existing broker-backed serial stream.
- Serialize command parsing safely because ESP-IDF console internals are process-global. Use bounded per-session input/output queues and a separate command worker; only the SSH owner task may call wolfSSH APIs. - UART0 and admin SSH now submit complete lines to one fixed-length request queue. A single dispatcher task is the sole caller of ESP-IDF's non-reentrant `esp_console_run()` and therefore executes the same registered command handlers for both entry routes. The former separately implemented reduced SSH command dispatcher has been removed.
- The worker uses fixed per-session command/input and output buffers. Queue records contain copied secret-free principals and generation-tagged session tokens; late work is discarded after disconnect, slot reuse, role change, password/key mutation, or deletion. Task-local standard streams route canonical handler output into the applicable bounded SSH ring, and only the SSH owner task calls wolfSSH APIs.
- Transport-neutral bounded prompts now support interactive user passwords/keys and Wi-Fi secrets over admin SSH without exposing hidden input or allowing another command while a prompt is active. Ping callbacks enqueue typed bounded events and the dispatcher alone formats their output. Four-entry per-session history and whole-line Tab completion are RAM-only and wiped on disconnect.
- Authenticated administrators receive the full operational registry, including recovery-secret display, HTTPS material rotation/reset, reboot, ping, and SSH lifecycle/session/host-key mutation. Self-terminating reboot and SSH actions are deferred until acknowledgement output drains, block further shell input, and are executed through the existing synchronous owner APIs from a separate bounded control task. Only initial `user bootstrap` and explicit `user recover --force` remain physical-UART0 operations.
- `ssh sessions` and `ssh counters` identify broker versus admin-console routes, worker command state, queued admin output, admission failures, and input backpressure. Admin sessions are checked for a current `admin` principal before command execution and during the active-session reconciliation.
- Keep SFTP, SCP, `exec`, forwarding, subsystems, and unauthenticated shells disabled. - Keep SFTP, SCP, `exec`, forwarding, subsystems, and unauthenticated shells disabled.
- Pending target-hardware validation: route separation from the broker, history/Tab editing, interactive visible/hidden prompts, output/backpressure, generated and entered user/password/key management including the longest ECDSA P-256 import, ping event routing, deferred reboot/SSH lifecycle drain behavior, bootstrap/recovery rejection, targeted self/other-user revocation during queued work, UART0/SSH administration serialization, and concurrent USB/WebSocket/user-SSH/admin-SSH operation.
4. **Phase 8D — Web user administration — Planned** 4. **Phase 8D — Web user administration — Planned**
- Add an admin-only user-management interface and typed, bounded APIs for account CRUD, roles, password generation/change, SSH-key management, and revocation. Never expose a generic HTTP endpoint that executes arbitrary CLI text. - Add an admin-only user-management interface and typed, bounded APIs for account CRUD, roles, password generation/change, SSH-key management, and revocation. Never expose a generic HTTP endpoint that executes arbitrary CLI text.
- Hide administrative navigation and controls for normal users, and enforce every authorization decision server-side so hidden UI is not treated as a security boundary. - Hide administrative navigation and controls for normal users, and enforce every authorization decision server-side so hidden UI is not treated as a security boundary.
+41
View File
@@ -120,3 +120,44 @@ Run `web status`, `ssh sessions`, `web counters`, `ssh counters`, and `broker cl
### 5. Concurrency regression ### 5. Concurrency regression
With USB CDC, two role-based network users, one WebSocket terminal, one SSH terminal, and UART1 traffic active, alternate writer ownership and mutate one account. Confirm binary transparency, observer isolation, bounded authentication/handshake behavior, UART0 responsiveness, and no unexpected disconnect of the unaffected user. Record memory, broker, web, SSH, and serial counters before and after. Repeat after reboot to verify passwords, roles, keys, and authentication methods persist. With USB CDC, two role-based network users, one WebSocket terminal, one SSH terminal, and UART1 traffic active, alternate writer ownership and mutate one account. Confirm binary transparency, observer isolation, bounded authentication/handshake behavior, UART0 responsiveness, and no unexpected disconnect of the unaffected user. Record memory, broker, web, SSH, and serial counters before and after. Repeat after reboot to verify passwords, roles, keys, and authentication methods persist.
## Phase 8C SSH administrative shell
Use one disposable `admin` and one disposable `user`. Keep UART0 attached throughout. The SSH server still accepts only shell sessions: `exec`, subsystem/SFTP/SCP, forwarding, and unauthenticated connections must remain rejected.
### 1. Route separation and normal shells
1. Connect as the normal user and confirm the existing broker-backed UART1 serial stream, broker client, and writer/observer behavior are unchanged.
2. Connect as the administrator and confirm the `admin@serial-tool>` prompt appears. Run `help`, `status`, `memory`, `serial status`, `wifi status`, `web status`, `broker status`, and `broker clients`. Compare representative output with UART0 and confirm both routes execute the same registered command implementations.
3. From UART0 run `ssh sessions` and `broker clients`. The user session must show `route=broker`; the admin session must show `route=admin-console`, `broker=0`, `broker-role=n/a`, and no writer lease. The admin session must not start UART1 or alter broker client/writer counts merely by connecting.
### 2. Bounded command processing
Exercise printable input, backspace, Ctrl-C, CR/LF, an empty line, and a line longer than the documented limit. Confirm the command line is bounded, overflow is discarded through a clear diagnostic, and a new prompt remains usable. Run `help`, `user list`, and `broker clients` in a normal ANSI terminal and confirm every line starts in column zero: canonical LF output must be normalized to CRLF without doubling handlers that already emit CRLF.
Run at least five distinct commands, then use Up/Down to navigate the four-entry per-session history, return to a saved draft with Down, and confirm older entries are bounded out. Verify history does not survive reconnect and is not shared with a second administrator. Exercise Tab on root and nested prefixes such as `us`, `user l`, `wifi ap sh`, and `ssh host-key i`; confirm unique/common prefixes redraw cleanly without inserting escape-sequence bytes into the command.
Run an unsupported command and confirm it is rejected without affecting UART0 or the serial broker. Run the full root `help` output to exercise output-ring draining. With the SSH client temporarily unable to read output, confirm the worker applies input backpressure rather than accepting an unbounded command/output backlog; inspect `ssh counters` for admin-console admission and input-backpressure values.
### 3. Remote account administration
Run `user list`, `user show <name>`, `user add <name> user --generate`, `user password <name> --generate`, `user role <name> admin --force`, and the key delete/clear operations from the administrative shell. Confirm generated passwords appear once only on that authenticated channel, affected account sessions are revoked, and unrelated sessions remain connected.
Import both supported key types through the remote form:
```text
user key add <username> ssh-ed25519 <base64-blob>
user key add <username> ecdsa-sha2-nistp256 <base64-blob>
```
Confirm the full ECDSA P-256 command is accepted, fingerprints appear in `user show`, a duplicate on the same account is rejected, and the same key can be imported for a second account. Verify subsequent private-key SSH login uses the selected SSH username.
### 4. Interactive administration, lifecycle actions, and revocation
Confirm only `user bootstrap` and `user recover --force` remain unavailable from SSH and continue to work through physical UART0. From admin SSH, exercise manually entered user passwords and public keys, Wi-Fi station/AP secret entry, AP secret display, legacy web recovery credential display/rotation, HTTPS certificate rotation/reset, and both `ping` and `wifi ping`. Hidden characters must not echo or enter command history; visible key input must support Backspace and Ctrl-C; ping lines must remain ordered and correctly attributed to the invoking SSH session.
Exercise `ssh disconnect` for another session and the current session. For the other-session case, confirm the acknowledgement drains, the target closes, and the source shell returns. Separately test `reboot`, `ssh stop`, `ssh host-key rotate --force`, and `ssh reset --force`: each must acknowledge scheduling, stop accepting another command, drain output, and then close/reboot as appropriate. Reconnect after key replacement and verify the new fingerprint. Simulate an unread SSH output window and confirm the destructive action cancels after its bounded drain timeout rather than remaining pending forever.
While an administrative command is queued or running, use UART0 to change that admin's role/password/key or delete it. Confirm no second remote administrative command runs after the mutation, the SSH session is revoked promptly, and queued output is not delivered to a reused SSH slot. Repeat with a different account mutation and confirm the administrator remains connected.
Finally, issue commands concurrently from UART0 and admin SSH, including `user list`, long `help` output, and one UART0 interactive password or key prompt while an SSH command waits. Confirm the single dispatcher serializes all `esp_console_run()` calls, UART0 retains its line editing/history/completion, prompt input is consumed only from UART0, outputs are not mixed between transports, and there is no stack overflow, corrupted argument parsing, database damage, or broker disruption.
+2
View File
@@ -21,6 +21,8 @@ idf_component_register(
"session_broker.c" "session_broker.c"
"session_console.c" "session_console.c"
"ssh_security.c" "ssh_security.c"
"admin_command_gate.c"
"admin_ssh_console.c"
"ssh_transport.c" "ssh_transport.c"
"ssh_console.c" "ssh_console.c"
"usb_cdc_transport.c" "usb_cdc_transport.c"
+44
View File
@@ -0,0 +1,44 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Shared recursive gate for administrative command execution origins. */
#include "admin_command_gate.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static SemaphoreHandle_t s_gate;
esp_err_t admin_command_gate_take(void)
{
taskENTER_CRITICAL(&s_lock);
SemaphoreHandle_t gate = s_gate;
taskEXIT_CRITICAL(&s_lock);
if (gate == NULL) {
SemaphoreHandle_t candidate = xSemaphoreCreateRecursiveMutex();
if (candidate == NULL) {
return ESP_ERR_NO_MEM;
}
taskENTER_CRITICAL(&s_lock);
if (s_gate == NULL) {
s_gate = candidate;
candidate = NULL;
}
gate = s_gate;
taskEXIT_CRITICAL(&s_lock);
if (candidate != NULL) {
vSemaphoreDelete(candidate);
}
}
return xSemaphoreTakeRecursive(gate, portMAX_DELAY) == pdTRUE ? ESP_OK : ESP_FAIL;
}
void admin_command_gate_give(void)
{
taskENTER_CRITICAL(&s_lock);
SemaphoreHandle_t gate = s_gate;
taskEXIT_CRITICAL(&s_lock);
if (gate != NULL) {
(void)xSemaphoreGiveRecursive(gate);
}
}
+17
View File
@@ -0,0 +1,17 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Serializes trusted UART0 and authenticated SSH administrative mutations. */
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
esp_err_t admin_command_gate_take(void);
void admin_command_gate_give(void);
#ifdef __cplusplus
}
#endif
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Bounded, transport-neutral administrative command worker for SSH sessions. */
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "esp_err.h"
#include "user_database.h"
#ifdef __cplusplus
extern "C" {
#endif
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
typedef struct {
uint8_t slot_index;
uint32_t session_id;
uint32_t slot_generation;
} admin_ssh_console_token_t;
typedef enum {
ADMIN_SSH_DEFER_NONE = 0,
ADMIN_SSH_DEFER_REBOOT,
ADMIN_SSH_DEFER_STOP,
ADMIN_SSH_DEFER_DISCONNECT,
ADMIN_SSH_DEFER_HOST_KEY_ROTATE,
ADMIN_SSH_DEFER_HOST_KEY_RESET,
} admin_ssh_deferred_action_type_t;
typedef struct {
bool active;
bool command_pending;
bool input_pending;
bool output_pending;
size_t input_length;
size_t output_length;
} admin_ssh_console_session_snapshot_t;
/* Starts the single command worker. It is the sole esp_console_run() caller. */
esp_err_t admin_ssh_console_init(void);
/* Called after all ESP-IDF commands are registered; starts the UART0 frontend. */
esp_err_t admin_ssh_console_start_uart_frontend(void);
/* Valid only while a registered command callback runs on the dispatcher task. */
bool admin_ssh_console_dispatch_is_remote(void);
const user_principal_t *admin_ssh_console_dispatch_principal(void);
esp_err_t admin_ssh_console_dispatch_read_input(
const char *prompt, uint8_t *output, size_t capacity,
bool hidden, size_t *output_length);
esp_err_t admin_ssh_console_dispatch_defer(
admin_ssh_deferred_action_type_t action, uint32_t argument);
/* The token and principal are copied; no SSH or socket objects cross this boundary. */
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal);
void admin_ssh_console_close(const admin_ssh_console_token_t *token);
/* Called only by the SSH owner task. Returns false when input must be backpressured. */
bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token);
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
const uint8_t *data, size_t length,
size_t *consumed);
/* Called only by the SSH owner task; copies already-produced output without blocking. */
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
uint8_t *data, size_t capacity,
size_t *received);
esp_err_t admin_ssh_console_get_session_snapshot(
const admin_ssh_console_token_t *token,
admin_ssh_console_session_snapshot_t *snapshot);
#ifdef __cplusplus
}
#endif
+48
View File
@@ -10,6 +10,11 @@
#include "esp_console.h" #include "esp_console.h"
#include "linenoise/linenoise.h" #include "linenoise/linenoise.h"
static const char *const s_root_candidates[] = {
"help", "status", "debug", "display", "serial", "broker", "usb", "user",
"wifi", "web", "ssh", "ping", "nslookup", "traceroute", "reboot", "memory",
};
/* Keep full-line candidate strings grouped by their registered root command. */ /* Keep full-line candidate strings grouped by their registered root command. */
static const char *const s_completion_candidates[] = { static const char *const s_completion_candidates[] = {
/* Hardware debug commands and safe fixed arguments. */ /* Hardware debug commands and safe fixed arguments. */
@@ -197,6 +202,49 @@ static const char *const s_completion_candidates[] = {
"ssh reset --force", "ssh reset --force",
}; };
bool console_completion_expand(const char *line, char *completed, size_t capacity)
{
if (line == NULL || completed == NULL || capacity == 0U) {
return false;
}
size_t line_length = strlen(line);
const char *const *candidates = strchr(line, ' ') == NULL
? s_root_candidates
: s_completion_candidates;
size_t candidate_count = strchr(line, ' ') == NULL
? sizeof(s_root_candidates) / sizeof(s_root_candidates[0])
: sizeof(s_completion_candidates) /
sizeof(s_completion_candidates[0]);
const char *first = NULL;
size_t common_length = 0U;
for (size_t index = 0U; index < candidate_count; ++index) {
const char *candidate = candidates[index];
if (strncmp(candidate, line, line_length) != 0) {
continue;
}
if (first == NULL) {
first = candidate;
common_length = strlen(candidate);
continue;
}
size_t candidate_length = strlen(candidate);
if (common_length > candidate_length) {
common_length = candidate_length;
}
size_t offset = line_length;
while (offset < common_length && first[offset] == candidate[offset]) {
++offset;
}
common_length = offset;
}
if (first == NULL || common_length <= line_length || common_length >= capacity) {
return false;
}
memcpy(completed, first, common_length);
completed[common_length] = '\0';
return true;
}
static ssize_t console_read_with_late_terminal_upgrade(int file_descriptor, static ssize_t console_read_with_late_terminal_upgrade(int file_descriptor,
void *buffer, void *buffer,
size_t size) size_t size)
+6
View File
@@ -2,6 +2,9 @@
#pragma once #pragma once
#include <stdbool.h>
#include <stddef.h>
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
#endif #endif
@@ -9,6 +12,9 @@ extern "C" {
/* Install late-terminal upgrade handling and project-specific completion. */ /* Install late-terminal upgrade handling and project-specific completion. */
void console_completion_install(void); void console_completion_install(void);
/* Bounded longest-prefix completion shared by the UART and admin SSH frontends. */
bool console_completion_expand(const char *line, char *completed, size_t capacity);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif
+5
View File
@@ -6,6 +6,7 @@
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include "admin_ssh_console.h"
#include "driver/uart.h" #include "driver/uart.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
@@ -31,6 +32,10 @@ static esp_err_t read_input(const char *prompt, uint8_t *output, size_t capacity
if (prompt == NULL || output == NULL || output_length == NULL || capacity == 0U) { if (prompt == NULL || output == NULL || output_length == NULL || capacity == 0U) {
return ESP_ERR_INVALID_ARG; return ESP_ERR_INVALID_ARG;
} }
if (admin_ssh_console_dispatch_is_remote()) {
return admin_ssh_console_dispatch_read_input(
prompt, output, capacity, hidden, output_length);
}
*output_length = 0U; *output_length = 0U;
memset(output, 0, capacity); memset(output, 0, capacity);
esp_err_t error = prepare_prompt(prompt); esp_err_t error = prepare_prompt(prompt);
+9 -4
View File
@@ -1,6 +1,7 @@
#include <string.h> #include <string.h>
#include "driver/uart.h" #include "driver/uart.h"
#include "admin_ssh_console.h"
#include "console_completion.h" #include "console_completion.h"
#include "esp_console.h" #include "esp_console.h"
#include "esp_err.h" #include "esp_err.h"
@@ -64,6 +65,8 @@ void app_main(void)
ESP_ERROR_CHECK(status_led_init()); ESP_ERROR_CHECK(status_led_init());
ESP_ERROR_CHECK(rs232_port_owner_init()); ESP_ERROR_CHECK(rs232_port_owner_init());
ESP_ERROR_CHECK(rs232_hw_test_init()); ESP_ERROR_CHECK(rs232_hw_test_init());
/* Reserve the shared UART0 dispatcher before optional SSH/network services. */
ESP_ERROR_CHECK(admin_ssh_console_init());
/* The optional display can fail without affecting UART0 or serial transports. */ /* The optional display can fail without affecting UART0 or serial transports. */
esp_err_t local_display_error = local_display_init(); esp_err_t local_display_error = local_display_init();
@@ -274,8 +277,9 @@ void app_main(void)
esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT(); esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT();
repl_config.prompt = "serial-tool> "; repl_config.prompt = "serial-tool> ";
repl_config.max_cmdline_length = 160; repl_config.max_cmdline_length = ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY;
repl_config.task_stack_size = 8192; /* The stock REPL task remains dormant; our shared frontend owns line dispatch. */
repl_config.task_stack_size = 2048;
/* /*
* UART0 remains dedicated to development and diagnostics. The external * UART0 remains dedicated to development and diagnostics. The external
@@ -304,8 +308,9 @@ void app_main(void)
ESP_ERROR_CHECK(system_console_register_commands()); ESP_ERROR_CHECK(system_console_register_commands());
/* Upgrade late UART terminals safely and add nested completion. */ /* Upgrade late UART terminals safely and add nested completion. */
console_completion_install(); console_completion_install();
ESP_ERROR_CHECK(esp_console_start_repl(repl)); ESP_ERROR_CHECK(admin_ssh_console_start_uart_frontend());
ESP_LOGI(TAG, "Interactive test console ready at %d baud", CONSOLE_BAUD_RATE); ESP_LOGI(TAG, "Shared UART0/SSH administration console ready at %d baud",
CONSOLE_BAUD_RATE);
ESP_LOGI(TAG, "Type 'help' for commands; native USB starts UART1 only when its host port opens"); ESP_LOGI(TAG, "Type 'help' for commands; native USB starts UART1 only when its host port opens");
} }
+96 -75
View File
@@ -14,6 +14,7 @@
#include "esp_err.h" #include "esp_err.h"
#include "esp_timer.h" #include "esp_timer.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "lwip/inet.h" #include "lwip/inet.h"
#include "lwip/inet_chksum.h" #include "lwip/inet_chksum.h"
@@ -163,109 +164,109 @@ static int resolve_ping_target(const char *host, ip_addr_t *target,
return 0; return 0;
} }
typedef enum {
PING_EVENT_LINE = 0,
PING_EVENT_END,
} ping_event_kind_t;
typedef struct { typedef struct {
TaskHandle_t waiting_task; ping_event_kind_t kind;
char line[128];
char address[NUMERIC_ADDRESS_CAPACITY];
uint32_t transmitted;
uint32_t received;
uint32_t duration_ms;
esp_err_t profile_error;
esp_err_t delete_error; esp_err_t delete_error;
bool received_reply; } ping_event_t;
bool summary_valid;
typedef struct {
QueueHandle_t queue;
} ping_wait_context_t; } ping_wait_context_t;
#define PING_EVENT_QUEUE_LENGTH (PING_MAX_COUNT + 1U)
static StaticQueue_t s_ping_queue_storage;
static uint8_t s_ping_queue_bytes[PING_EVENT_QUEUE_LENGTH * sizeof(ping_event_t)];
static QueueHandle_t s_ping_queue;
static void ping_on_success(esp_ping_handle_t handle, void *arguments) static void ping_on_success(esp_ping_handle_t handle, void *arguments)
{ {
(void)arguments; ping_wait_context_t *context = arguments;
ping_event_t event = {.kind = PING_EVENT_LINE};
uint16_t sequence = 0U; uint16_t sequence = 0U;
uint8_t ttl = 0U; uint8_t ttl = 0U;
uint32_t reply_size = 0U; uint32_t reply_size = 0U;
uint32_t elapsed_ms = 0U; uint32_t elapsed_ms = 0U;
ip_addr_t reply_address; ip_addr_t reply_address;
char numeric[NUMERIC_ADDRESS_CAPACITY]; char numeric[NUMERIC_ADDRESS_CAPACITY] = "?";
bool valid = esp_ping_get_profile(handle, ESP_PING_PROF_SEQNO,
if (esp_ping_get_profile(handle, ESP_PING_PROF_SEQNO, &sequence, sizeof(sequence)) == ESP_OK &&
&sequence, sizeof(sequence)) != ESP_OK ||
esp_ping_get_profile(handle, ESP_PING_PROF_SIZE, esp_ping_get_profile(handle, ESP_PING_PROF_SIZE,
&reply_size, sizeof(reply_size)) != ESP_OK || &reply_size, sizeof(reply_size)) == ESP_OK &&
esp_ping_get_profile(handle, ESP_PING_PROF_TIMEGAP, esp_ping_get_profile(handle, ESP_PING_PROF_TIMEGAP,
&elapsed_ms, sizeof(elapsed_ms)) != ESP_OK || &elapsed_ms, sizeof(elapsed_ms)) == ESP_OK &&
esp_ping_get_profile(handle, ESP_PING_PROF_IPADDR, esp_ping_get_profile(handle, ESP_PING_PROF_IPADDR,
&reply_address, sizeof(reply_address)) != ESP_OK || &reply_address, sizeof(reply_address)) == ESP_OK &&
ipaddr_ntoa_r(&reply_address, numeric, (int)sizeof(numeric)) == NULL) { ipaddr_ntoa_r(&reply_address, numeric, (int)sizeof(numeric)) != NULL;
printf("ping: received a reply but could not read its profile\n"); if (!valid) {
return; strlcpy(event.line, "ping: received a reply but could not read its profile",
} sizeof(event.line));
} else if (IP_IS_V4(&reply_address) &&
if (IP_IS_V4(&reply_address) &&
esp_ping_get_profile(handle, ESP_PING_PROF_TTL, esp_ping_get_profile(handle, ESP_PING_PROF_TTL,
&ttl, sizeof(ttl)) == ESP_OK) { &ttl, sizeof(ttl)) == ESP_OK) {
printf("%" PRIu32 " bytes from %s: icmp_seq=%" PRIu16 snprintf(event.line, sizeof(event.line),
" ttl=%u time=%" PRIu32 " ms\n", "%" PRIu32 " bytes from %s: icmp_seq=%" PRIu16
" ttl=%u time=%" PRIu32 " ms",
reply_size, numeric, sequence, (unsigned int)ttl, elapsed_ms); reply_size, numeric, sequence, (unsigned int)ttl, elapsed_ms);
} else { } else {
printf("%" PRIu32 " bytes from %s: icmp_seq=%" PRIu16 snprintf(event.line, sizeof(event.line),
" time=%" PRIu32 " ms\n", "%" PRIu32 " bytes from %s: icmp_seq=%" PRIu16
" time=%" PRIu32 " ms",
reply_size, numeric, sequence, elapsed_ms); reply_size, numeric, sequence, elapsed_ms);
} }
(void)xQueueSend(context->queue, &event, 0U);
} }
static void ping_on_timeout(esp_ping_handle_t handle, void *arguments) static void ping_on_timeout(esp_ping_handle_t handle, void *arguments)
{ {
(void)arguments; ping_wait_context_t *context = arguments;
ping_event_t event = {.kind = PING_EVENT_LINE};
uint16_t sequence = 0U; uint16_t sequence = 0U;
ip_addr_t target_address; ip_addr_t target_address;
char numeric[NUMERIC_ADDRESS_CAPACITY] = "?"; char numeric[NUMERIC_ADDRESS_CAPACITY] = "?";
if (esp_ping_get_profile(handle, ESP_PING_PROF_SEQNO, if (esp_ping_get_profile(handle, ESP_PING_PROF_SEQNO,
&sequence, sizeof(sequence)) == ESP_OK && &sequence, sizeof(sequence)) == ESP_OK &&
esp_ping_get_profile(handle, ESP_PING_PROF_IPADDR, esp_ping_get_profile(handle, ESP_PING_PROF_IPADDR,
&target_address, sizeof(target_address)) == ESP_OK) { &target_address, sizeof(target_address)) == ESP_OK) {
(void)ipaddr_ntoa_r(&target_address, numeric, (int)sizeof(numeric)); (void)ipaddr_ntoa_r(&target_address, numeric, (int)sizeof(numeric));
} }
printf("From %s: icmp_seq=%" PRIu16 " timeout\n", numeric, sequence); snprintf(event.line, sizeof(event.line), "From %s: icmp_seq=%" PRIu16 " timeout",
numeric, sequence);
(void)xQueueSend(context->queue, &event, 0U);
} }
static void ping_on_end(esp_ping_handle_t handle, void *arguments) static void ping_on_end(esp_ping_handle_t handle, void *arguments)
{ {
ping_wait_context_t *context = (ping_wait_context_t *)arguments; ping_wait_context_t *context = arguments;
uint32_t transmitted = 0U; ping_event_t event = {.kind = PING_EVENT_END, .profile_error = ESP_OK};
uint32_t received = 0U;
uint32_t duration_ms = 0U;
ip_addr_t target_address; ip_addr_t target_address;
char numeric[NUMERIC_ADDRESS_CAPACITY] = "?"; strlcpy(event.address, "?", sizeof(event.address));
event.profile_error = esp_ping_get_profile(
esp_err_t profile_error = esp_ping_get_profile( handle, ESP_PING_PROF_REQUEST, &event.transmitted, sizeof(event.transmitted));
handle, ESP_PING_PROF_REQUEST, &transmitted, sizeof(transmitted)); if (event.profile_error == ESP_OK) {
if (profile_error == ESP_OK) { event.profile_error = esp_ping_get_profile(
profile_error = esp_ping_get_profile( handle, ESP_PING_PROF_REPLY, &event.received, sizeof(event.received));
handle, ESP_PING_PROF_REPLY, &received, sizeof(received));
} }
if (profile_error == ESP_OK) { if (event.profile_error == ESP_OK) {
profile_error = esp_ping_get_profile( event.profile_error = esp_ping_get_profile(
handle, ESP_PING_PROF_DURATION, &duration_ms, sizeof(duration_ms)); handle, ESP_PING_PROF_DURATION, &event.duration_ms, sizeof(event.duration_ms));
} }
if (esp_ping_get_profile(handle, ESP_PING_PROF_IPADDR, if (esp_ping_get_profile(handle, ESP_PING_PROF_IPADDR,
&target_address, sizeof(target_address)) == ESP_OK) { &target_address, sizeof(target_address)) == ESP_OK) {
(void)ipaddr_ntoa_r(&target_address, numeric, (int)sizeof(numeric)); (void)ipaddr_ntoa_r(&target_address, event.address, (int)sizeof(event.address));
} }
event.delete_error = esp_ping_delete_session(handle);
if (profile_error == ESP_OK) { (void)xQueueSend(context->queue, &event, 0U);
context->received_reply = received > 0U;
context->summary_valid = true;
uint32_t loss_percent = transmitted == 0U
? 0U
: ((transmitted - received) * 100U) / transmitted;
printf("\n--- %s ping statistics ---\n", numeric);
printf("%" PRIu32 " packets transmitted, %" PRIu32
" received, %" PRIu32 "%% packet loss, time %" PRIu32 " ms\n",
transmitted, received, loss_percent, duration_ms);
} else {
printf("ping: session ended, but summary profile retrieval failed: %s\n",
esp_err_to_name(profile_error));
}
/* Stop ping_sock's task before waking the higher-priority console caller. */
context->delete_error = esp_ping_delete_session(handle);
xTaskNotifyGive(context->waiting_task);
} }
static int execute_ping(int argc, char **argv) static int execute_ping(int argc, char **argv)
@@ -288,12 +289,17 @@ static int execute_ping(int argc, char **argv)
return 1; return 1;
} }
ping_wait_context_t context = { if (s_ping_queue == NULL) {
.waiting_task = xTaskGetCurrentTaskHandle(), s_ping_queue = xQueueCreateStatic(PING_EVENT_QUEUE_LENGTH, sizeof(ping_event_t),
.delete_error = ESP_FAIL, s_ping_queue_bytes, &s_ping_queue_storage);
}; } else {
/* Remove any unrelated notification before this command begins waiting. */ (void)xQueueReset(s_ping_queue);
(void)ulTaskNotifyTake(pdTRUE, 0U); }
if (s_ping_queue == NULL) {
printf("ping: could not allocate event queue\n");
return 1;
}
ping_wait_context_t context = {.queue = s_ping_queue};
esp_ping_config_t config = ESP_PING_DEFAULT_CONFIG(); esp_ping_config_t config = ESP_PING_DEFAULT_CONFIG();
config.count = count; config.count = count;
@@ -321,21 +327,36 @@ static int execute_ping(int argc, char **argv)
return 1; return 1;
} }
/* Finite count guarantees on_ping_end; blocking keeps console output ordered. */ for (;;) {
if (ulTaskNotifyTake(pdTRUE, portMAX_DELAY) == 0U) { ping_event_t event;
if (xQueueReceive(s_ping_queue, &event, portMAX_DELAY) != pdTRUE) {
printf("ping: wait for session completion failed\n"); printf("ping: wait for session completion failed\n");
(void)esp_ping_stop(session);
(void)esp_ping_delete_session(session);
return 1; return 1;
} }
if (event.kind == PING_EVENT_LINE) {
bool command_succeeded = context.summary_valid && context.received_reply; printf("%s\n", event.line);
if (context.delete_error != ESP_OK) { continue;
}
if (event.profile_error != ESP_OK) {
printf("ping: session ended, but summary profile retrieval failed: %s\n",
esp_err_to_name(event.profile_error));
return 1;
}
uint32_t loss_percent = event.transmitted == 0U
? 0U
: ((event.transmitted - event.received) * 100U) /
event.transmitted;
printf("\n--- %s ping statistics ---\n", event.address);
printf("%" PRIu32 " packets transmitted, %" PRIu32
" received, %" PRIu32 "%% packet loss, time %" PRIu32 " ms\n",
event.transmitted, event.received, loss_percent, event.duration_ms);
if (event.delete_error != ESP_OK) {
printf("ping: could not delete session: %s\n", printf("ping: could not delete session: %s\n",
esp_err_to_name(context.delete_error)); esp_err_to_name(event.delete_error));
return 1; return 1;
} }
return command_succeeded ? 0 : 1; return event.received > 0U ? 0 : 1;
}
} }
static bool socket_addresses_equal(const struct addrinfo *left, static bool socket_addresses_equal(const struct addrinfo *left,
+72 -12
View File
@@ -9,6 +9,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include "admin_ssh_console.h"
#include "esp_console.h" #include "esp_console.h"
#include "mbedtls/base64.h" #include "mbedtls/base64.h"
#include "secure_random.h" #include "secure_random.h"
@@ -50,6 +51,15 @@ static const char *auth_method_name(user_auth_method_t method)
: method == USER_AUTH_METHOD_SSH_PUBLIC_KEY ? "public-key" : "unknown"; : method == USER_AUTH_METHOD_SSH_PUBLIC_KEY ? "public-key" : "unknown";
} }
static const char *route_name(ssh_transport_session_route_t route)
{
return route == SSH_TRANSPORT_ROUTE_BROKER
? "broker"
: route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE
? "admin-console"
: "none";
}
static int print_sessions(const ssh_transport_snapshot_t *snapshot) static int print_sessions(const ssh_transport_snapshot_t *snapshot)
{ {
printf("SSH sessions: active=%" PRIu32 "/%u\n", printf("SSH sessions: active=%" PRIu32 "/%u\n",
@@ -60,21 +70,22 @@ static int print_sessions(const ssh_transport_snapshot_t *snapshot)
continue; continue;
} }
printf(" id=%" PRIu32 " slot=%u peer=%s state=%s auth=%s account=%s" printf(" id=%" PRIu32 " slot=%u peer=%s state=%s auth=%s account=%s"
" user-role=%s method=%s broker=%" PRIu32 " user-role=%s method=%s route=%s broker=%" PRIu32
" broker-role=%s rx-pending=%s tx-pending=%s closing=%s\n", " broker-role=%s admin-command=%s admin-output=%" PRIu32
" rx-pending=%s tx-pending=%s closing=%s\n",
session->session_id, (unsigned int)index, session->peer, session->session_id, (unsigned int)index, session->peer,
state_name(session->state), session->authenticated ? "yes" : "no", state_name(session->state), session->authenticated ? "yes" : "no",
session->principal_valid ? session->username : "-", session->principal_valid ? session->username : "-",
session->principal_valid session->principal_valid ? user_role_to_string(session->user_role) : "-",
? user_role_to_string(session->user_role) session->principal_valid ? auth_method_name(session->auth_method) : "-",
: "-", route_name(session->route), session->broker_client_id,
session->principal_valid session->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE
? auth_method_name(session->auth_method) ? "n/a"
: "-", : (session->broker_client_id == SESSION_BROKER_NO_CLIENT
session->broker_client_id,
session->broker_client_id == SESSION_BROKER_NO_CLIENT
? "unattached" ? "unattached"
: (session->writer ? "writer" : "observer"), : (session->writer ? "writer" : "observer")),
session->admin_command_pending ? "running" : "idle",
session->admin_output_pending,
session->rx_pending ? "yes" : "no", session->rx_pending ? "yes" : "no",
session->tx_pending ? "yes" : "no", session->tx_pending ? "yes" : "no",
session->close_requested ? "yes" : "no"); session->close_requested ? "yes" : "no");
@@ -135,6 +146,10 @@ static int show_counters(void)
counter->disconnections, counter->writer_requests, counter->disconnections, counter->writer_requests,
counter->writer_grants, counter->writer_denials, counter->writer_grants, counter->writer_denials,
counter->writer_revocations); counter->writer_revocations);
printf("Admin console: admissions=%" PRIu64 " admission-failures=%" PRIu64
" input-backpressure=%" PRIu64 "\n",
counter->admin_console_admissions, counter->admin_console_admission_failures,
counter->admin_console_input_rejections);
printf("Stream: rx=%" PRIu64 " accepted=%" PRIu64 printf("Stream: rx=%" PRIu64 " accepted=%" PRIu64
" rejected=%" PRIu64 " tx=%" PRIu64 " rejected=%" PRIu64 " tx=%" PRIu64
" io-failures=%" PRIu64 " session-revocations=%" PRIu64 "\n", " io-failures=%" PRIu64 " session-revocations=%" PRIu64 "\n",
@@ -193,6 +208,18 @@ static bool parse_session_id(const char *text, uint32_t *session_id)
static int replace_host_key(bool reset) static int replace_host_key(bool reset)
{ {
if (admin_ssh_console_dispatch_is_remote()) {
esp_err_t deferred = admin_ssh_console_dispatch_defer(
reset ? ADMIN_SSH_DEFER_HOST_KEY_RESET : ADMIN_SSH_DEFER_HOST_KEY_ROTATE, 0U);
if (deferred != ESP_OK) {
printf("Could not schedule SSH host-key replacement: %s\n",
esp_err_to_name(deferred));
return 1;
}
printf("SSH host-key %s scheduled after output drains; all SSH sessions will close.\n",
reset ? "reset" : "rotation");
return 0;
}
ssh_security_metadata_t before = {0}; ssh_security_metadata_t before = {0};
bool had_before = ssh_security_get_metadata(&before) == ESP_OK; bool had_before = ssh_security_get_metadata(&before) == ESP_OK;
esp_err_t error = ssh_transport_replace_host_key(reset); esp_err_t error = ssh_transport_replace_host_key(reset);
@@ -242,6 +269,16 @@ static int command_ssh(int argc, char **argv)
return 0; return 0;
} }
if (argc == 2 && strcmp(argv[1], "stop") == 0) { if (argc == 2 && strcmp(argv[1], "stop") == 0) {
if (admin_ssh_console_dispatch_is_remote()) {
esp_err_t deferred = admin_ssh_console_dispatch_defer(
ADMIN_SSH_DEFER_STOP, 0U);
if (deferred != ESP_OK) {
printf("Could not schedule SSH stop: %s\n", esp_err_to_name(deferred));
return 1;
}
printf("SSH stop scheduled after output drains; all SSH sessions will close.\n");
return 0;
}
esp_err_t error = ssh_transport_stop(); esp_err_t error = ssh_transport_stop();
if (error != ESP_OK) { if (error != ESP_OK) {
printf("Could not stop SSH: %s\n", esp_err_to_name(error)); printf("Could not stop SSH: %s\n", esp_err_to_name(error));
@@ -268,7 +305,30 @@ static int command_ssh(int argc, char **argv)
printf("Session ID must be a nonzero decimal integer.\n"); printf("Session ID must be a nonzero decimal integer.\n");
return 1; return 1;
} }
esp_err_t error = ssh_transport_disconnect(session_id); esp_err_t error;
if (admin_ssh_console_dispatch_is_remote()) {
ssh_transport_snapshot_t snapshot;
error = ssh_transport_get_snapshot(&snapshot);
bool found = false;
if (error == ESP_OK) {
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
if (snapshot.sessions[index].active &&
snapshot.sessions[index].session_id == session_id) {
found = true;
break;
}
}
if (!found) {
error = ESP_ERR_NOT_FOUND;
}
}
if (error == ESP_OK) {
error = admin_ssh_console_dispatch_defer(
ADMIN_SSH_DEFER_DISCONNECT, session_id);
}
} else {
error = ssh_transport_disconnect(session_id);
}
if (error != ESP_OK) { if (error != ESP_OK) {
printf("Could not disconnect SSH session: %s\n", esp_err_to_name(error)); printf("Could not disconnect SSH session: %s\n", esp_err_to_name(error));
return 1; return 1;
+142 -5
View File
@@ -9,6 +9,7 @@
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include "admin_ssh_console.h"
#include "esp_heap_caps.h" #include "esp_heap_caps.h"
#include "esp_log.h" #include "esp_log.h"
#include "esp_timer.h" #include "esp_timer.h"
@@ -57,6 +58,7 @@ typedef struct {
int socket_fd; int socket_fd;
WOLFSSH *ssh; WOLFSSH *ssh;
session_broker_client_id_t broker_client_id; session_broker_client_id_t broker_client_id;
ssh_transport_session_route_t route;
user_principal_t principal; user_principal_t principal;
user_principal_t pending_principal; user_principal_t pending_principal;
bool principal_valid; bool principal_valid;
@@ -125,6 +127,16 @@ static void notify_task(void)
} }
} }
static admin_ssh_console_token_t admin_console_token(const ssh_slot_t *slot,
size_t slot_index)
{
return (admin_ssh_console_token_t){
.slot_index = (uint8_t)slot_index,
.session_id = slot->session_id,
.slot_generation = slot->generation,
};
}
static void publish_slot(const ssh_slot_t *slot, size_t slot_index) static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
{ {
ssh_transport_session_snapshot_t snapshot = { ssh_transport_session_snapshot_t snapshot = {
@@ -140,11 +152,21 @@ static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
.socket_fd = slot->socket_fd, .socket_fd = slot->socket_fd,
.broker_client_id = slot->broker_client_id, .broker_client_id = slot->broker_client_id,
.state = slot->state, .state = slot->state,
.route = slot->route,
.user_role = slot->principal_valid ? slot->principal.role : USER_ROLE_USER, .user_role = slot->principal_valid ? slot->principal.role : USER_ROLE_USER,
.auth_method = slot->principal_valid .auth_method = slot->principal_valid
? slot->principal.method ? slot->principal.method
: USER_AUTH_METHOD_PASSWORD, : USER_AUTH_METHOD_PASSWORD,
}; };
if (slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) {
admin_ssh_console_session_snapshot_t admin_snapshot;
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
if (admin_ssh_console_get_session_snapshot(&token, &admin_snapshot) == ESP_OK) {
snapshot.admin_command_pending = admin_snapshot.command_pending;
snapshot.admin_output_pending = (uint32_t)admin_snapshot.output_length;
snapshot.tx_pending = snapshot.tx_pending || admin_snapshot.output_pending;
}
}
if (slot->principal_valid) { if (slot->principal_valid) {
memcpy(snapshot.username, slot->principal.username, memcpy(snapshot.username, slot->principal.username,
slot->principal.username_length); slot->principal.username_length);
@@ -442,6 +464,11 @@ static void close_socket(int *socket_fd)
static bool cleanup_slot(ssh_slot_t *slot) static bool cleanup_slot(ssh_slot_t *slot)
{ {
if (slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) {
size_t slot_index = (size_t)(slot - s_slots);
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
admin_ssh_console_close(&token);
}
if (slot->ssh != NULL) { if (slot->ssh != NULL) {
(void)wolfSSH_shutdown(slot->ssh); (void)wolfSSH_shutdown(slot->ssh);
wolfSSH_free(slot->ssh); wolfSSH_free(slot->ssh);
@@ -886,14 +913,33 @@ static void process_handshake(ssh_slot_t *slot, size_t slot_index)
return; return;
} }
esp_err_t error = connect_broker(slot, slot_index); esp_err_t error;
if (slot->principal.role == USER_ROLE_USER) {
error = connect_broker(slot, slot_index);
if (error != ESP_OK) { if (error != ESP_OK) {
add_counter(&s_counters.broker_failures, 1U); add_counter(&s_counters.broker_failures, 1U);
request_slot_close(slot, false); request_slot_close(slot, false);
return; return;
} }
slot->route = SSH_TRANSPORT_ROUTE_BROKER;
} else if (slot->principal.role == USER_ROLE_ADMIN) {
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
error = admin_ssh_console_open(&token, &slot->principal);
if (error != ESP_OK) {
add_counter(&s_counters.admin_console_admission_failures, 1U);
request_slot_close(slot, false);
return;
}
slot->route = SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
add_counter(&s_counters.admin_console_admissions, 1U);
} else {
request_slot_close(slot, true);
return;
}
if (!slot_principal_is_current(slot)) { if (!slot_principal_is_current(slot)) {
if (slot->route == SSH_TRANSPORT_ROUTE_BROKER) {
disconnect_failed_admission(slot); disconnect_failed_admission(slot);
}
request_slot_close(slot, true); request_slot_close(slot, true);
return; return;
} }
@@ -1086,12 +1132,98 @@ static bool read_broker_output(ssh_slot_t *slot)
return received == 0U ? true : flush_client_output(slot); return received == 0U ? true : flush_client_output(slot);
} }
static void process_active(ssh_slot_t *slot) static bool reconcile_admin_principal(ssh_slot_t *slot)
{ {
bool healthy = service_wolfssh_io(slot) && int64_t now = esp_timer_get_time();
drain_broker_events(slot) && reconcile_writer(slot) && if (now - slot->last_reconcile_us < SSH_TRANSPORT_RECONCILE_INTERVAL_US) {
return true;
}
slot->last_reconcile_us = now;
if (!slot_principal_is_current(slot) || slot->principal.role != USER_ROLE_ADMIN) {
request_slot_close(slot, true);
return false;
}
return true;
}
static bool flush_admin_input(ssh_slot_t *slot, size_t slot_index)
{
if (slot->rx_offset >= slot->rx_length) {
slot->rx_offset = 0U;
slot->rx_length = 0U;
return true;
}
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
size_t consumed = 0U;
bool accepted = admin_ssh_console_feed_input(
&token, slot->rx_buffer + slot->rx_offset,
slot->rx_length - slot->rx_offset, &consumed);
if (consumed > 0U) {
slot->rx_offset += consumed;
add_counter(&s_counters.rx_accepted_bytes, consumed);
}
if (slot->rx_offset >= slot->rx_length) {
slot->rx_offset = 0U;
slot->rx_length = 0U;
}
if (!accepted && consumed == 0U) {
add_counter(&s_counters.admin_console_input_rejections, 1U);
}
return true;
}
static bool receive_admin_input(ssh_slot_t *slot, size_t slot_index)
{
if (slot->rx_length != 0U) {
return flush_admin_input(slot, slot_index);
}
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
if (!admin_ssh_console_accepts_input(&token)) {
return true;
}
slot->io_read_budget = SSH_TRANSPORT_WOLFSSH_READ_BUDGET;
int result = wolfSSH_stream_read(slot->ssh, slot->rx_buffer,
sizeof(slot->rx_buffer));
if (result > 0) {
slot->rx_offset = 0U;
slot->rx_length = (size_t)result;
add_counter(&s_counters.rx_bytes, (uint64_t)result);
return flush_admin_input(slot, slot_index);
}
return result == 0 || wolfssh_would_block(slot->ssh, result);
}
static bool read_admin_output(ssh_slot_t *slot, size_t slot_index)
{
if (slot->tx_length != 0U) {
return true;
}
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
size_t received = 0U;
esp_err_t error = admin_ssh_console_read_output(&token, slot->tx_buffer,
sizeof(slot->tx_buffer), &received);
if (error != ESP_OK && error != ESP_ERR_NOT_FOUND) {
return false;
}
slot->tx_offset = 0U;
slot->tx_length = received;
return error == ESP_OK;
}
static void process_active(ssh_slot_t *slot, size_t slot_index)
{
bool healthy = service_wolfssh_io(slot);
if (healthy && slot->route == SSH_TRANSPORT_ROUTE_BROKER) {
healthy = drain_broker_events(slot) && reconcile_writer(slot) &&
flush_client_output(slot) && read_broker_output(slot) && flush_client_output(slot) && read_broker_output(slot) &&
flush_client_input(slot) && receive_client_input(slot); flush_client_input(slot) && receive_client_input(slot);
} else if (healthy && slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE) {
healthy = reconcile_admin_principal(slot) && flush_client_output(slot) &&
read_admin_output(slot, slot_index) && flush_client_output(slot) &&
receive_admin_input(slot, slot_index);
} else if (healthy) {
healthy = false;
}
if (!healthy) { if (!healthy) {
add_counter(&s_counters.io_failures, 1U); add_counter(&s_counters.io_failures, 1U);
request_slot_close(slot, false); request_slot_close(slot, false);
@@ -1117,7 +1249,7 @@ static void process_slots(void)
if (slot->state == SSH_TRANSPORT_SESSION_HANDSHAKE) { if (slot->state == SSH_TRANSPORT_SESSION_HANDSHAKE) {
process_handshake(slot, index); process_handshake(slot, index);
} else if (slot->state == SSH_TRANSPORT_SESSION_ACTIVE) { } else if (slot->state == SSH_TRANSPORT_SESSION_ACTIVE) {
process_active(slot); process_active(slot, index);
} }
publish_slot(slot, index); publish_slot(slot, index);
} }
@@ -1186,6 +1318,11 @@ esp_err_t ssh_transport_init(void)
error = ESP_ERR_NO_MEM; error = ESP_ERR_NO_MEM;
goto fail; goto fail;
} }
error = admin_ssh_console_init();
if (error != ESP_OK) {
vSemaphoreDelete(command_mutex);
goto fail;
}
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) { for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
s_slots[index].state = SSH_TRANSPORT_SESSION_FREE; s_slots[index].state = SSH_TRANSPORT_SESSION_FREE;
s_slots[index].socket_fd = -1; s_slots[index].socket_fd = -1;
+12
View File
@@ -27,6 +27,12 @@ typedef enum {
SSH_TRANSPORT_SESSION_CLOSING, SSH_TRANSPORT_SESSION_CLOSING,
} ssh_transport_session_state_t; } ssh_transport_session_state_t;
typedef enum {
SSH_TRANSPORT_ROUTE_NONE = 0,
SSH_TRANSPORT_ROUTE_BROKER,
SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE,
} ssh_transport_session_route_t;
typedef struct { typedef struct {
uint64_t starts; uint64_t starts;
uint64_t start_failures; uint64_t start_failures;
@@ -52,6 +58,9 @@ typedef struct {
uint64_t tx_bytes; uint64_t tx_bytes;
uint64_t io_failures; uint64_t io_failures;
uint64_t session_revocations; uint64_t session_revocations;
uint64_t admin_console_admissions;
uint64_t admin_console_admission_failures;
uint64_t admin_console_input_rejections;
} ssh_transport_counters_t; } ssh_transport_counters_t;
typedef struct { typedef struct {
@@ -62,11 +71,14 @@ typedef struct {
bool close_requested; bool close_requested;
bool rx_pending; bool rx_pending;
bool tx_pending; bool tx_pending;
bool admin_command_pending;
uint32_t admin_output_pending;
uint32_t session_id; uint32_t session_id;
uint32_t generation; uint32_t generation;
int socket_fd; int socket_fd;
session_broker_client_id_t broker_client_id; session_broker_client_id_t broker_client_id;
ssh_transport_session_state_t state; ssh_transport_session_state_t state;
ssh_transport_session_route_t route;
user_role_t user_role; user_role_t user_role;
user_auth_method_t auth_method; user_auth_method_t auth_method;
char username[USER_DATABASE_USERNAME_CAPACITY + 1U]; char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
+10
View File
@@ -6,6 +6,7 @@
#include <stdint.h> #include <stdint.h>
#include <stdio.h> #include <stdio.h>
#include "admin_ssh_console.h"
#include "esp_console.h" #include "esp_console.h"
#include "esp_heap_caps.h" #include "esp_heap_caps.h"
#include "esp_system.h" #include "esp_system.h"
@@ -47,6 +48,15 @@ static int command_reboot(int argc, char **argv)
return 1; return 1;
} }
if (admin_ssh_console_dispatch_is_remote()) {
esp_err_t error = admin_ssh_console_dispatch_defer(ADMIN_SSH_DEFER_REBOOT, 0U);
if (error != ESP_OK) {
printf("Could not schedule reboot: %s\n", esp_err_to_name(error));
return 1;
}
printf("Reboot scheduled after SSH output drains; unsaved changes will be lost.\n");
return 0;
}
printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n"); printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n");
fflush(stdout); fflush(stdout);
/* Give the UART driver time to transmit the acknowledgement before reset. */ /* Give the UART driver time to transmit the acknowledgement before reset. */
+85 -40
View File
@@ -7,6 +7,8 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include "admin_command_gate.h"
#include "admin_ssh_console.h"
#include "console_input.h" #include "console_input.h"
#include "esp_console.h" #include "esp_console.h"
#include "mbedtls/base64.h" #include "mbedtls/base64.h"
@@ -18,6 +20,9 @@
#define USER_CONSOLE_KEY_LINE_CAPACITY 256U #define USER_CONSOLE_KEY_LINE_CAPACITY 256U
/* `user` commands are serialized by the administration gate. */
static user_database_snapshot_t s_user_snapshot;
static void print_usage(void) static void print_usage(void)
{ {
printf("Usage:\n"); printf("Usage:\n");
@@ -30,6 +35,7 @@ static void print_usage(void)
printf(" user role <username> <user|admin> --force\n"); printf(" user role <username> <user|admin> --force\n");
printf(" user password <username> [--generate]\n"); printf(" user password <username> [--generate]\n");
printf(" user key add <username>\n"); printf(" user key add <username>\n");
printf(" user key add <username> <type> <base64>\n");
printf(" user key delete <username> <0..2> --force\n"); printf(" user key delete <username> <0..2> --force\n");
printf(" user key clear <username> --force\n"); printf(" user key clear <username> --force\n");
} }
@@ -88,23 +94,22 @@ static void print_user(const user_database_user_snapshot_t *user)
static int show_users(const char *selected) static int show_users(const char *selected)
{ {
user_database_snapshot_t snapshot; esp_err_t error = user_database_get_snapshot(&s_user_snapshot);
esp_err_t error = user_database_get_snapshot(&snapshot);
if (error != ESP_OK) { if (error != ESP_OK) {
printf("User database unavailable: %s\n", esp_err_to_name(error)); printf("User database unavailable: %s\n", esp_err_to_name(error));
return 1; return 1;
} }
if (selected == NULL) { if (selected == NULL) {
printf("User database: generation=%lu users=%u/%u admins=%u bootstrapped=%s\n", printf("User database: generation=%lu users=%u/%u admins=%u bootstrapped=%s\n",
(unsigned long)snapshot.generation, (unsigned long)s_user_snapshot.generation,
(unsigned int)snapshot.user_count, (unsigned int)s_user_snapshot.user_count,
USER_DATABASE_MAX_USERS, USER_DATABASE_MAX_USERS,
(unsigned int)snapshot.admin_count, (unsigned int)s_user_snapshot.admin_count,
snapshot.admin_bootstrapped ? "yes" : "no"); s_user_snapshot.admin_bootstrapped ? "yes" : "no");
} }
bool found = false; bool found = false;
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) { for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) {
const user_database_user_snapshot_t *user = &snapshot.users[index]; const user_database_user_snapshot_t *user = &s_user_snapshot.users[index];
if (!user->active || if (!user->active ||
(selected != NULL && (selected != NULL &&
(strlen(selected) != user->username_length || (strlen(selected) != user->username_length ||
@@ -118,7 +123,7 @@ static int show_users(const char *selected)
printf("User '%s' not found.\n", selected); printf("User '%s' not found.\n", selected);
return 1; return 1;
} }
if (!snapshot.admin_bootstrapped) { if (!s_user_snapshot.admin_bootstrapped) {
printf("Administrative network access is not bootstrapped; use 'user bootstrap'.\n"); printf("Administrative network access is not bootstrapped; use 'user bootstrap'.\n");
} }
return 0; return 0;
@@ -297,6 +302,42 @@ static bool key_delimiter(uint8_t value)
return value == ' ' || value == '\t'; return value == ' ' || value == '\t';
} }
static int add_key_parts(const char *username,
const uint8_t *type, size_t type_length,
const uint8_t *encoded, size_t encoded_length)
{
uint8_t blob[USER_DATABASE_SSH_KEY_BLOB_CAPACITY] = {0};
size_t blob_length = 0U;
int decoded = mbedtls_base64_decode(blob, sizeof(blob), &blob_length,
encoded, encoded_length);
if (decoded != 0 || !user_database_key_valid(type, type_length, blob, blob_length)) {
printf("Unsupported or malformed key; use ssh-ed25519 or ecdsa-sha2-nistp256.\n");
secure_wipe(blob, sizeof(blob));
return 1;
}
uint8_t key_index = 0U;
esp_err_t error = user_database_add_ssh_key(
(const uint8_t *)username, strlen(username), type, type_length,
blob, blob_length, &key_index);
secure_wipe(blob, sizeof(blob));
if (error != ESP_OK) {
if (error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY) {
printf("Could not add SSH key: that public key is already assigned to this account.\n");
} else if (error == ESP_ERR_NO_MEM) {
printf("Could not add SSH key: the account already has %u keys.\n",
USER_DATABASE_MAX_SSH_KEYS_PER_USER);
} else {
printf("Could not add SSH key: %s\n", esp_err_to_name(error));
}
return 1;
}
revoke_user_network_sessions(username);
printf("SSH public key added at index %u. Public-key login is active.\n",
(unsigned int)key_index);
return 0;
}
static int add_key(const char *username) static int add_key(const char *username)
{ {
uint8_t line[USER_CONSOLE_KEY_LINE_CAPACITY] = {0}; uint8_t line[USER_CONSOLE_KEY_LINE_CAPACITY] = {0};
@@ -338,42 +379,15 @@ static int add_key(const char *username)
size_t encoded_length = encoded_end == NULL size_t encoded_length = encoded_end == NULL
? remaining ? remaining
: (size_t)(encoded_end - encoded); : (size_t)(encoded_end - encoded);
uint8_t blob[USER_DATABASE_SSH_KEY_BLOB_CAPACITY] = {0}; int result = add_key_parts(username, line, type_length, encoded, encoded_length);
size_t blob_length = 0U;
int decoded = mbedtls_base64_decode(blob, sizeof(blob), &blob_length,
encoded, encoded_length);
if (decoded != 0 ||
!user_database_key_valid(line, type_length, blob, blob_length)) {
printf("Unsupported or malformed key; use ssh-ed25519 or ecdsa-sha2-nistp256.\n");
secure_wipe(blob, sizeof(blob));
secure_wipe(line, sizeof(line)); secure_wipe(line, sizeof(line));
return 1; return result;
} }
uint8_t key_index = 0U; static int command_user_inner(int argc, char **argv)
error = user_database_add_ssh_key((const uint8_t *)username, strlen(username),
line, type_length, blob, blob_length, &key_index);
secure_wipe(blob, sizeof(blob));
secure_wipe(line, sizeof(line));
if (error != ESP_OK) {
if (error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY) {
printf("Could not add SSH key: that public key is already assigned to this account.\n");
} else if (error == ESP_ERR_NO_MEM) {
printf("Could not add SSH key: the account already has %u keys.\n",
USER_DATABASE_MAX_SSH_KEYS_PER_USER);
} else {
printf("Could not add SSH key: %s\n", esp_err_to_name(error));
}
return 1;
}
revoke_user_network_sessions(username);
printf("SSH public key added at index %u. Public-key login is active.\n",
(unsigned int)key_index);
return 0;
}
static int command_user(int argc, char **argv)
{ {
bool remote = admin_ssh_console_dispatch_is_remote();
const user_principal_t *principal = admin_ssh_console_dispatch_principal();
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0) || if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0) ||
(argc == 2 && strcmp(argv[1], "list") == 0)) { (argc == 2 && strcmp(argv[1], "list") == 0)) {
return show_users(NULL); return show_users(NULL);
@@ -383,6 +397,10 @@ static int command_user(int argc, char **argv)
} }
if (argc == 3 && strcmp(argv[1], "recover") == 0 && if (argc == 3 && strcmp(argv[1], "recover") == 0 &&
strcmp(argv[2], "--force") == 0) { strcmp(argv[2], "--force") == 0) {
if (remote) {
printf("User database recovery is restricted to physical UART0.\n");
return 1;
}
return recover_database(); return recover_database();
} }
if ((argc == 2 || argc == 3) && strcmp(argv[1], "bootstrap") == 0) { if ((argc == 2 || argc == 3) && strcmp(argv[1], "bootstrap") == 0) {
@@ -391,6 +409,10 @@ static int command_user(int argc, char **argv)
print_usage(); print_usage();
return 1; return 1;
} }
if (remote) {
printf("Administrator bootstrap is restricted to physical UART0.\n");
return 1;
}
return bootstrap(generated); return bootstrap(generated);
} }
if ((argc == 4 || argc == 5) && strcmp(argv[1], "add") == 0) { if ((argc == 4 || argc == 5) && strcmp(argv[1], "add") == 0) {
@@ -438,12 +460,23 @@ static int command_user(int argc, char **argv)
print_usage(); print_usage();
return 1; return 1;
} }
if (remote && generated && principal != NULL &&
strlen(argv[2]) == principal->username_length &&
memcmp(argv[2], principal->username, principal->username_length) == 0) {
printf("Remote generated-password changes for the current admin are disabled; use UART0.\n");
return 1;
}
return change_password(argv[2], generated); return change_password(argv[2], generated);
} }
if (argc == 4 && strcmp(argv[1], "key") == 0 && if (argc == 4 && strcmp(argv[1], "key") == 0 &&
strcmp(argv[2], "add") == 0) { strcmp(argv[2], "add") == 0) {
return add_key(argv[3]); return add_key(argv[3]);
} }
if (argc == 6 && strcmp(argv[1], "key") == 0 &&
strcmp(argv[2], "add") == 0) {
return add_key_parts(argv[3], (const uint8_t *)argv[4], strlen(argv[4]),
(const uint8_t *)argv[5], strlen(argv[5]));
}
if (argc == 6 && strcmp(argv[1], "key") == 0 && if (argc == 6 && strcmp(argv[1], "key") == 0 &&
strcmp(argv[2], "delete") == 0 && strcmp(argv[5], "--force") == 0) { strcmp(argv[2], "delete") == 0 && strcmp(argv[5], "--force") == 0) {
uint8_t index; uint8_t index;
@@ -477,6 +510,18 @@ static int command_user(int argc, char **argv)
return 1; return 1;
} }
static int command_user(int argc, char **argv)
{
esp_err_t error = admin_command_gate_take();
if (error != ESP_OK) {
printf("Administrative command gate unavailable: %s\n", esp_err_to_name(error));
return 1;
}
int result = command_user_inner(argc, argv);
admin_command_gate_give();
return result;
}
esp_err_t user_console_register_commands(void) esp_err_t user_console_register_commands(void)
{ {
const esp_console_cmd_t command = { const esp_console_cmd_t command = {
+1
View File
@@ -146,6 +146,7 @@ esp_err_t user_database_remove_ssh_key(const uint8_t *username,
esp_err_t user_database_clear_ssh_keys(const uint8_t *username, esp_err_t user_database_clear_ssh_keys(const uint8_t *username,
size_t username_length); size_t username_length);
bool user_database_username_valid(const uint8_t *username, size_t length); bool user_database_username_valid(const uint8_t *username, size_t length);
bool user_database_password_valid(const uint8_t *password, size_t length); bool user_database_password_valid(const uint8_t *password, size_t length);
bool user_database_key_valid(const uint8_t *key_type, size_t key_type_length, bool user_database_key_valid(const uint8_t *key_type, size_t key_type_length,
+10 -61
View File
@@ -9,18 +9,16 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include "driver/uart.h" #include "console_input.h"
#include "esp_console.h" #include "esp_console.h"
#include "esp_err.h" #include "esp_err.h"
#include "esp_netif_ip_addr.h" #include "esp_netif_ip_addr.h"
#include "esp_wifi_types.h" #include "esp_wifi_types.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "network_console.h" #include "network_console.h"
#include "wifi_config.h" #include "wifi_config.h"
#include "wifi_manager.h" #include "wifi_manager.h"
#define WIFI_CONSOLE_UART UART_NUM_0
#define WIFI_CONSOLE_SECRET_CAPACITY WIFI_CONFIG_PSK_MAX_LEN #define WIFI_CONSOLE_SECRET_CAPACITY WIFI_CONFIG_PSK_MAX_LEN
static void print_usage(void) static void print_usage(void)
@@ -242,68 +240,19 @@ static esp_err_t apply_candidate(wifi_app_config_t *candidate)
static esp_err_t read_secret_no_echo(uint8_t *secret, uint8_t *secret_len) static esp_err_t read_secret_no_echo(uint8_t *secret, uint8_t *secret_len)
{ {
uint8_t buffer[WIFI_CONSOLE_SECRET_CAPACITY]; uint8_t buffer[WIFI_CONSOLE_SECRET_CAPACITY + 1U] = {0};
size_t length = 0U; size_t length = 0U;
memset(buffer, 0, sizeof(buffer)); esp_err_t error = console_input_read_hidden(
"Enter 8..63 printable ASCII characters (input hidden, Ctrl-C cancels): ",
/* buffer, sizeof(buffer), WIFI_CONFIG_PSK_MIN_LEN,
* esp_console may execute on CR while the terminal's trailing LF is still WIFI_CONFIG_PSK_MAX_LEN, &length);
* arriving. Let that line ending settle, then discard only pre-prompt RX so if (error == ESP_OK) {
* it cannot be mistaken for an immediately submitted empty secret.
*/
vTaskDelay(1U);
esp_err_t flush_error = uart_flush_input(WIFI_CONSOLE_UART);
if (flush_error != ESP_OK) {
wifi_config_secure_wipe(buffer, sizeof(buffer));
printf("Could not prepare secret input: %s\n", esp_err_to_name(flush_error));
return flush_error;
}
printf("Enter 8..63 printable ASCII characters (input hidden, Ctrl-C cancels): ");
fflush(stdout);
for (;;) {
uint8_t byte = 0U;
int received = uart_read_bytes(WIFI_CONSOLE_UART, &byte, 1U, portMAX_DELAY);
if (received != 1) {
wifi_config_secure_wipe(buffer, sizeof(buffer));
printf("\nSecret input failed.\n");
return ESP_FAIL;
}
if (byte == 0x03U) {
wifi_config_secure_wipe(buffer, sizeof(buffer));
printf("\nCancelled.\n");
return ESP_ERR_INVALID_STATE;
}
if (byte == '\r' || byte == '\n') {
break;
}
if (byte == 0x08U || byte == 0x7fU) {
if (length > 0U) {
buffer[--length] = 0U;
}
continue;
}
if (byte < 0x20U || byte > 0x7eU || length >= sizeof(buffer)) {
putchar('\a');
fflush(stdout);
continue;
}
buffer[length++] = byte;
}
putchar('\n');
if (length < WIFI_CONFIG_PSK_MIN_LEN || length > WIFI_CONFIG_PSK_MAX_LEN) {
wifi_config_secure_wipe(buffer, sizeof(buffer));
printf("Secret length must be 8..63 characters.\n");
return ESP_ERR_INVALID_ARG;
}
memset(secret, 0, WIFI_CONFIG_PSK_MAX_LEN); memset(secret, 0, WIFI_CONFIG_PSK_MAX_LEN);
memcpy(secret, buffer, length); memcpy(secret, buffer, length);
*secret_len = (uint8_t)length; *secret_len = (uint8_t)length;
}
wifi_config_secure_wipe(buffer, sizeof(buffer)); wifi_config_secure_wipe(buffer, sizeof(buffer));
return ESP_OK; return error;
} }
static int set_profile(char **argv) static int set_profile(char **argv)