Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e17df0ac5 | ||
|
|
31d3561d47 | ||
|
|
6318f0c770 | ||
|
|
10b6d65fa9 | ||
|
|
4449131079 | ||
|
|
3feb3b5916 | ||
|
|
06bf33b3cf | ||
|
|
c37fab67db | ||
|
|
d9bd86bb84 | ||
|
|
2aafff25be | ||
|
|
6ad6c00d68 | ||
|
|
868e9ebc23 | ||
|
|
26e8bbe905 | ||
|
|
ec56d20b3e | ||
|
|
5ecd88e19f | ||
|
|
35a6f32e8b | ||
|
|
21d1b12f31 | ||
|
|
2f383cd283 | ||
|
|
7b87375980 | ||
|
|
f227a2026f | ||
|
|
c2c11fee4e | ||
|
|
0a1bbd6782 | ||
|
|
44e3962444 | ||
|
|
302b9065bd | ||
|
|
7a4769fdd8 | ||
|
|
c7d0d59f3e | ||
|
|
b7999043ca | ||
|
|
bb8e385354 | ||
|
|
0c058b6a8f | ||
|
|
cd235445c7 | ||
|
|
0c7b763bec | ||
|
|
57a18be9bd | ||
|
|
96e5792273 | ||
|
|
276559536b | ||
|
|
16c0c02389 | ||
|
|
371c0ab896 | ||
|
|
e291e29357 | ||
|
|
c10e7d24a8 | ||
|
|
4aa5ca80da | ||
|
|
ec9ca5e6d2 | ||
|
|
bb04e0ba79 | ||
|
|
fa339a3dfa | ||
|
|
a350ba2f5e | ||
|
|
a58eacb7d2 | ||
|
|
bbcdf37a3b | ||
|
|
d779fb046e | ||
|
|
f6a275c543 | ||
|
|
72d030bc7a | ||
|
|
c018bfe361 | ||
|
|
5f7ea5b79d | ||
|
|
8b5417881c |
@@ -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
|
||||
```
|
||||
|
||||
The first migration from the former default/factory layout to the custom partition table requires `pio run --target erase`. 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.
|
||||
@@ -1,5 +1,15 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
# Route wolfCrypt seeding through the project's sole pre-radio DRBG callback.
|
||||
# wolfSSL's ESP32 AES/SHA locks do not coordinate with ESP-IDF mbedTLS/Wi-Fi;
|
||||
# define the documented software-fallback controls before components are parsed.
|
||||
add_compile_definitions(
|
||||
WC_RNG_SEED_CB
|
||||
NO_WOLFSSL_ESP32_CRYPT_AES
|
||||
NO_WOLFSSL_ESP32_CRYPT_HASH
|
||||
WOLFSSL_ED25519_STREAMING_VERIFY
|
||||
)
|
||||
|
||||
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
|
||||
set(PROJECT_VER "0.1.0")
|
||||
project(esp32_serial_swiss_army_knife)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||

|
||||
|
||||
Universal wireless serial adaptor firmware for the ESP32-S3.
|
||||
ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one MAX3243-backed UART1 serial port and safely shares it between native USB CDC-ACM, an HTTPS/WebSocket browser terminal, and SSH: one connected client can write while others observe. The firmware also provides persistent serial and Wi-Fi configuration, a UART0 recovery console, and hardware diagnostics; it is not a general-purpose router, captive portal, or unauthenticated TCP serial server.
|
||||
|
||||
## Initial hardware target
|
||||
|
||||
@@ -10,13 +10,18 @@ Universal wireless serial adaptor firmware for the ESP32-S3.
|
||||
- ESP32-S3-WROOM-1-N16R8 module
|
||||
- 16 MB flash
|
||||
- 8 MB octal PSRAM
|
||||
- Adafruit MAX3243 full-pinout RS-232 breakout, product 5988
|
||||
- Adafruit MAX3243 full-pinout RS-232 breakout; the male connector version is preferred (see [Hardware wiring](docs/wiring.md) for supported connector variants)
|
||||
|
||||
The firmware has completed **Phase 0 hardware characterization**, the **Phase 1 serial-core foundation**, the **Phase 2 transport-neutral session broker**, and the first real broker transport through native USB CDC-ACM. The current phase adds persistent multi-profile Wi-Fi station configuration and managed AP fallback/AP+STA operation. The MAX3243 diagnostics and recovery consoles remain available. No electrical test starts automatically; UART1 starts when requested explicitly or when a host opens native USB CDC.
|
||||
## Development status
|
||||
|
||||
## Hardware wiring
|
||||
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, Phase 8B's role-aware HTTPS/SSH authentication and revocation, and Phase 8C's shared UART0/admin-SSH command shell are target-hardware validated. Phase 8D remains in progress: explicit browser sessions, exact-session serial/admin WebSockets, the canonical browser admin shell, typed serial controls, guided user/password/role/authorized-key management, generation-safe Wi-Fi profile/AP/secret editing and saving, display-aging controls, broker-client popovers, and atomic writer transfer are implemented. Switching terminal modes preserves the browser serial client and writer lease. Service/session administration, network diagnostics, security/danger operations, and unusual hardware/debug commands remain in the Admin shell rather than guided forms. All current Phase 8D target-hardware validation remains pending. Configurable STA-only mDNS naming as `sak-<suffix>.local` is implemented with independent NVS persistence; target-hardware validation is pending. See the [Roadmap](docs/roadmap.md) for phase status and validation details.
|
||||
|
||||
See [`wiring.md`](wiring.md) for the hardware profile, GPIO assignments, loopback diagrams, safety notes, and the recommended test sequence. The initial profile covers the ESP32-S3-DevKitC-1 N16R8 and the Adafruit MAX3243 full-pinout RS-232 breakout.
|
||||
## Documentation
|
||||
|
||||
- [Hardware wiring](docs/wiring.md): hardware profile, GPIO assignments, connector guidance, and safety notes.
|
||||
- [Electrical tests](docs/electrical_tests.md): OLED/buttons, MAX3243, UART loopback, and session-broker verification procedures.
|
||||
- [Role-based user database and UART0 administration](docs/user_administration_tests.md): user migration and administration, HTTPS/SSH authentication, session revocation, and the pending integrated web-administration validation matrix.
|
||||
- [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands.
|
||||
|
||||
## Flash partition layout
|
||||
|
||||
@@ -24,22 +29,22 @@ The N16R8 target has 16 MiB flash and 8 MiB octal PSRAM. PlatformIO uses the cus
|
||||
|
||||
| Partition | Offset | Size | Purpose |
|
||||
|---|---:|---:|---|
|
||||
| `nvs` | `0x009000` | 512 KiB | Serial configuration and future Wi-Fi/provisioning data |
|
||||
| `nvs` | `0x009000` | 512 KiB | Serial, Wi-Fi, mDNS hostname, local-display, role-based user, legacy recovery credential, HTTPS identity, and SSH host-key data |
|
||||
| `otadata` | `0x089000` | 8 KiB | Active OTA-slot selection metadata |
|
||||
| `phy_init` | `0x08B000` | 4 KiB | Optional PHY initialization data |
|
||||
| `nvs_key` | `0x08C000` | 4 KiB | Reserved for future encrypted-NVS keys |
|
||||
| `coredump` | `0x08D000` | 128 KiB | Reserved for flash core dumps |
|
||||
| `ota_0` | `0x0B0000` | 4 MiB | Primary application/OTA slot |
|
||||
| `ota_1` | `0x4B0000` | 4 MiB | Alternate application/OTA slot |
|
||||
| `storage` | `0x8B0000` | 7488 KiB | Future LittleFS web assets, certificates, logs, and files |
|
||||
| `storage` | `0x8B0000` | 7488 KiB | Reserved for future LittleFS web assets, logs, and files |
|
||||
|
||||
Application offsets are aligned to the ESP32-S3's required 64 KiB boundary. The final storage partition ends at `0x1000000`, exactly the end of the 16 MiB flash chip.
|
||||
|
||||
The partition table reserves OTA and LittleFS space but does not by itself implement OTA downloads, rollback confirmation, core-dump handling, NVS encryption, or filesystem mounting. Those features will be enabled deliberately in later phases.
|
||||
The table reserves OTA and storage space; it does not implement OTA downloads, rollback confirmation, core-dump handling, NVS encryption, or filesystem mounting.
|
||||
|
||||
### One-time migration from the default partition table
|
||||
|
||||
The previous 1 MiB factory application began at `0x10000`, which is now inside the enlarged NVS address range. A normal upload does not erase all stale bytes there. Perform a full flash erase once when first switching to this layout:
|
||||
The previous 1 MiB factory application began at `0x10000`, which now lies inside the enlarged NVS range. A normal upload does not erase stale data in that range. When first switching to this layout, erase the flash completely:
|
||||
|
||||
```sh
|
||||
pio run --target erase
|
||||
@@ -47,9 +52,7 @@ pio run --target upload
|
||||
pio device monitor -b 115200
|
||||
```
|
||||
|
||||
This erases the currently saved serial configuration and all other flash contents. The firmware will boot with safe serial defaults and recreate NVS. Subsequent ordinary uploads do not require another full erase.
|
||||
|
||||
PlatformIO's application-size report should now use the 4 MiB `ota_0` slot instead of the previous 1 MiB factory partition.
|
||||
This removes saved serial configuration and all other flash contents. The firmware recreates NVS with safe defaults. Subsequent ordinary uploads do not need a full erase.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -66,245 +69,16 @@ pio run --target upload
|
||||
pio device monitor -b 115200
|
||||
```
|
||||
|
||||
The firmware starts an interactive console on UART0 with the prompt `serial-tool>`. Type `help` to display concise root-command descriptions. This USB-to-UART device normally appears as `/dev/ttyUSB*`; it is separate from the native USB CDC serial transport described below.
|
||||
The firmware provides an interactive UART0 console at `serial-tool>`. Run `help` for available commands. The USB-to-UART bridge normally appears as `/dev/ttyUSB*`; it is separate from the native USB CDC serial transport, which normally appears as `/dev/ttyACM*`.
|
||||
|
||||
The console provides line editing, history for the current session, terminal-aware cursor movement, and Tab completion. ESP-IDF supplies completion for root commands; the project extends it to nested `debug`, `serial`, `broker`, `usb`, and `wifi` subcommands plus safe fixed values such as AP policy and serial framing. Password values are intentionally never completion candidates.
|
||||
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.
|
||||
|
||||
ESP-IDF normally probes terminal cursor support once while constructing the UART REPL. If the board boots without a terminal attached, that probe times out and selects dumb mode. The DevKit's USB-to-UART bridge exposes no host-open signal that firmware can monitor, and entering enhanced mode before a terminal exists would block linenoise while it waits for a cursor-position response.
|
||||
Serial, Wi-Fi, display-aging, and mDNS hostname edits remain in RAM until explicitly saved with `serial save`, `wifi save`, `display save`, or `mdns save`. Authenticated admin SSH sessions and the admin-only browser shell expose the shared operational administration registry, including interactive secrets, recovery-material management, network diagnostics, and deferred self-affecting lifecycle commands. Initial administrator bootstrap and explicit recovery of an unavailable user database remain UART0-only. A remotely connected administrator also cannot generate a replacement password for its own account, preventing the one-time value from being lost when that mutation revokes the session. `web credentials show` exposes only the legacy migration/recovery credential, not an active Phase 8B network login.
|
||||
|
||||
The project therefore preserves safe dumb mode until the first real UART byte arrives. Attach picoterm, picocom, minicom, PuTTY, or another ANSI-capable terminal and press Enter once. That empty line is consumed by the basic reader and promotes the next prompt to enhanced mode, enabling history, Delete, cursor keys, and completion without rebooting. This detects terminal activity rather than electrical USB attachment; a genuinely non-ANSI terminal is not automatically distinguishable on this hardware profile.
|
||||
## Security notes
|
||||
|
||||
The root-level lifecycle command is:
|
||||
|
||||
```text
|
||||
reboot
|
||||
```
|
||||
|
||||
It acknowledges the request, waits briefly for UART output to drain, and calls the ESP-IDF software restart. RAM-only serial or Wi-Fi changes are lost unless they were persisted first with `serial save` or `wifi save`.
|
||||
|
||||
### Phase 1 serial service
|
||||
|
||||
The `serial` command manages the working configuration and UART1 service:
|
||||
|
||||
```text
|
||||
serial status
|
||||
serial start
|
||||
serial stop
|
||||
serial set <baud|data-bits|parity|stop-bits|flow|dtr|rts-threshold> <value>
|
||||
serial save
|
||||
serial load
|
||||
serial defaults
|
||||
serial reset
|
||||
serial counters
|
||||
serial clear-counters
|
||||
```
|
||||
|
||||
Safe defaults are 115200 baud, 8 data bits, no parity, one stop bit, no flow control, and inactive DTR. Supported configuration values are:
|
||||
|
||||
| Parameter | Values |
|
||||
|---|---|
|
||||
| `baud` | 110–1000000 |
|
||||
| `data-bits` | `7`, `8` |
|
||||
| `parity` | `none`, `even`, `odd` |
|
||||
| `stop-bits` | `1`, `2` |
|
||||
| `flow` | `none`, `rts-cts` |
|
||||
| `dtr` | `inactive`, `active`, `on-connect` |
|
||||
| `rts-threshold` | 1–127 bytes |
|
||||
|
||||
`serial set` changes the working configuration and safely restarts UART1 if the service is running. It does not write flash; use `serial save` to commit the current configuration to NVS. `serial defaults` changes RAM only, while `serial reset` applies and persists defaults. The firmware never erases the shared NVS partition automatically when storage is incompatible or unavailable.
|
||||
|
||||
The service uses independent software RX and TX streams. Calls into those streams are nonblocking, and a deasserted CTS cannot block service shutdown. UART data access is intentionally reserved for the session broker; the `serial` command controls configuration and lifecycle only.
|
||||
|
||||
UART1 has exclusive ownership while the service runs. Phase 0 commands will refuse to touch the port until `serial stop` releases it.
|
||||
|
||||
### Phase 2 session broker
|
||||
|
||||
The broker is initialized at boot and continuously drains the serial service whenever UART1 is running. It is transport-neutral: console test clients and native USB CDC use the same API that WebSocket and SSH transports will use later.
|
||||
|
||||
```text
|
||||
broker status
|
||||
broker clients
|
||||
broker counters
|
||||
broker clear-counters
|
||||
broker connect <name>
|
||||
broker disconnect <client-id>
|
||||
broker request-writer <client-id>
|
||||
broker release-writer <client-id>
|
||||
broker force-writer <client-id|none>
|
||||
broker send-hex <client-id> <hex-bytes>
|
||||
broker read <client-id> [maximum-bytes]
|
||||
broker events <client-id>
|
||||
```
|
||||
|
||||
Each connection receives a generation-safe numeric ID. Stale IDs from disconnected clients cannot address a newly reused slot. Up to eight clients may connect, each with a bounded 4096-byte output queue and a 16-entry event queue.
|
||||
|
||||
UART RX is copied to every connected client. A full observer queue drops bytes only for that observer and records the loss; it never blocks UART reception or another client. With no clients, the broker still drains UART data and records it as unobserved.
|
||||
|
||||
Exactly one client may hold the writer lease. Competing requests are denied and generate events. Administrative forced reassignment atomically revokes the old writer and grants the new one. Bytes already accepted before revocation remain queued for transmission; revocation prevents future admission rather than purging the UART TX stream.
|
||||
|
||||
Connect, disconnect, writer grant, release, revoke, and denial events have a broker-global sequence number. Event queues are intentionally bounded, so future transports should reconcile sequence gaps against broker snapshots. The first and last broker connection also drive the Phase 1 `DTR=on-connect` policy.
|
||||
|
||||
### Native USB CDC-ACM transport
|
||||
|
||||
The ESP32-S3's native USB OTG peripheral presents one CDC-ACM serial interface through the development board's connector labelled **USB**. It uses GPIO19 (`USB D-`) and GPIO20 (`USB D+`) and normally appears on Linux as `/dev/ttyACM*`. It is not the USB-to-UART bridge used for upload and logs.
|
||||
|
||||
The UART0 development console provides these diagnostics and controls:
|
||||
|
||||
```text
|
||||
usb
|
||||
usb help
|
||||
usb status
|
||||
usb counters
|
||||
usb clear-counters
|
||||
usb request-writer
|
||||
usb release-writer
|
||||
```
|
||||
|
||||
Both `usb` and `usb help` print the same multi-line command summary and return successfully; runtime state is shown explicitly with `usb status`.
|
||||
|
||||
Opening the CDC port with DTR asserted automatically starts UART1, connects a broker client named `usb-cdc`, and requests the writer lease. If another client already owns the lease, USB remains connected as a read-only observer; `usb status` reports its current role. Closing the port or unplugging native USB disconnects that broker client and discards transport-local pending data. The serial service itself remains running until it is stopped explicitly with `serial stop`.
|
||||
|
||||
The data path is binary-transparent. UTF-8 bytes, NUL bytes, terminal escape sequences, and color sequences are passed unchanged; interpretation remains the terminal application's responsibility. USB output is bounded and nonblocking, so a host that stops reading can lose only its own observer data rather than stall UART1 or another client.
|
||||
|
||||
Host line coding is accepted for baud rates 110–1000000 with 7 or 8 data bits, none/odd/even parity, and 1 or 2 stop bits. USB's 1.5 stop bits and mark/space parity are rejected. Supported settings are applied to the working UART configuration only when USB owns the writer lease and queued UART TX has drained. They are not saved to NVS automatically; use `serial save` deliberately if the setting should survive reboot. USB RTS is reported as host status only. It does not drive the physical RS-232 RTS line, which remains controlled by UART1's configured RTS/CTS flow control.
|
||||
|
||||
The development VID/PID comes from Espressif's TinyUSB defaults. The USB serial-number string is derived from the ESP32-S3 station MAC so multiple adapters can be distinguished consistently.
|
||||
|
||||
#### Linux loopback validation
|
||||
|
||||
Keep the USB-to-UART cable connected for logs and commands, and connect a second data-capable cable to the native **USB** connector. On the host, identify the new CDC device:
|
||||
|
||||
```sh
|
||||
dmesg
|
||||
ls -l /dev/ttyACM*
|
||||
```
|
||||
|
||||
With power removed and no external RS-232 peer attached, connect only DE-9 pin 3 (`TX`) to pin 2 (`RX`), then power the board. Open the native port with a serial terminal such as:
|
||||
|
||||
```sh
|
||||
picocom -b 115200 /dev/ttyACM0
|
||||
```
|
||||
|
||||
Use the actual device path assigned by the host. Typed data should return through USB → broker → UART1 → MAX3243 loopback → broker → USB. On the UART0 console, verify `usb status`, `usb counters`, `broker clients`, and `serial status`. The USB client should normally be the writer and counters should increase without drops.
|
||||
|
||||
For a binary check, install PySerial on the host and send all byte values:
|
||||
|
||||
```python
|
||||
import serial
|
||||
|
||||
payload = bytes(range(256))
|
||||
with serial.Serial("/dev/ttyACM0", 115200, timeout=2) as port:
|
||||
port.reset_input_buffer()
|
||||
port.write(payload)
|
||||
echoed = port.read(len(payload))
|
||||
|
||||
assert echoed == payload, (len(echoed), echoed.hex())
|
||||
print("256-byte binary USB/RS-232 loopback passed")
|
||||
```
|
||||
|
||||
Close the terminal and check `usb status` and `broker clients`; DTR-aware applications should cause the USB broker client to disconnect. Physically unplugging the native USB cable is the definitive detach test. To test observer mode, assign a console test client as writer before opening `/dev/ttyACM0`; USB should connect as an observer, receive UART output, and discard host-originated input until ownership is granted.
|
||||
|
||||
Power down and remove the DE-9 pin 3-to-2 jumper before connecting an external serial peer.
|
||||
|
||||
### Wi-Fi foundation
|
||||
|
||||
Wi-Fi is managed independently of the serial-session broker in this phase. It provides network connectivity and recovery access-point policy for the later HTTPS/WebSocket transport, but it does not yet run HTTP, HTTPS, DNS interception, a captive portal, NAPT, or any TCP serial listener.
|
||||
|
||||
Configuration uses four fixed station-profile slots. Lower numeric priority values are tried first, with slot number breaking ties. Profiles support WPA2/WPA3 mixed operation or require WPA3-SAE. ESP-IDF's station threshold can express “WPA2 or stronger” but not a strict WPA2-only maximum, so the configuration does not pretend to offer a distinct WPA2-only mode. Each profile attempt has a 12-second association/DHCP deadline. After all enabled profiles fail, the manager uses exponential retry delays from 2 to 60 seconds.
|
||||
|
||||
AP policy is independent of the station profiles:
|
||||
|
||||
| Policy | Behavior |
|
||||
|---|---|
|
||||
| `off` | Station only; never start the fallback AP |
|
||||
| `fallback` | Start the AP immediately when no profiles exist, or after one failed profile cycle; disable it after station connectivity has remained stable for 30 seconds |
|
||||
| `always` | Keep AP and station active concurrently |
|
||||
|
||||
Fresh defaults enable Wi-Fi with `fallback` policy, AP channel 6, a MAC-suffixed SSID such as `ESP32-SAK-A1B2C3`, and a randomly generated 16-character password. The initial random credential is saved to NVS automatically when possible so it remains stable across reboot. Retrieve it deliberately from the physical UART0 administration console with `wifi ap show-secret`.
|
||||
|
||||
The `wifi` command provides:
|
||||
|
||||
```text
|
||||
wifi status
|
||||
wifi profiles
|
||||
wifi start|stop|reconnect
|
||||
wifi profile set <slot> <priority> <mixed|wpa3> <ssid>
|
||||
wifi profile secret <slot>
|
||||
wifi profile enable|disable|delete <slot>
|
||||
wifi ap policy <off|fallback|always>
|
||||
wifi ap ssid <ssid>
|
||||
wifi ap channel <1..11>
|
||||
wifi ap secret|show-secret
|
||||
wifi save|load|defaults|reset
|
||||
wifi counters|clear-counters
|
||||
wifi ping <host> [count]
|
||||
wifi nslookup <host>
|
||||
wifi traceroute <host> [max-hops]
|
||||
```
|
||||
|
||||
The network diagnostics are also registered as root aliases, so `ping`, `nslookup`, and `traceroute` are equivalent to their `wifi`-prefixed forms. `ping` accepts 1–20 probes and supports IPv4 or IPv6. `nslookup` prints unique numeric IPv4/IPv6 results. `traceroute` is currently IPv4-only, sends one ICMP Echo probe per hop, accepts 1–30 hops, and uses a one-second timeout per hop; routers that suppress ICMP replies appear as `*`.
|
||||
|
||||
Ordinary status and profile output never displays passwords. `wifi profile secret` and `wifi ap secret` read through a dedicated no-echo UART0 prompt, keeping credentials out of the command line and its history. SSIDs containing spaces can be quoted. Profile and AP edits apply to the working RAM configuration and restart Wi-Fi asynchronously if it is running; use `wifi save` explicitly to persist them. `wifi start` and `wifi stop` also change the working `enabled-at-boot` setting, which becomes persistent only after `wifi save`.
|
||||
|
||||
A typical station setup is:
|
||||
|
||||
```text
|
||||
wifi profile set 0 10 mixed "your SSID"
|
||||
wifi profile secret 0
|
||||
wifi profile enable 0
|
||||
wifi save
|
||||
wifi reconnect
|
||||
wifi status
|
||||
```
|
||||
|
||||
The fallback AP uses Espressif's default `192.168.4.1/24` network for now. AP clients receive addresses through its DHCP server but are not routed to the station network. ESP32-S3 has one 2.4 GHz radio, so in AP+STA mode the AP follows the connected station's channel. Station connection attempts and scans can temporarily increase AP latency, and clients can briefly reconnect when the channel moves.
|
||||
|
||||
Wi-Fi credentials currently reside as plaintext in the application-owned `wifi_app/config` NVS blob. Selecting `WIFI_STORAGE_RAM` prevents the ESP-IDF driver from creating a second persistent credential copy, but it does not encrypt the application's blob. The reserved `nvs_key` partition alone does not enable encryption. NVS encryption, secure boot, flash encryption, and core-dump credential exposure require a deliberate later security phase.
|
||||
|
||||
#### Wi-Fi validation
|
||||
|
||||
1. Boot with no station profiles. `wifi status` should report `ap-only`, and the generated SSID should be visible from another device.
|
||||
2. Use `wifi ap show-secret`, join the AP, confirm a `192.168.4.x` lease, and run `wifi ping 192.168.4.1`. No web page is expected yet.
|
||||
3. Configure and enable a WPA2/WPA3 station profile using the example above. `wifi status` should progress through `connecting`, `waiting-ip`, and `online` and display the acquired address, channel, RSSI, and negotiated authentication.
|
||||
4. Reboot and verify profile and AP credential persistence.
|
||||
5. Configure two profiles with different priorities, make the first unavailable, and verify failover to the second after its timeout.
|
||||
6. Make all profiles unavailable and verify fallback AP startup plus increasing retry delays in `wifi status`/`wifi counters`.
|
||||
7. Test `wifi ap policy always` while online and confirm both interfaces remain available; expect the AP channel to follow the station.
|
||||
8. Test `wifi stop`, `wifi start`, and `wifi reconnect` while confirming UART0 and native USB serial operation remain unaffected.
|
||||
9. If available, test a WPA3-only profile and a wrong password, then inspect the disconnect reason and counters.
|
||||
|
||||
### Phase 0 diagnostics
|
||||
|
||||
The top-level `status` command retains quick MAX3243 signal-state inspection. Potentially disruptive hardware-characterization operations are grouped below `debug` so the primary help page stays concise:
|
||||
|
||||
```text
|
||||
status
|
||||
debug transceiver <enable|disable>
|
||||
debug drivers <tx 0|1> <dtr 0|1> <rts 0|1>
|
||||
debug loopback-a
|
||||
debug loopback-b
|
||||
debug valid-test
|
||||
debug uart-loopback <baud> [8N1|8E1|8O1|8N2|7E1|7O1] [bytes]
|
||||
debug uart-suite
|
||||
debug cts-flow-test
|
||||
debug rts-flow-test
|
||||
```
|
||||
|
||||
Run `debug` without a subcommand for its usage summary. `debug uart-loopback` defaults to `8N1` and 256 bytes. Its accepted payload range is 1–512 bytes. `debug uart-suite` covers 300 through 250000 baud and all supported frame formats. `debug cts-flow-test` verifies transmit gating and exact resumption, while `debug rts-flow-test` uses UART2 as an internal traffic generator to verify automatic receive backpressure. Follow the command-specific loopback wiring in [`wiring.md`](wiring.md) before invoking any test.
|
||||
|
||||
A mutex-protected port lease prevents diagnostics, UART1 service startup, and future clients from reconfiguring the same GPIOs concurrently. If a UART driver cannot be removed during cleanup, the firmware keeps the MAX3243 shut down and marks the port faulted until reboot rather than exposing an ambiguous hardware state.
|
||||
|
||||
The onboard RGB LED reports the most recent test-harness state:
|
||||
|
||||
| Color | Meaning |
|
||||
|---|---|
|
||||
| Blue | Idle; waiting for a command |
|
||||
| Yellow/orange | Test running |
|
||||
| Green | Last test passed |
|
||||
| Red | Last test failed |
|
||||
|
||||
This hardware profile uses the onboard RGB LED on GPIO48. Official ESP32-S3-DevKitC-1 v1.1 boards commonly use GPIO38 instead, and compatible boards or clones may vary. A different board revision requires an adjusted board pin profile before running this firmware.
|
||||
The HTTPS interface uses a device-specific self-signed certificate and an explicit same-origin login/logout flow over TLS; opaque eight-hour browser sessions are held in a bounded RAM table, while raw session tokens are sent only in a host-only `Secure`, `HttpOnly`, `SameSite=Strict` cookie. State-changing web requests require a session-bound CSRF token and strict same-origin validation; there is no plaintext HTTP or TCP serial listener. SSH accepts role-based passwords and authorized Ed25519/ECDSA P-256 public keys. User passwords are stored as salted PBKDF2-HMAC-SHA256 verifiers, but the legacy recovery password, HTTPS private key, SSH private key, and Wi-Fi credentials remain recoverable from unencrypted application-owned NVS blobs. Offline password guessing and stale append-oriented flash copies also remain possible. The reserved `nvs_key` partition does not enable encryption. Do not treat this firmware as resistant to physical flash or RAM extraction until the planned hardening work is complete.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the [GNU General Public License version 3 only](LICENSE) (`GPL-3.0-only`). This is compatible with using the GPLv3 releases of wolfSSL and wolfSSH later. Third-party components remain subject to their respective licenses.
|
||||
This project is licensed under the [GNU General Public License version 3 only](LICENSE) (`GPL-3.0-only`). Third-party components remain subject to their respective licenses. The integration baseline uses Espressif registry components `espressif/mdns` `1.12.0`, `wolfssl/wolfssl` `5.8.2~1`, and `wolfssl/wolfssh` `1.4.20`; review upstream security releases before production use.
|
||||
|
||||
+28
-1
@@ -29,6 +29,16 @@ dependencies:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 3.0.3
|
||||
espressif/mdns:
|
||||
component_hash: 3ba256ac95e07c274be53cbd73f06cb846c403b61e8fbdf1be57bdb79db7a63e
|
||||
dependencies:
|
||||
- name: idf
|
||||
require: private
|
||||
version: '>=5.0'
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.12.0
|
||||
espressif/tinyusb:
|
||||
component_hash: a72b7d67472914ab76309340fd50d578b31e310963d45ad0f81144bde3314752
|
||||
dependencies:
|
||||
@@ -49,10 +59,27 @@ dependencies:
|
||||
source:
|
||||
type: idf
|
||||
version: 5.5.0
|
||||
wolfssl/wolfssh:
|
||||
component_hash: 24d623360c07374a90b1ade8d1218b24bb7b661fd51ace9135dccf510a338927
|
||||
dependencies: []
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 1.4.20
|
||||
wolfssl/wolfssl:
|
||||
component_hash: 4d619e882c19d967bbaa53302e3bd2bdb8c611b5efb13302171aace809fccfdf
|
||||
dependencies: []
|
||||
source:
|
||||
registry_url: https://components.espressif.com/
|
||||
type: service
|
||||
version: 5.8.2~1
|
||||
direct_dependencies:
|
||||
- espressif/esp_tinyusb
|
||||
- espressif/led_strip
|
||||
- espressif/mdns
|
||||
- idf
|
||||
manifest_hash: c088ae17e0ad9f6ac683daf40ffe6018e98fa32aea77d8f2252b0b2703e77298
|
||||
- wolfssl/wolfssh
|
||||
- wolfssl/wolfssl
|
||||
manifest_hash: 4b6fa5a7e06122f9194adfc282a4baf4247d8a114b905e65f6397503e59f0898
|
||||
target: esp32s3
|
||||
version: 2.0.0
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# 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 project-owned application 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. RGB LED initialization is currently guarded by `ESP_ERROR_CHECK` and is therefore boot-fatal.
|
||||
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 and the independent mDNS hostname configuration, persist generated first-boot Wi-Fi defaults when appropriate, initialize the nonfatal mDNS configuration service and Wi-Fi manager, and start Wi-Fi when configured for boot. The Wi-Fi manager owns subsequent mDNS announcement transitions.
|
||||
9. Start HTTPS and SSH only when their startup gates pass. The Wi-Fi portion requires valid configuration and successful manager initialization and, when enabled at boot, successful submission of its asynchronous start request; it does not require association, an IP address, or reachability. Both gates also require HTTPS security readiness, and SSH additionally requires its own security/runtime readiness. The HTTPS-security gate on SSH is an implemented dependency even though SSH has a separate host key.
|
||||
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. Network services start before command registration, so role-`user` serial access can be admitted in that interval while remote admin-console admission is rejected until the administration frontend is ready.
|
||||
|
||||
## Serial service and physical ownership
|
||||
|
||||
`serial_service` owns the UART1 driver while running. It exposes bounded RX/TX streams and a task that:
|
||||
|
||||
- continuously drains UART RX, even if UART event notification is incomplete;
|
||||
- moves pending TX to the UART FIFO without blocking indefinitely on CTS;
|
||||
- discards and accounts queued traffic during shutdown/reconfiguration.
|
||||
|
||||
`rs232_port_owner` separately protects the physical UART/MAX3243 resource:
|
||||
|
||||
- `NONE`: available;
|
||||
- `PHASE0`: hardware diagnostics own it;
|
||||
- `SERVICE`: serial service owns it;
|
||||
- `FAULT`: cleanup could not establish a safe state; reboot is required.
|
||||
|
||||
The owner is cooperative rather than an interceptor for UART/GPIO APIs. Active diagnostic commands claim `PHASE0`, and the running service claims `SERVICE`; boot-time static-safe GPIO initialization and service-owned restoration of that static mode are explicit exceptions. Unsafe cleanup keeps the transceiver disabled and marks a fault rather than attempting continued operation.
|
||||
|
||||
Serial configuration is a working RAM value. Applying it while running performs a stop/restart and attempts rollback on failure. Stop/reconfiguration discards and accounts serial-service RX/TX streams and task-local pending TX, but does not disconnect broker clients or clear their writer lease, events, or already-fanned output. An open USB session retries service start after a stop; existing WebSocket and role-`user` SSH sessions do not independently restart it. Persistence is explicit through save/reset commands.
|
||||
|
||||
## Session broker and data flow
|
||||
|
||||
The permanent `session_broker` task is the intended sole consumer/producer of serial-service data.
|
||||
|
||||
### RS-232 to clients
|
||||
|
||||
```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.
|
||||
|
||||
The broker currently enters nonblocking serial read/write APIs while holding its mutex and takes the serial-service state mutex during first-connect/last-disconnect DTR changes. Keep this ordering acyclic: serial-service code must not call broker APIs while holding its state mutex.
|
||||
|
||||
## Transport architecture
|
||||
|
||||
### USB CDC
|
||||
|
||||
`usb_cdc_transport` has a permanent transport task and TinyUSB callbacks. Attached plus host DTR asserted is treated as open. Opening starts the serial service if necessary, creates the `usb-cdc` broker client, and opportunistically requests writer ownership; otherwise USB observes.
|
||||
|
||||
TinyUSB callbacks enqueue/copy data and state; the transport task owns broker lifecycle and forwarding. The line-coding callback records the latest host setting for diagnostics only. It never reconfigures UART1: physical framing and speed remain controlled by the explicit serial configuration, regardless of USB writer ownership.
|
||||
|
||||
### HTTPS, WebSocket, and web serial
|
||||
|
||||
`web_server` runs HTTPS only on port 443 using the device-specific self-signed P-256 certificate from `web_security`. Current routes provide same-origin login/logout, the UI and static assets, status, typed admin operations, and separate serial/admin WebSocket ticket and upgrade paths.
|
||||
|
||||
Browser passwords authenticate through `user_database`; the legacy web credential remains migration/recovery material and is not an active browser login after bootstrap. Successful login creates one of eight fixed eight-hour RAM sessions, with at most two retained per account. The raw 192-bit token is sent only in the host-only `__Host-sak-session` cookie (`Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`) while storage retains its SHA-256 digest, copied principal, generation-safe slot identity, and monotonic expiry. A boot-local key derives a deterministic session-bound CSRF token. Logout and every state-changing endpoint require strict `Origin == https://<Host>` and CSRF validation; a four-entry source-address limiter provides bounded, deliberately modest login throttling.
|
||||
|
||||
The serial WebSocket requires a one-time exact-browser-session-bound ticket with a maximum 30-second lifetime. Tickets are stored as digests, consumed before currentness validation, and never persisted. Admission and active input revalidate both the copied principal and exact browser-session reference. An admitted serial connection starts the service if necessary, creates a broker client, and opportunistically requests writer ownership. The serial transport has two fixed slots and four ticket slots. Binary frames carry serial data; small text messages request or release writer ownership. HTTPD owns socket send/close operations, while the permanent transport task mediates broker work through bounded scheduling.
|
||||
|
||||
Administrators can separately mint a one-time ticket for one fixed browser admin-console WebSocket. That transport has its own permanent task, bounded input/output storage, copied admin principal, exact browser-session reference, and a single canonical-console slot; it never starts serial service or joins the broker. Terminal-mode switching changes only DOM visibility/focus and lazily opens the admin route, leaving the serial socket and writer lease alive. The combined Connect/Disconnect serial control explicitly manages only the serial route.
|
||||
|
||||
Typed admin endpoints cover full serial framing/lifecycle/persistence operations; Wi-Fi lifecycle/profile rotation; guided user CRUD, roles, entered/generated passwords, and authorized Ed25519/P-256 key add/remove; generation-safe station-profile/AP/secret editing and exact-generation save; display-aging apply/save/load/defaults/reset; a bounded secret-free broker-client list; and atomic expected-writer-to-target transfer. All authenticate the admin role server-side; mutators revalidate the exact browser session after their bounded body is parsed and immediately before the typed side effect. The common URL-form parser decodes in its body buffer, accepts at most 512 bytes and 10 unique fields, and rejects duplicate fields. User edits compare the database generation plus stable user ID; Wi-Fi edits compare the working-config generation, and reads disclose only `secret_set` flags. On stale conflicts the browser reloads user/Wi-Fi state without replaying the request and clears entered/generated secret fields on failure or Settings close.
|
||||
|
||||
The Admin shell remains the web route for broader service/session controls, network diagnostics, security/danger operations, and unusual hardware/debug commands; those operations do not have guided forms.
|
||||
|
||||
Serial and admin WebSocket initialization are failure-isolated from base HTTPS. An unavailable transport leaves login and non-WebSocket routes available. A lifecycle mutex serializes start, stop, and TLS refresh; a generation records explicit desired-running intent so a post-material refresh cannot override a newer request. Certificate rotation and full material reset require that refresh after persistence. HTTPS stop first disables further transport-owned HTTPD calls and tracks calls already in progress. Failed HTTPD destruction retains the handle for a retry, while a timed-out or failed admin detach is finalized only after successful HTTPD destruction and remains pending for retry before a later start.
|
||||
|
||||
`web_ui.c` contains authored index/application strings and response policy. Its restrictive CSP contains a hard-coded hash of the inline loader, so those two must change atomically; preserve same-origin connections, no-referrer behavior, frame denial, and the existing cache policy. `web_assets_data.c` contains checked-in generated arrays for vendored compressed xterm assets and the logo. Normal builds compile these arrays directly; they do not regenerate assets.
|
||||
|
||||
### SSH
|
||||
|
||||
`ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers.
|
||||
|
||||
Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`.
|
||||
|
||||
Routing follows the authenticated role:
|
||||
|
||||
- `user`: start the serial service if necessary, then create a broker-backed binary-transparent serial stream and opportunistically request writer ownership;
|
||||
- `admin`: bounded administration console, with no broker client or writer lease.
|
||||
|
||||
A shell request is required, but project code does not explicitly require a PTY. Exec and subsystem requests are rejected, and there is no project SFTP, SCP, agent-forwarding, or TCP-forwarding route.
|
||||
|
||||
## Authentication, authorization, and revocation
|
||||
|
||||
`user_database` is a fixed-capacity, mutex-protected store: at most eight accounts and three authorized keys per account. Accounts have `user` or `admin` role, random account ID, and authentication generation. Passwords are salted PBKDF2-HMAC-SHA256 verifiers; plaintext passwords are not retained in the database.
|
||||
|
||||
Network code holds copied, secret-free principals rather than pointers into database records. Principal currentness requires matching username, account ID, role, and authentication generation. Password, role, or key changes increment the generation; deletion/recreation also changes the account ID.
|
||||
|
||||
Revocation has two layers:
|
||||
|
||||
1. `user_admin_service` serializes typed web and console mutations with `admin_command_gate`; after a database mutation commits, it makes best-effort targeted WebSocket/SSH revocation calls, and notification failure does not roll back the mutation;
|
||||
2. transports periodically and at sensitive boundaries recheck principal currentness, providing authoritative fail-safe closure if notification fails.
|
||||
|
||||
The web user editor supplies an expected database generation for every mutation and the stable target user ID for existing-account operations. A mismatch rejects stale state, including delete/recreate of the same username. The final administrator remains protected, and remote self-generated password replacement is rejected so its one-time result cannot be lost during revocation.
|
||||
|
||||
The final administrator cannot be deleted or demoted. UART0 is trusted for initial administrator bootstrap and explicit unavailable-database recovery. Authenticated admin SSH and the browser Admin shell can run the operational registry but are denied those two recovery operations; other secret-bearing commands are remotely available unless their handlers deny them.
|
||||
|
||||
NVS is not encrypted. Password verifiers improve password storage, but Wi-Fi credentials, legacy recovery credentials, and TLS/SSH private keys remain recoverable under physical flash extraction.
|
||||
|
||||
## Console architecture
|
||||
|
||||
UART0, admin SSH, and the browser admin shell share canonical command implementations:
|
||||
|
||||
```text
|
||||
UART0 linenoise frontend -----\
|
||||
admin SSH line editor ---------> fixed request queue -> one dispatcher -> esp_console_run()
|
||||
browser admin line editor -----/ |
|
||||
+-> registered *_console handlers
|
||||
```
|
||||
|
||||
`admin_ssh_console` creates the transport-neutral dispatcher before network services but marks command dispatch ready only after ESP-IDF console registration and successful UART frontend task creation. Remote admin admission during that boot window is rejected rather than racing an incomplete registry.
|
||||
|
||||
The dispatcher is the sole caller of `esp_console_run()`, serializing UART0, admin SSH, and browser-admin 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 remote frontends, 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 remote session. SSH and browser admin use the same bounded editor, four-entry history, completion formatter, overlong-line discard state, prompt handling, and generation-safe console tokens. Exact frontend callbacks revalidate the transport/session binding immediately before dispatch. Only the SSH owner task moves SSH ring output through wolfSSH; only the web-admin task queues browser output to HTTPD.
|
||||
|
||||
Remote `exit`, reboot, SSH stop/session disconnect/host-key changes, and web-origin HTTPS stop/restart use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
|
||||
|
||||
## Wi-Fi and persistence
|
||||
|
||||
`wifi_config` owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy `off`, `fallback`, or `always`. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used.
|
||||
|
||||
`wifi_manager` is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP deadlines, profile failover, AP policy, retries/backoff, next-profile requests, and the mDNS announcement lifecycle. `mdns_service` initializes the responder at most once after a validated STA `GOT_IP`; the managed component's own event handlers withdraw and restore the STA announcement across transient connectivity changes, while the project tracks whether announcement is currently expected. Initialization failure is latched rather than retried because partial upstream low-memory initialization is not safely recoverable; mDNS failure is nonfatal. It also reconciles against authoritative driver/netif state so dropped events do not permanently wedge policy. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save. Edits to disabled station profiles are staged in RAM without restarting the radio; enabling/disabling a profile or changing enabled station/AP policy restarts it asynchronously. Start/stop—including local controls—intentionally update the RAM `enabled_at_boot` field.
|
||||
|
||||
A dedicated config-writer mutex serializes complete working-config writers. The typed browser editor copies the credential-bearing config with its exact nonzero generation, modifies and validates the copy, and commits only through compare-and-swap; Save holds the same writer serialization through NVS and persists only the expected generation. Generation mismatch or exhaustion fails closed. Credential-bearing copies are securely wiped, browser reads return only per-profile/AP `secret_set` booleans, and routine status/local UI use secret-free snapshots.
|
||||
|
||||
Persistent namespaces/blobs include:
|
||||
|
||||
- `serial/config`;
|
||||
- `wifi_app/config`;
|
||||
- `mdns_cfg/config`;
|
||||
- `local_ui/config`;
|
||||
- `web_sec/material`;
|
||||
- `user_db/database`;
|
||||
- `ssh_sec/material`.
|
||||
|
||||
Configuration modules generally choose RAM defaults without erasing incompatible storage. Security-material modules fail closed on malformed existing material and require explicit reset. OTA slots, coredump space, an NVS-key partition, and storage are reserved in `partitions.csv`; OTA, NVS encryption, coredump handling, and filesystem mounting are not implemented.
|
||||
|
||||
## Local UI and hardware boundaries
|
||||
|
||||
`board_pins.h` centralizes project-assigned RS-232, diagnostic, RGB LED, and local-UI hardware resources; UART0 GPIOs remain local to `main.c`, and native USB uses platform wiring. `local_display` solely owns I2C0, the SSD1315-compatible OLED, its static framebuffer, and display mutex. Display frames belong to the initiating task. Dirty-page commits and I2C transactions are bounded.
|
||||
|
||||
When button GPIO initialization succeeds, `local_status_ui` starts a firmware-lifetime low-priority task that polls/debounces buttons, renders copied public snapshots, implements aging/wake behavior, and invokes a constrained set of public service APIs for local controls. It collects snapshots before opening a display frame, so service/broker locks are not held across I2C. It never parses CLI output, becomes a broker client, edits credentials, or assigns a writer; emergency action can only release the expected current writer.
|
||||
|
||||
The task can run with an absent OLED, and a fresh button press can request one bounded panel reprobe after successful I2C bus setup. Failed I2C bus creation is not recoverable through that path. The `display` configuration commands and typed `/api/admin/display` aging operations depend on the UI task; web Apply/Save/Load/Defaults/Reset reuse the local-UI validation and persistence contracts and share `admin_command_gate` with console display writers so each complete RAM/NVS operation is serialized. Long confirmation holds protect disruptive local actions, and stuck buttons are quarantined.
|
||||
|
||||
Hardware diagnostics are synchronous console commands. RS-232 tests own the physical port exclusively and restore safe GPIO state; OLED tests reuse the display service rather than taking independent I2C ownership.
|
||||
|
||||
## Concurrency and lifecycle constraints
|
||||
|
||||
- Broker, USB, web-transport, Wi-Fi, and SSH owner tasks are firmware-lifetime tasks; the local-UI task is also firmware-lifetime when button initialization allowed it to start. Stopping a service generally stops its runtime/listener, not the owner task.
|
||||
- Bounded queues, stream buffers, work bursts, and drop counters are part of slow-client and watchdog isolation.
|
||||
- Transport slot generations, account authentication generations/stable IDs, Wi-Fi working-config generations, and HTTPS lifecycle generations solve different stale-reference problems; preserve each domain's checks.
|
||||
- Library/hardware ownership is centralized: serial task owns UART1 while running, display service owns I2C/framebuffer, the SSH owner task owns post-initialization wolfSSH runtime calls, and the console dispatcher owns `esp_console_run()`.
|
||||
- Password authentication performs PBKDF2 outside the user-database mutex and revalidates afterward. Typed user mutations are serialized by `user_admin_service` plus `admin_command_gate`; typed display Apply/Save/Load/Defaults/Reset also use that gate with console display writers. Some password mutation paths derive verifiers while holding the mutation lock, so do not generalize the authentication locking pattern without checking the exact path.
|
||||
- Avoid holding service/database/broker locks across I2C, network sends, or other potentially long operations unless the existing contract explicitly requires it. Preserve the existing broker-before-serial lock order.
|
||||
- Serial RX/TX stream payloads, broker per-client payloads, the transactional user-database candidate, and selected cryptographic allocations prefer PSRAM with internal fallback. The live user database, FreeRTOS control structures, UART driver buffers, and task stacks remain internal where deterministic/cache-disable access matters.
|
||||
- The build disables wolfSSL ESP32 AES/SHA acceleration, and the HTTPS path uses software AES for PSRAM-backed records. This preserves the validated workaround for uncoordinated mbedTLS/wolfSSL hardware-crypto locks and a prior mbedTLS external-RAM DMA watchdog stall.
|
||||
@@ -0,0 +1,166 @@
|
||||
# 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 project-owned, mutex-serialized application 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, the HTTPS authentication cache, and the wolfCrypt seed callback
|
||||
- Constraint: initialization order is security-significant; the DRBG deliberately avoids post-radio reseeding and fails closed at its generation limit. 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: the cooperative owner arbitrates active diagnostics (`PHASE0`) against the service (`SERVICE`); boot-time static-safe GPIO initialization and service-owned static-mode restoration are explicit exceptions. Unsafe cleanup marks `FAULT` until reboot.
|
||||
- Lifecycle: stop/reconfiguration discards serial-service RX/TX and task-local pending bytes, but leaves broker clients, writer ownership, events, and already-fanned output intact. The 16 KiB RX and 8 KiB TX payloads prefer PSRAM; their FreeRTOS controls and UART driver storage remain internal.
|
||||
|
||||
## 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: callbacks retain the latest host line coding only for diagnostics; it never reconfigures UART1. UART1 framing and speed remain controlled by the serial configuration and its explicit persistence commands.
|
||||
|
||||
## 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_session.{h,c}`, `src/web_serial_transport.{h,c}`, `src/web_admin_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: generation-tagged web init/start/stop/TLS refresh and snapshots; HTTP handlers including `/api/admin/users`, `/api/admin/wifi-config`, and `/api/admin/display`; ticket mint/consume; attach/detach/finalize; targeted session revocation
|
||||
- Called by: startup, ESP-IDF HTTPS server, user administration revocation, console/local UI
|
||||
- Dependencies: user database, secure random, broker, successful Wi-Fi manager initialization at boot, mbedTLS/HTTPS server; actual network reachability is an operational prerequisite, not an initializer invariant
|
||||
- Flow: `browser -> HTTPS login session -> exact-session ticket -> serial WebSocket -> broker`; admin sessions may separately use `admin ticket -> admin WebSocket -> canonical dispatcher` without joining the broker.
|
||||
- Ownership: HTTPD owns socket send/close work; separate permanent web tasks own serial broker mediation and browser-admin console I/O; there are two serial slots/four serial tickets and one admin slot/two admin tickets. HTTPS lifecycle transitions are serialized separately from state snapshots, carry a lifecycle generation, and retain failed-stop/finalizer ownership for retry. The admin transport disables new HTTPD calls during detach and tracks calls already in progress.
|
||||
- Security constraints: eight opaque browser sessions retain digest-only tokens and copied current principals, with at most two sessions per account. Mutations require strict same-origin and session-bound CSRF checks. Typed URL-form bodies decode in their own storage and are limited to 512 bytes/10 unique fields. User and Wi-Fi editors use optimistic generations; user edits also bind stable user IDs, while Wi-Fi reads expose only `secret_set` flags. Terminal-mode switching never closes the serial socket or releases its writer lease; the combined control explicitly connects/disconnects only serial. Changes to the authored inline loader must update its hard-coded CSP hash in the same change.
|
||||
- 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: after caller-side library initialization, one task pinned to core 1 owns runtime wolfSSH contexts/sessions; two fixed generation-tagged slots.
|
||||
- Security constraint: an interactive shell request is required; exec and subsystems are rejected, and no project file-transfer or forwarding route exists. PTY is not explicitly required.
|
||||
|
||||
## 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_admin_service.{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, optimistic mutation results, snapshots
|
||||
- Called by: web and SSH authentication/currentness checks, `/api/admin/users`, and console administration
|
||||
- Dependencies: NVS, secure random, mbedTLS cryptography, web/SSH targeted revocation
|
||||
- Ownership: database mutex protects the internal live record and PSRAM-preferred transactional candidate; password authentication runs PBKDF2 outside the mutex and revalidates afterward. `user_admin_service` is the shared typed mutation boundary for web and console paths: its recursive `admin_command_gate` region serializes snapshot expectation checks plus commit, then performs best-effort web and SSH revocation after a committed change.
|
||||
- Authorization: UART0 exclusively owns initial administrator bootstrap and unavailable-database recovery. Both roles may use browser serial; only current admins may use guided admin APIs, the browser Admin shell, or admin SSH, subject to handler policy.
|
||||
- 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, admin SSH, and the browser Admin shell.
|
||||
|
||||
- Files: `src/admin_ssh_console.{h,c}`, `src/console_input.{h,c}`, `src/console_completion.{h,c}`, `src/system_console.{h,c}` and all `*_console.{h,c}` modules
|
||||
- Entry points: `admin_ssh_console_init()`, `admin_ssh_console_start_uart_frontend()`, `admin_ssh_console_open()`, command registration functions
|
||||
- Called by: startup, UART0 frontend, role-`admin` SSH transport, browser admin transport
|
||||
- Dependencies: ESP-IDF console/linenoise, all command handlers, user-principal currentness
|
||||
- Flow: `UART0/admin SSH/browser admin -> bounded request queue -> one dispatcher -> esp_console_run()`
|
||||
- Ownership: dispatcher is sole `esp_console_run()` caller; the SSH owner exclusively performs post-initialization wolfSSH runtime calls.
|
||||
- Lifecycle: remote session tokens include frontend identity and slot generation; fixed output/history/prompt state is wiped immediately on idle close or after an executing handler returns. Remote `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort output draining.
|
||||
- Constraint: one slow command or prompt serializes all administration. Remote admin-console admission is unavailable until command registration and UART frontend creation complete; supported deferred actions wait only for a bounded application-buffer drain heuristic.
|
||||
|
||||
## 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/mdns_config.{h,c}`, `src/mdns_service.{h,c}`, `src/mdns_console.{h,c}`, `src/network_console.{h,c}`
|
||||
- Interfaces: config defaults/validate/load/save; manager init/start/stop/apply/reconnect/next-profile/snapshot; versioned working-config copy, compare-and-swap, and exact-generation save
|
||||
- Called by: startup, console, local UI, typed web handlers, ESP event callbacks
|
||||
- Dependencies: secure random for default AP password, NVS, ESP-NETIF/Wi-Fi/events, Espressif mDNS, 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. Start/stop, including local controls, intentionally update the RAM `enabled_at_boot` field. Browser edits compare a nonzero working-config generation, and browser Save persists exactly that generation; stale or exhausted generations fail closed. Working-config copies contain PSKs and must be tightly scoped and wiped; web reads expose only `secret_set` flags, and routine status/local UI must use secret-free snapshots.
|
||||
|
||||
## Local display and controls
|
||||
|
||||
**Responsibility:** own OLED I2C/framebuffer operations and present 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}`; shared writer serialization uses `src/admin_command_gate.{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, and typed `/api/admin/display` handlers
|
||||
- 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. Typed web Apply/Save/Load/Defaults/Reset and console display writers share `admin_command_gate`, serializing each complete working-config or persistence operation.
|
||||
- Lifecycle: the low-priority task is firmware-lifetime only if button GPIO initialization succeeds; it still runs with an absent panel so a press can reprobe after successful I2C bus setup. Failed bus creation is not recoverable by that reprobe, and `display` configuration commands depend on the UI task.
|
||||
- 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` 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. RGB LED initialization is currently boot-fatal, and its colors report diagnostic state rather than aggregate firmware health.
|
||||
|
||||
## 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_admin_service.*`, `user_database.*`, `user_console.c`, `/api/admin/users` handlers, transport revocation APIs |
|
||||
| Change Wi-Fi policy or profile persistence | `wifi_manager.*`, `wifi_config.*`, `wifi_console.c`, `/api/admin/wifi-config` handlers |
|
||||
| Change guided display aging | `local_ui_config.*`, `local_status_ui.*`, `/api/admin/display` handlers, `web_ui.c` |
|
||||
| Change HTTPS stop/restart or TLS-material refresh | `web_server.*`, `web_console.c`, `web_admin_transport.*`, deferred control in `admin_ssh_console.*` |
|
||||
| Change station mDNS hostname or persistence | `mdns_service.*`, `mdns_config.*`, `mdns_console.c`, then `wifi_manager.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 |
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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 the current uncommitted worktree 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, uses the shared `esp_console` registry, and has passed target-hardware validation.
|
||||
- Phase 8D is in progress. Explicit browser sessions, exact-session serial/admin WebSocket binding, one browser frontend for the canonical admin dispatcher, typed serial controls, guided user CRUD/roles/passwords/authorized keys, generation-safe Wi-Fi profile/AP/secret editing and save, display-aging operations serialized with console writers through `admin_command_gate`, contextual client/status popovers, atomic writer transfer, and serialized retry-safe HTTPS lifecycle handling are implemented in the current worktree. All current Phase 8D target-hardware validation remains pending.
|
||||
- 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.
|
||||
|
||||
## Recent memory audit
|
||||
|
||||
- Fixed failed-initialization ownership leaks for wolfSSH, partial HTTPS startup, and TinyUSB teardown. Failed teardown now retains ownership and blocks unsafe duplicate initialization.
|
||||
- Serial-service RX/TX stream payloads (16 KiB and 8 KiB effective capacity) now prefer PSRAM with internal fallback; FreeRTOS controls and UART driver buffers remain internal.
|
||||
- The 5,360-byte transactional user-database candidate now prefers PSRAM with internal fallback while the live database remains internal. Candidate contents are wiped after each transaction and wiped/freed on initialization or recovery failure.
|
||||
- UART and admin-SSH completion formatter buffers were reduced from 2 KiB to 1 KiB each; current worst-case output is 890 bytes and overflow remains fail-closed.
|
||||
- Linked RAM fell from 99,508 to 92,188 bytes (7,320 bytes). PSRAM placement of serial payloads additionally removes about 24 KiB of normal internal-heap pressure on the target.
|
||||
- The reviewed mDNS-enabled build uses 94,532 bytes of linked static RAM, 2,344 bytes above the earlier 92,188-byte baseline, and 1,599,765 bytes of flash. Minimizing the managed component saved 112 bytes of linked RAM and about 5.9 KiB flash versus the first mDNS build. Its 4 KiB task stack remains internal, while checked-in settings move general mDNS allocations to PSRAM and disable unused browse, component CLI, AP/ETH, and multiple-instance features. Runtime heap impact still requires target measurement.
|
||||
- The final current Phase 8D build footprint is 119,860 bytes linked static RAM and 1,715,753 bytes flash. Runtime heap/stack/socket headroom requires target measurement.
|
||||
|
||||
## Clearly incomplete or transitional areas
|
||||
|
||||
- Phase 8C hardware validation passed, including route separation, shared command serialization, history/completion, prompts, output backpressure, revocation during queued work, deferred SSH lifecycle/reboot actions, and full concurrent transport operation. At 460800 baud with SSH and WebSocket clients in parallel, substantial packet drops and slow display controls were observed under load, without memory exhaustion; no baud-rate reduction is planned.
|
||||
- The browser Settings dialog covers complete serial framing/lifecycle/persistence controls, Wi-Fi lifecycle/profile rotation, guided user/password/role/authorized-key management, generation-safe Wi-Fi profile/AP/secret editing and exact-generation save, and display-aging apply/save/load/defaults/reset. Broader service/session controls, network diagnostics, security/danger operations, and unusual hardware/debug commands remain shell-only; do not describe them as guided forms.
|
||||
- The four-entry source-address login limiter is deliberately bounded and modest: oldest-entry replacement prevents limiter-induced global denial of service, but distributed/rotating-source attacks remain Phase 9 hardening work.
|
||||
- NVS encryption, secure boot/flash encryption review, comprehensive authentication lockout/rate limiting beyond the bounded browser-login limiter, production certificate/provisioning policy, and OTA are not implemented.
|
||||
|
||||
## Known inconsistencies
|
||||
|
||||
These observations should be checked when touching the relevant area; they are not automatically bugs requiring unrelated cleanup.
|
||||
|
||||
- Some source comments still call shared commands UART0-only or call the current local status/control task read-only.
|
||||
- `USER_DATABASE_LOAD_EMPTY` is only an initialization/failure sentinel at the checked-in revision: every successful `user_database_init()` path returns `STORED` or `MIGRATED_LEGACY`, so `main.c`'s successful "new empty" log branch is unreachable.
|
||||
- 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
|
||||
|
||||
- Confirm task-local Newlib standard-stream behavior if ESP-IDF/Newlib configuration changes; admin SSH command output relies on dispatcher-task stream redirection.
|
||||
- If HTTPD changes from one handler task to concurrent request execution, protect or eliminate the shared 4 KiB typed-response scratch and audit all handler-local single-owner assumptions.
|
||||
|
||||
## Active Task
|
||||
|
||||
- **Objective:** Finish documentation and target-hardware validation for the current Phase 8D integrated web-administration implementation without weakening broker, recovery, command-dispatch, concurrency, or secret-handling invariants.
|
||||
- **Relevant files:** `src/user_admin_service.{c,h}`, `src/web_session.{c,h}`, `src/web_server.{c,h}`, `src/web_serial_transport.{c,h}`, `src/web_admin_transport.{c,h}`, `src/web_ui.{c,h}`, `src/admin_ssh_console.{c,h}`, `src/wifi_manager.{c,h}`, `src/local_ui_config.{c,h}`, `src/local_ui_console.c`, `src/local_status_ui.{c,h}`, `src/ssh_transport.c`, `src/session_broker.{c,h}`, and Phase 8 documentation.
|
||||
- **Findings:** User web mutations need both database generation and stable user ID, while Wi-Fi edits/save need one exact working-config generation. Revocation is post-commit best effort, with principal currentness authoritative. HTTPD stop/finalizer failures require retained ownership and retry rather than unsafe reuse. Browser stale-conflict handling must clear secrets and reload without replay.
|
||||
- **Decisions made:** Route typed user mutations through `user_admin_service` and serialize them with `admin_command_gate`. Serialize typed display Apply/Save/Load/Defaults/Reset with console display writers through the same gate. Use full-config Wi-Fi compare-and-swap and exact-generation save, expose only `secret_set`, and fail closed on generation conflict/exhaustion. Serialize HTTPS lifecycle with generation-tagged intent and require post-material TLS refresh. Keep service/session controls, network diagnostics, security/danger operations, and unusual hardware/debug commands in the Admin shell rather than adding guided forms.
|
||||
- **Changes completed:** In addition to the browser-session/admin-shell/serial/client foundation, implemented guided user CRUD/roles/entered and one-time generated passwords/authorized Ed25519 and P-256 key add/remove, typed Wi-Fi profile/AP/secret editing and save, typed display aging serialized with console writers, secret clearing/stale reload behavior, centralized post-commit revocation, bounded 512-byte/10-field in-place form parsing, and serialized retry-safe HTTPS teardown/TLS refresh. The final current build footprint is 119,860 bytes linked static RAM and 1,715,753 bytes flash.
|
||||
- **Remaining work:** Run every current Phase 8D target-hardware procedure in `docs/user_administration_tests.md`; none is yet recorded as passed.
|
||||
- **Risks / things to remember:** Measure internal free/minimum/largest blocks and web-admin task stack margin under maximum HTTPS/SSH/serial concurrency. Exercise revocation-hook failure, Wi-Fi/user stale editors, self-revocation/final-admin protection, duplicate-key scope, secret clearing, display validation, failed HTTPD stop, and retryable post-stop finalization. The login limiter is bounded defense-in-depth, not strong distributed attack protection. Do not move browser admin through the broker or auto-release its preserved serial writer lease.
|
||||
|
||||
### Handoff template
|
||||
|
||||
- **Objective:**
|
||||
- **Relevant files:**
|
||||
- **Findings:**
|
||||
- **Decisions made:**
|
||||
- **Changes completed:**
|
||||
- **Remaining work:**
|
||||
- **Risks / things to remember:**
|
||||
@@ -0,0 +1,233 @@
|
||||
# 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. `serial_service_start()` is not idempotent, so admission code must reconcile check/start races as the existing transports do. Broker paths enter serial-service APIs while holding the broker mutex; preserve that lock order and do not call back into the broker while holding the serial state mutex. 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. Active hardware tests must claim `PHASE0`; the production service must claim `SERVICE`. Boot-time static-safe GPIO setup and service-owned static-mode restoration are explicit exceptions to this cooperative gate. 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, user principals/accounts, Wi-Fi working configuration, and HTTPS lifecycle intent carry domain-specific generations or random stable IDs to reject stale references, slot reuse, and lost updates.
|
||||
|
||||
**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; user/Wi-Fi web editors carry optimistic generations; HTTPS snapshots expose lifecycle generation.
|
||||
|
||||
**Consequence for future changes:** Preserve transport-slot, account-authentication/database, Wi-Fi working-config, and HTTPS lifecycle generations as distinct concepts. Validate tokens immediately before side effects and discard late work after disconnect/reuse/revocation. Existing-account web mutations must also compare stable user ID so deletion/recreation of the same username cannot retarget stale work.
|
||||
|
||||
**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}`, `src/user_admin_service.{h,c}`, `src/wifi_manager.{h,c}`, `src/web_server.{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`
|
||||
|
||||
## Selected self-affecting admin SSH actions use bounded deferred control
|
||||
|
||||
**Decision:** Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset are deferred until command state and administration/transport application buffers appear drained, with a ten-second limit and short final delay.
|
||||
|
||||
**Rationale/evidence:** `admin_ssh_console` has a separate bounded control task and pending-action state. The check is a best-effort application-buffer heuristic, not peer-delivery confirmation. User account mutations and their immediate revocation calls do not use this path.
|
||||
|
||||
**Consequence for future changes:** Actions that would invalidate their own SSH transport should integrate with deferred control when acknowledgement preservation matters. Prevent new input while an action is pending, keep the wait bounded, and do not describe it as guaranteed delivery.
|
||||
|
||||
**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; after commit, the command layer requests best-effort targeted transport revocation, while ongoing currentness checks are authoritative.
|
||||
|
||||
**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, and notification failure must not roll back an already committed mutation.
|
||||
|
||||
**Relevant files:** `src/user_database.{h,c}`, `src/user_console.c`, `src/web_server.c`, `src/web_serial_transport.c`, `src/ssh_transport.c`
|
||||
|
||||
## Browser authentication uses bounded explicit sessions
|
||||
|
||||
**Decision:** Browser access uses a same-origin login/logout flow and a fixed RAM session table rather than HTTP Basic. Raw opaque tokens exist only in host-only secure cookies; firmware storage retains token digests, copied principals, monotonic expiry, and generation-safe slot identity. State-changing requests require a session-bound CSRF token and exact Origin validation.
|
||||
|
||||
**Rationale/evidence:** Explicit logout and account switching cannot reliably invalidate browser-managed HTTP Basic credentials. Exact browser-session references also allow logout of one session without revoking another session for the same account.
|
||||
|
||||
**Consequence for future changes:** Preserve digest-only storage, the two-per-account/eight-global capacity, exact-session WebSocket binding, and current-principal checks. Do not expose tokens, CSRF values, ticket values, or internal references in logs/snapshots. New mutation endpoints must use the body-backed in-place URL-form parser's 512-byte/10-unique-field bounds, parse closed schemas, and revalidate the exact admin session immediately before typed side effects. Browser failure/close paths must clear entered and generated secret material rather than replaying it after a reload.
|
||||
|
||||
**Relevant files:** `src/web_session.{h,c}`, `src/web_server.c`, `src/web_serial_transport.c`, `src/web_admin_transport.c`
|
||||
|
||||
## Browser admin shell is separate from browser serial
|
||||
|
||||
**Decision:** An administrator may keep the browser serial WebSocket alive while independently opening one admin-only WebSocket frontend for the canonical command dispatcher. Terminal switching changes only browser visibility and focus; the admin route never becomes a broker client.
|
||||
|
||||
**Rationale/evidence:** This preserves a browser-held writer lease while giving full canonical administrative command parity without a generic HTTP command endpoint.
|
||||
|
||||
**Consequence for future changes:** Do not multiplex admin command bytes into `/ws/serial`, and do not close or release the serial route as a side effect of mode switching, settings navigation, or popover display. HTTPD remains the owner of WebSocket send/close calls; the web-admin task only queues bounded work. Self-affecting HTTPS commands must use deferred drain control.
|
||||
|
||||
**Relevant files:** `src/web_admin_transport.{h,c}`, `src/admin_ssh_console.{h,c}`, `src/web_ui.c`, `src/web_server.c`
|
||||
|
||||
## Browser writer transfer is atomic and generation-safe
|
||||
|
||||
**Decision:** Guided writer assignment compares the expected current writer and validates the exact generation-safe target under the broker mutex before making one atomic ownership change.
|
||||
|
||||
**Rationale/evidence:** A browser dialog can become stale while open. Unconditional force assignment could overwrite a newer legitimate lease or release ownership when its target disconnected.
|
||||
|
||||
**Consequence for future changes:** Use `session_broker_compare_exchange_writer()` for stale UI/API transfers. Opening or hovering a writer control must never mutate ownership, and target/current conflicts must leave the current lease unchanged.
|
||||
|
||||
**Relevant files:** `src/session_broker.{h,c}`, `src/web_server.c`, `src/web_ui.c`
|
||||
|
||||
## User mutations have one serialized typed boundary
|
||||
|
||||
**Decision:** `user_admin_service` owns typed account/password/role/key mutations for console and web callers. Its recursive `admin_command_gate` critical region includes the optimistic snapshot check and database commit; a committed mutation is followed by independent best-effort web and SSH revocation attempts.
|
||||
|
||||
**Rationale/evidence:** Browser requests can race one another and canonical shell commands. Database generation plus stable user ID reject stale editors and username delete/recreate races, while the shared gate prevents caller-specific check-then-mutate interleaving. Revocation cannot be made atomic with the NVS commit, so principal currentness remains authoritative.
|
||||
|
||||
**Consequence for future changes:** Route new ordinary user mutations through this service instead of calling `user_database` directly. Do not roll back or report a committed mutation as failed solely because a transport notification failed. Preserve final-admin checks, remote self-generated-password restrictions, secret wiping, and generation/user-ID conflict reporting.
|
||||
|
||||
**Relevant files:** `src/user_admin_service.{h,c}`, `src/user_database.{h,c}`, `src/user_console.c`, `src/admin_command_gate.{h,c}`, `src/web_server.c`, `src/ssh_transport.c`
|
||||
|
||||
## Display configuration writers share the administration gate
|
||||
|
||||
**Decision:** Typed browser display Apply/Save/Load/Defaults/Reset operations and console display-writer commands serialize through the recursive `admin_command_gate` for the complete RAM and persistence operation.
|
||||
|
||||
**Rationale/evidence:** Browser handlers and canonical console frontends can mutate the same local-UI working configuration and NVS record concurrently. Serializing only individual lower-level calls could allow interleaved apply/save/load/reset sequences and inconsistent final state.
|
||||
|
||||
**Consequence for future changes:** Keep all new display configuration writers under the same gate, including any read-modify-write and rollback sequence. Do not hold the gate for read-only status or rendering work, and do not conflate it with the display framebuffer/I2C mutex.
|
||||
|
||||
**Relevant files:** `src/web_server.c`, `src/local_ui_console.c`, `src/local_ui_config.{h,c}`, `src/local_status_ui.{h,c}`, `src/admin_command_gate.{h,c}`
|
||||
|
||||
## Guided Wi-Fi editing uses exact-generation compare-and-swap
|
||||
|
||||
**Decision:** Browser Wi-Fi reads return configuration metadata plus `secret_set` flags, never PSKs. Each typed edit applies a complete validated working-config copy only if its expected nonzero generation remains current, and Save persists exactly the expected generation under the same writer serialization.
|
||||
|
||||
**Rationale/evidence:** Multiple browser editors, console changes, and lifecycle controls can update RAM configuration concurrently. A conventional read/modify/write or copy-then-save sequence could overwrite a newer secret or persist a generation the user never reviewed.
|
||||
|
||||
**Consequence for future changes:** Keep credential-bearing copies tightly scoped and wiped. Add config writers under the writer mutex and advance generation without wraparound; mismatch or exhaustion must fail closed. Stale browser forms must reload without replay and clear entered secrets.
|
||||
|
||||
**Relevant files:** `src/wifi_manager.{h,c}`, `src/wifi_config.{h,c}`, `src/wifi_console.c`, `src/web_server.c`, `src/web_ui.c`
|
||||
|
||||
## HTTPS lifecycle and post-material refresh are serialized
|
||||
|
||||
**Decision:** HTTPS start, stop, and TLS refresh share a lifecycle mutex and generation-tagged desired-running intent. Certificate/material replacement always proceeds to a TLS refresh. Teardown disables new admin-transport HTTPD calls, tracks calls already in progress, retains a server whose stop failed, and keeps incomplete post-stop finalization pending for retry before another start.
|
||||
|
||||
**Rationale/evidence:** Browser-shell commands can tear down their own transport while console commands race a restart or replace persisted TLS material. HTTPD-owned queued work must finish before its handle or the admin transport's static state can be reused.
|
||||
|
||||
**Consequence for future changes:** Do not start a second server around a retained handle, bypass lifecycle serialization, or make post-material refresh optional after persistence. A newer explicit lifecycle intent must win over an older refresh. Finalize timed-out detach state only after HTTPD destruction, and retry a failed finalizer before attaching a replacement server.
|
||||
|
||||
**Relevant files:** `src/web_server.{h,c}`, `src/web_console.c`, `src/web_admin_transport.{h,c}`, `src/admin_ssh_console.{h,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, mDNS-hostname, 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. The live user database remains internal while its 5,360-byte candidate is a persistent PSRAM-preferred allocation with internal fallback and is wiped after every transaction.
|
||||
|
||||
**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 and per-blob commit-before-live-install for user and identity mutation. Keep candidate ownership mutex-local and wipe/free it on initialization or recovery failure. Recheck external-buffer staging in the flash/NVS implementation when upgrading from the pinned ESP-IDF 5.5 baseline. Pre-bootstrap legacy credential rotation spans `web_sec/material` and `user_db/database`, is not cross-namespace atomic, and relies on boot reconciliation after interruption.
|
||||
|
||||
**Relevant files:** `src/serial_config.c`, `src/wifi_config.c`, `src/mdns_config.c`, `src/mdns_service.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, reconciliation, and station mDNS announcement transitions. mDNS initializes at most once, remains allocated across transient disconnects while its component handlers withdraw/re-enable the STA interface, and treats failure as nonfatal.
|
||||
|
||||
**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}`, `src/mdns_service.{h,c}`, `src/mdns_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 owns runtime wolfSSH contexts/calls after caller-side library initialization, 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 make post-initialization wolfSSH calls, 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 reports a reproduced watchdog stall involving mbedTLS external-RAM hardware-AES DMA, uncoordinated mbedTLS/wolfSSL hardware locks, and a successful software-crypto concurrency retest; no standalone execution record is checked in.
|
||||
|
||||
**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. Changes to its inline bootstrap loader must update the hard-coded CSP hash atomically and preserve the response security policy. 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`
|
||||
@@ -0,0 +1,194 @@
|
||||
# Command reference
|
||||
|
||||
UART0, authenticated `admin` SSH sessions, and the browser Admin shell use the same registered command implementations through one serialized dispatcher. The remote shells expose the operational registry, including interactive prompts, recovery-secret display, network diagnostics, reboot, and HTTPS/SSH material mutation. Initial administrator bootstrap and explicit recovery of an unavailable user database remain physically bound to UART0. A remote administrator cannot generate a replacement password for its own account, so the one-time value cannot be lost when the session is revoked. Run `help` for root commands and `<group> help` for a group summary. Configuration changes are RAM-only unless explicitly saved.
|
||||
|
||||
## System
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `memory` | Show free memory, minimum free memory, and largest blocks for internal RAM, DMA-capable RAM, and PSRAM. |
|
||||
| `reboot` | Drain console output briefly and restart the ESP32. |
|
||||
| `exit` | Close the current administrative SSH session after its acknowledgement drains; unavailable on UART0. Ctrl+D on an empty admin SSH command line does the same. |
|
||||
|
||||
## Role-based users
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `user status` / `user list` | Show database generation, capacity, administrator/bootstrap state, and all secret-free account summaries. |
|
||||
| `user show <username>` | Show one account's role, ID, authentication generation, and SSH-key fingerprints. |
|
||||
| `user bootstrap` | Set and confirm the `admin` password without echo, then promote the migrated account to `admin`. |
|
||||
| `user bootstrap --generate` | Bootstrap `admin` with a generated 24-character password displayed once. |
|
||||
| `user add <username> <user|admin>` | Create an account using a bounded no-echo password and confirmation prompt. |
|
||||
| `user add <username> <user|admin> --generate` | Create an account with a generated password displayed once. |
|
||||
| `user delete <username> --force` | Delete an account; the pre-bootstrap migrated `admin` and final administrator are protected. |
|
||||
| `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> --generate` | Replace a password with a generated value displayed once. |
|
||||
| `user key add <username>` | Prompt on UART0 or authenticated admin SSH 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 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. |
|
||||
|
||||
Usernames must match `[a-z][a-z0-9_-]{0,15}`. Passwords contain 12–64 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.
|
||||
|
||||
`user recover --force` is a destructive physical recovery operation and succeeds only while the database is unavailable. It replaces the user blob with one role-`user` account derived from the current legacy credential; run `user bootstrap` afterward. It does not erase unrelated NVS data. Successful password, role, key, bootstrap, and delete operations invalidate only that username's outstanding WebSocket tickets and active WebSocket/SSH sessions; unrelated users remain connected.
|
||||
|
||||
The administrator-only Settings dialog uses `/api/admin/users` for guided account create/list/edit/delete, role changes, entered or one-time generated passwords, and authorized Ed25519/P-256 key add/remove. Mutations include the displayed database generation and stable user ID, so a stale editor is rejected and reloaded instead of targeting a deleted/recreated account. CLI and browser mutations share `user_admin_service`; it serializes mutations with `admin_command_gate`, commits first, and then requests best-effort web and SSH revocation. Transport currentness checks remain authoritative if notification is incomplete. Entered keys/passwords and generated-password output are cleared from the dialog on close or failure.
|
||||
|
||||
## Local display
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `display status` | Show the runtime aging settings and OLED service state. |
|
||||
| `display set dim-seconds <0..86400>` | Set the RAM inactivity delay before contrast drops to `1`; `0` disables dimming. |
|
||||
| `display set off-seconds <0..86400>` | Set the RAM inactivity delay before the OLED switches off; `0` disables automatic off. |
|
||||
| `display save` / `display load` | Save the working aging settings to NVS or load them. |
|
||||
| `display defaults` / `display reset` | Apply 300/600-second defaults in RAM, or apply and persist them. |
|
||||
|
||||
When both transitions are enabled, `off-seconds` must be greater than `dim-seconds`. Applying settings counts as local UI activity. The administrator-only Settings dialog exposes the same typed Apply/Save/Load/Defaults/Reset behavior through `/api/admin/display`; all five operations are serialized with console display writers through `admin_command_gate`, and invalid aging combinations are rejected without applying them. At normal boot, an initialized OLED shows a bounded five-second identity animation before the status UI begins; it scrolls the device name in yellow and draws the compact upright-terminal logo in blue. A missing OLED remains nonfatal; after reconnecting it safely, one new button press requests a bounded reprobe and is consumed without navigating.
|
||||
|
||||
## Serial service
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `serial status` | Show UART1 state, configuration, modem signals, and ownership. |
|
||||
| `serial start` / `serial stop` | Start or release the physical UART1 service. |
|
||||
| `serial set <baud|data-bits|parity|stop-bits|flow|dtr|rts-threshold> <value>` | Change the working serial configuration and safely restart a running service. |
|
||||
| `serial save` / `serial load` | Save the working configuration to NVS or load it. |
|
||||
| `serial defaults` / `serial reset` | Apply defaults in RAM, or apply and persist them. |
|
||||
| `serial counters` / `serial clear-counters` | Show or clear serial counters. |
|
||||
|
||||
Defaults are 115200 baud, 8 data bits, no parity, one stop bit, no flow control, and inactive DTR. Supported values: baud `110`–`1000000`; data bits `7` or `8`; parity `none`, `even`, or `odd`; stop bits `1` or `2`; flow `none` or `rts-cts`; DTR `inactive`, `active`, or `on-connect`; and RTS threshold `1`–`127` bytes. The broker exclusively owns serial data access.
|
||||
|
||||
## Session broker
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `broker status` / `broker clients` | Show broker state or connected clients. |
|
||||
| `broker counters` / `broker clear-counters` | Show or clear broker counters. |
|
||||
| `broker connect <name>` / `broker disconnect <client-id>` | Create or remove a console test client. |
|
||||
| `broker request-writer <client-id>` / `broker release-writer <client-id>` | Request or relinquish the single writer lease. |
|
||||
| `broker force-writer <client-id|none>` | Administratively assign or clear the writer lease. |
|
||||
| `broker send-hex <client-id> <hex-bytes>` | Send hexadecimal bytes through a writer client. |
|
||||
| `broker read <client-id> [maximum-bytes]` | Read queued serial output for a client. |
|
||||
| `broker events <client-id>` | Show ownership and connection events for a client. |
|
||||
|
||||
Each client has a generation-safe ID. There can be one writer and multiple observers; a slow observer loses only its own queued output.
|
||||
|
||||
## Native USB CDC-ACM
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `usb` / `usb help` | Show USB command usage. |
|
||||
| `usb status` | Show CDC connection, broker role, and runtime state. |
|
||||
| `usb counters` / `usb clear-counters` | Show or clear USB counters. |
|
||||
| `usb request-writer` / `usb release-writer` | Request or release USB writer ownership. |
|
||||
|
||||
Opening `/dev/ttyACM*` with DTR asserted creates the `usb-cdc` broker client, starts UART1 if needed, and requests writer ownership. It becomes an observer if another client is writer. USB data is binary-transparent. The host's CDC line coding is shown by `usb status` for diagnostics only; it does not alter UART1. Configure physical baud rate, framing, flow control, and DTR explicitly with `serial` commands and persist them with `serial save`.
|
||||
|
||||
## Wi-Fi
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `wifi status` / `wifi profiles` | Show Wi-Fi state or configured station profiles. |
|
||||
| `wifi start` / `wifi stop` / `wifi reconnect` | Start, stop, or reconnect Wi-Fi. |
|
||||
| `wifi next-profile` | Queue a switch to the enabled station profile after the currently active profile in priority order; wraps safely. |
|
||||
| `wifi profile set <slot> <priority> <mixed|wpa3> <ssid>` | Set a station profile. |
|
||||
| `wifi profile secret <slot>` | Set a profile password through a no-echo prompt. |
|
||||
| `wifi profile enable|disable|delete <slot>` | Manage a station-profile slot. |
|
||||
| `wifi ap policy <off|fallback|always>` | Configure fallback AP behavior. |
|
||||
| `wifi ap ssid <ssid>` / `wifi ap channel <1..11>` | Set the AP name or channel. |
|
||||
| `wifi ap secret` / `wifi ap show-secret` | Set or reveal the AP password. |
|
||||
| `wifi save|load|defaults|reset` | Persist, restore, reset in RAM, or reset and persist configuration. |
|
||||
| `wifi counters|clear-counters` | Show or clear Wi-Fi counters. |
|
||||
| `wifi ping <host> [count]` | Send 1–20 IPv4 or IPv6 ICMP probes. |
|
||||
| `wifi nslookup <host>` | Resolve and display unique IPv4/IPv6 addresses. |
|
||||
| `wifi traceroute <host> [max-hops]` | Run IPv4 ICMP traceroute with up to 30 hops. |
|
||||
|
||||
`ping`, `nslookup`, and `traceroute` are root aliases. The four station-profile slots use lower priority values first. Edits to a disabled profile's SSID, priority, security mode, or secret are staged in RAM and do not interrupt the current Wi-Fi connection. Enabling or disabling a profile, changing an enabled profile, or changing AP policy/configuration applies the new radio policy and may reconnect Wi-Fi. Use `wifi save` to persist working changes.
|
||||
|
||||
The administrator-only Settings dialog uses `/api/admin/wifi-config` for typed station-profile, AP policy/SSID/channel, and write-only secret edits. Reads return only `secret_set` flags. Every mutation compares the exact working-configuration generation, and Save persists only that same generation; stale or exhausted generations fail closed without applying or saving another editor's state. On a conflict, the browser clears entered secrets and reloads current values without replaying the request.
|
||||
|
||||
## mDNS
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `mdns status` | Show the configured `sak-<suffix>.local` hostname and announcement state. |
|
||||
| `mdns suffix <value>` | Set a 1–55-character lowercase hostname suffix in RAM. |
|
||||
| `mdns save` / `mdns load` | Save the working suffix to its independent NVS record or load it. |
|
||||
| `mdns defaults` / `mdns reset` | Restore the MAC-derived suffix in RAM, or restore and persist it. |
|
||||
|
||||
When the Wi-Fi station receives an IPv4 address, the Wi-Fi manager announces `sak-<suffix>.local`. The default suffix is the lower-case hexadecimal STA MAC address. Suffixes may contain lowercase ASCII letters, digits, and internal hyphens only. Changing a suffix while online causes a best-effort reannouncement; mDNS failures do not stop Wi-Fi, UART0, UART1, or native USB access.
|
||||
|
||||
## HTTPS web terminal
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `web` / `web help` | Show web-service command usage. |
|
||||
| `web status` | Show HTTPS, browser-session, serial-WebSocket, and admin-WebSocket state. |
|
||||
| `web start` / `web stop` | Start or stop HTTPS service. |
|
||||
| `web counters` / `web clear-counters` | Show or clear web counters. |
|
||||
| `web credentials show` | Display the legacy migration/recovery credential on UART0 or an authenticated remote admin shell; it is not a role-based network login. |
|
||||
| `web credentials rotate --force` | Replace the legacy recovery credential and synchronize the migrated pre-bootstrap account only. |
|
||||
| `web certificate info` | Display certificate identity and fingerprint. |
|
||||
| `web certificate rotate --force` | Replace the HTTPS certificate and private key. |
|
||||
| `web reset --force` | Explicitly replace missing, incompatible, or damaged legacy credentials and web material. |
|
||||
|
||||
HTTPS listens on port 443 only. Sign in with any current user-database username/password through the same-origin login page; explicit logout permits account switching without relying on a browser HTTP-authentication cache. The device stores at most eight opaque eight-hour browser sessions, with at most two retained per account, and sends the raw token only in a host-only secure cookie. State-changing requests require strict Origin and session-bound CSRF validation.
|
||||
|
||||
Both roles receive the offline browser serial terminal. Its one-time ticket and active WebSocket are bound to the exact browser session and obey the broker's one-writer rule. The combined **Connect serial**/**Disconnect serial** control closes only the serial WebSocket and pauses automatic reconnect when active. Account mutations revoke that account's browser sessions, serial/admin tickets, and WebSockets without disturbing unrelated accounts.
|
||||
|
||||
An administrator additionally receives a **Serial terminal**/**Admin shell** selector, typed Serial controls, guided user/password/role/authorized-key management, generation-safe Wi-Fi profile/AP/secret editing and saving, display-aging controls, and contextual Serial, Wi-Fi, broker-client, and writer-transfer popovers. The browser admin shell uses the same bounded editor, history, completion, prompts, serialized dispatcher, and registered command handlers as admin SSH and UART0; it never joins the serial broker. Switching terminal modes only changes visibility and focus: it does not close the serial WebSocket or release its writer lease. Writer transfer requires an explicit confirmation and atomically checks both the expected current writer and generation-safe target ID. Service/session controls beyond the guided serial/Wi-Fi actions, network diagnostics, security/danger operations, and unusual hardware/debug commands remain shell-only. Initial `user bootstrap` and `user recover --force` remain physical-UART0-only.
|
||||
|
||||
Typed mutation endpoints accept only body-backed URL-encoded forms bounded to 512 bytes and 10 unique fields; duplicate, oversized, malformed, stale-session, wrong-origin, and wrong-CSRF requests fail without a side effect. HTTPS start/stop and TLS refresh are serialized and carry a lifecycle generation. Certificate rotation and full material reset always trigger a post-material TLS refresh; a newer explicit lifecycle request takes precedence. Teardown disables transport-owned HTTPD calls, tracks any already in progress, retains an HTTPD handle after stop failure, and retries pending post-stop admin-transport finalization before a later start.
|
||||
|
||||
## SSH serial transport
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `ssh` / `ssh help` | Show SSH command usage. |
|
||||
| `ssh status` | Show service state and resource information. |
|
||||
| `ssh start` / `ssh stop` | Start or stop the SSH server. |
|
||||
| `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 counters` / `ssh clear-counters` | Show or clear SSH counters. |
|
||||
| `ssh host-key info` | Display the OpenSSH host-key fingerprint. |
|
||||
| `ssh host-key rotate --force` | Replace the persistent SSH host key. |
|
||||
| `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. 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, admin SSH, and the browser admin shell submit to one bounded queue, and one dispatcher task is the sole caller of `esp_console_run()`. Consequently, remote commands execute the canonical UART0 handlers rather than using separate command implementations. Each remote frontend has generation-safe session identity, exact authorization checks, bounded editor/history/output state, and transport-owned network I/O; only the SSH transport task accesses wolfSSH and only HTTPD performs browser WebSocket sends/closes.
|
||||
|
||||
UART0 and admin SSH use shared whole-line Tab completion. A unique/common prefix expands inline; a Tab that cannot extend an ambiguous prefix prints the matching candidates and redraws the unchanged input line instead of cycling candidates. Admin SSH additionally supports four-entry per-session command history with Up/Down, inline cursor editing with Left/Right, Home/End (including Pos1/Ende terminal sequences), Backspace/Delete, Ctrl-C, and visible or no-echo interactive prompts. Its 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.
|
||||
|
||||
`exit`, `reboot`, `ssh stop`, session disconnect, and SSH host-key reset/rotation use bounded deferred control. The firmware waits on a best-effort basis for the administration output ring and transport TX buffer to drain before acting; this is not confirmation that the peer received the acknowledgement. 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. `user bootstrap` and `user recover --force` remain UART0-only. A connected administrator also 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
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `debug status` | Show MAX3243 driver, receiver, VLD, and shutdown states. It requires UART1 to be stopped. |
|
||||
| `debug transceiver <enable|disable>` | Enable or shut down the MAX3243. |
|
||||
| `debug drivers <tx 0|1> <dtr 0|1> <rts 0|1>` | Drive static TX, DTR, and RTS logic levels for measurement. |
|
||||
| `debug loopback-a` / `debug loopback-b` | Test MAX3243 driver/receiver loopback configurations. |
|
||||
| `debug valid-test` | Verify valid RS-232 voltage detection. |
|
||||
| `debug uart-loopback <baud> [8N1|8E1|8O1|8N2|7E1|7O1] [bytes]` | Run a parameterized UART loopback test. |
|
||||
| `debug uart-suite` | Test supported baud rates and frame formats. |
|
||||
| `debug cts-flow-test` / `debug rts-flow-test` | Verify hardware transmit gating or receive backpressure. |
|
||||
| `debug display status` | Show the current display diagnostic state. |
|
||||
| `debug display probe` | Probe the expected OLED addresses 7-bit `0x3c` and `0x3d`, initially using 100 kHz I²C. The tested module responds at `0x3c`. |
|
||||
| `debug display scan --force` | Scan usable 7-bit addresses `0x08`–`0x77` at 100 kHz; use only on this dedicated local-UI bus. |
|
||||
| `debug display init [address]` | Initialize the OLED at 7-bit `0x3c`/`0x3d`, or their 8-bit write/read aliases: `0x78`/`0x79` and `0x7a`/`0x7b`. |
|
||||
| `debug display off` | Turn off the initialized OLED. |
|
||||
| `debug display pattern <clear|fill|checker|grid|corners|layout>` | Draw a full-screen electrical and geometry test pattern; `layout` renders separate status- and content-panel text. |
|
||||
| `debug display row <0..63>` | Draw the selected one-pixel display row for addressing and color-boundary checks. |
|
||||
| `debug display contrast <0..255>` | Set the OLED contrast to the specified bounded value. |
|
||||
| `debug display invert <on|off>` | Enable or disable OLED pixel inversion. |
|
||||
| `debug buttons status` | Show the current active-low state of previous/back GPIO10, select/confirm GPIO13, and next GPIO14. |
|
||||
| `debug buttons test [seconds]` | Run the bounded button event test for 1–30 seconds; the default is 10 seconds. |
|
||||
|
||||
Follow the exact wiring in [Electrical tests](electrical_tests.md) before invoking diagnostics. The OLED must be powered from 3.3 V because module I²C pull-ups may connect to `VCC`; verify that all external pull-ups also terminate at 3.3 V. Display diagnostics probe the standard SSD1315-compatible 7-bit `0x3c`/`0x3d` addresses. The currently tested module acknowledges at `0x3c`, whose 8-bit write/read forms are `0x78`/`0x79`; an explicit `scan --force` is available only for the dedicated local-UI bus. Diagnostics initially run at 100 kHz and treat an absent display as nonfatal. RS-232 diagnostics that require UART1 refuse to use it until `serial stop` releases it. The RGB LED shows test state: blue idle, yellow/orange running, green passed, red failed.
|
||||
@@ -0,0 +1,333 @@
|
||||
# Electrical tests
|
||||
|
||||
These manual procedures verify the OLED and buttons, MAX3243 breakout, UART1 data path, hardware flow control, and session broker. The firmware does not start diagnostic commands automatically, but normal boot does initialize the OLED, play the bounded identity animation, and start the local status/control UI when the hardware responds.
|
||||
|
||||
> **Safety:** With power removed, install only the wiring required by the selected test. DE-9 pins 3 (`TX`), 4 (`DTR`), and 7 (`RTS`) are driven outputs. Never connect one of these outputs to another driven output. Keep temporary Dupont wiring short and secure. Power the OLED only from 3.3 V because module-mounted I²C pull-ups may connect SDA and SCL to the OLED `VCC` rail.
|
||||
|
||||
## Phase 7 OLED and button bring-up
|
||||
|
||||
Use the exact OLED and button connections in [Hardware wiring](wiring.md). Display diagnostics initially operate I²C at 100 kHz and probe the standard 7-bit `0x3c` and `0x3d` addresses. The connected test module acknowledges at `0x3c`, whose 8-bit write/read forms are `0x78` and `0x79`. A missing or unresponsive display is nonfatal: diagnostics should report it without disrupting UART0 or the serial services.
|
||||
|
||||
### 1. Power-off wiring checks
|
||||
|
||||
Disconnect both USB connectors and every other power source before checking or changing wiring.
|
||||
|
||||
1. Confirm OLED `VCC` goes only to `3V3`, OLED `GND` goes to `GND`, SDA goes to GPIO11, and SCL goes to GPIO12.
|
||||
2. Check for an unintended short between `3V3` and `GND`, and verify ground continuity between the OLED and ESP32 board.
|
||||
3. Determine whether the OLED module has SDA/SCL pull-ups and verify that any module-mounted or external pull-ups terminate at 3.3 V, never 5 V. Add suitable external pull-ups to `3V3` only if the module does not provide them; account for parallel resistance if more than one set is fitted.
|
||||
4. Confirm each button is wired between its input and `GND`: previous/back GPIO10, select/confirm GPIO13, and next GPIO14. With a meter, each button should be open when released and near zero ohms to `GND` when pressed.
|
||||
5. Check that no button shorts two GPIOs together and that SDA and SCL are not swapped or shorted.
|
||||
|
||||
### 2. Powered idle checks and address probe
|
||||
|
||||
Apply power and allow normal boot to complete. A connected, responsive OLED is normally initialized automatically and may already show the status UI.
|
||||
|
||||
1. Measure OLED `VCC` relative to `GND`; it should be approximately 3.3 V.
|
||||
2. Measure idle SDA on GPIO11 and idle SCL on GPIO12. Both should be near 3.3 V. Power down immediately if either bus line rises toward 5 V; correct the OLED supply or pull-up wiring before continuing.
|
||||
3. Run `debug display status` and record whether normal boot initialized the display.
|
||||
4. Run `debug display probe`. Confirm that it tests only 7-bit `0x3c` and `0x3d` at the initial 100 kHz bus rate. The tested module should acknowledge at `0x3c` (8-bit `0x78` write / `0x79` read).
|
||||
|
||||
If the expected address does not respond, treat the result as a nonfatal hardware finding. On this dedicated local-UI bus, `debug display scan --force` may identify an unexpected address before further investigation. Otherwise leave the serial core running, power down, and recheck 3.3 V power, common ground, SDA/SCL order, solder joints, and pull-ups. Do not scan a bus shared with unrelated I²C devices.
|
||||
|
||||
### 3. Initialization and display patterns
|
||||
|
||||
Initialize the address observed during the scan. The tested module uses 7-bit `0x3c`, equivalently 8-bit `0x78` (write) and `0x79` (read):
|
||||
|
||||
```text
|
||||
debug display init 0x3c
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```text
|
||||
debug display pattern clear
|
||||
debug display pattern fill
|
||||
debug display pattern checker
|
||||
debug display pattern grid
|
||||
debug display pattern corners
|
||||
```
|
||||
|
||||
Confirm that clear and fill affect the full 128×64 area, checker and grid have regular spacing without shifted or wrapped columns, and all four corner markers are visible in the correct locations. Display diagnostics pause the periodic status UI for 30 seconds so the selected pattern remains observable. Record any unexpected mirroring, rotation, clipping, or column offset for correction.
|
||||
|
||||
### 4. Rendered status/content layout
|
||||
|
||||
Run the Phase 7B panel-layout pattern:
|
||||
|
||||
```text
|
||||
debug display pattern layout
|
||||
```
|
||||
|
||||
Confirm that the status text is entirely in the yellow 128×16 panel (rows 0–15), content text is entirely in the blue 128×48 panel (rows 16–63), and the physical black divider between them remains visible. Record any text crossing the divider, clipping, incorrect panel color, or divider obscuration.
|
||||
|
||||
### 5. Row 15/16 color-boundary test
|
||||
|
||||
Clear the display, illuminate row 15, and record its physical color and position:
|
||||
|
||||
```text
|
||||
debug display pattern clear
|
||||
debug display row 15
|
||||
```
|
||||
|
||||
Repeat for row 16:
|
||||
|
||||
```text
|
||||
debug display pattern clear
|
||||
debug display row 16
|
||||
```
|
||||
|
||||
**Verified result:** row 15 is the last yellow addressable row and row 16 is the first blue addressable row. The two colored areas are separated by a narrow physical black divider, so later UI rendering must treat the 128×16 yellow and 128×48 blue regions as separate panels rather than one visually continuous canvas. Also test another endpoint row if needed with `debug display row <0..63>` to confirm row addressing and orientation.
|
||||
|
||||
### 6. Contrast, inversion, and display-off checks
|
||||
|
||||
With a visible pattern loaded, exercise the bounded contrast range and confirm that brightness changes without bus errors. On the validated module, the useful visible brightness range begins at `1` and extends through `255`:
|
||||
|
||||
```text
|
||||
debug display contrast 0
|
||||
debug display contrast 64
|
||||
debug display contrast 128
|
||||
debug display contrast 255
|
||||
```
|
||||
|
||||
Then verify inversion toggles all displayed pixels and can be restored:
|
||||
|
||||
```text
|
||||
debug display invert on
|
||||
debug display invert off
|
||||
```
|
||||
|
||||
Finally run `debug display off` and confirm the panel turns off cleanly. Use `debug display status`, then `debug display init 0x3c` (or equivalently `0x78` or `0x79`) before further display tests.
|
||||
|
||||
### 7. Button checks
|
||||
|
||||
With all buttons released, run `debug buttons status`. Confirm previous/back GPIO10, select/confirm GPIO13, and next GPIO14 report released/high due to their internal pull-ups; each should report pressed/low while held to `GND`.
|
||||
|
||||
Run `debug buttons test` for the default 10-second interval. During the test, press and release each button separately with a deliberate short press, then repeat with a sustained long press. Confirm that the correct button and short/long classification are reported exactly once per intended action.
|
||||
|
||||
> **Live-UI caution:** The normal local status/control task continues to process button input during this diagnostic. Begin on a status page, watch the OLED, and cancel any Controls confirmation that opens. Do not leave a disruptive action selected while testing long holds.
|
||||
|
||||
Repeat with an explicit duration, for example:
|
||||
|
||||
```text
|
||||
debug buttons test 30
|
||||
```
|
||||
|
||||
Use the longer run to check:
|
||||
|
||||
- **Debounce:** press with normal switch bounce and make several deliberately quick taps; one physical press must not produce a burst of duplicate press/release or short/long events.
|
||||
- **Long press:** hold each button long enough for the diagnostic to classify it as long, then release it; it must not also create an unintended short-press action.
|
||||
- **Stuck button:** hold one button before starting the test and keep it held. The input must remain identified as pressed/stuck without blocking checks of the other buttons, and the bounded diagnostic must still exit after the selected duration.
|
||||
- **Recovery:** release the held button and confirm `debug buttons status` returns to released/high without a reboot.
|
||||
|
||||
`debug buttons test [seconds]` accepts 1 through 30 seconds and defaults to 10 seconds when omitted. Record unexpected event duplication, missed transitions, incorrect GPIO mapping, false long presses, or a test that fails to terminate.
|
||||
|
||||
### 8. Status pages
|
||||
|
||||
After boot, the OLED starts on the **OVERVIEW** page. A short previous/back press on GPIO10 and a short next press on GPIO14 must wrap through these status pages:
|
||||
|
||||
1. **OVERVIEW** — serial, broker, USB, HTTPS/WebSocket, SSH, and Wi-Fi summary.
|
||||
2. **RS232 MODEM** — framing, modem inputs, byte counters, queue depth, drops, and faults.
|
||||
3. **BROKER** — connected clients, current writer marker, pending output, and drop/event counters.
|
||||
4. **NETWORK SERVICES** — Wi-Fi state/RSSI/IP/AP state and HTTPS, WebSocket, and SSH service state.
|
||||
|
||||
Confirm that each page remains entirely within the blue content panel. The yellow panel must retain the same fixed icon positions while pages change: serial, segmented Wi-Fi strength, USB, WebSocket, SSH, broker clients, and alert. Active icons are solid with a value below; inactive icon slots remain completely blank and omit their value. Confirm that page text uses readable upper/lowercase, separates items and values with colons, and displays the active station SSID or AP SSID with its channel on the Overview and Network pages.
|
||||
|
||||
The UI refreshes at 4 Hz maximum. Status pages are observational and must not expose passwords, credentials, tickets, or key material. A short Select press opens the separate Controls menu described below; merely navigating status pages must not change a service, alter writer ownership, or inject serial data. With UART0, USB CDC, WebSocket, and SSH active, navigate pages and confirm that the display continues to update without disrupting serial traffic or UART0 recovery.
|
||||
|
||||
For the OLED-aging policy, leave all three buttons untouched and verify:
|
||||
|
||||
1. At five minutes, display contrast drops to `1` while the contents remain present.
|
||||
2. At ten minutes, the OLED switches off.
|
||||
3. The first debounced press of any button wakes the OLED at contrast `127` without changing the current page.
|
||||
4. A subsequent previous/next press navigates normally and restarts the inactivity timers.
|
||||
|
||||
`debug display status` may verify an individual transition in a separate timing run. Every display diagnostic counts as activity and holds normal UI rendering for 30 seconds, so do not invoke it between the five- and ten-minute observations of one continuous run. Record any missing, stale, clipped, or implausible status value or incorrect dim/off/wake transition.
|
||||
|
||||
### 9. Local controls (Phase 7D)
|
||||
|
||||
From a status page, use a short Select press to open **Controls**. Previous/next selects an item; Select activates it. The menu includes serial start/stop, Wi-Fi start/stop/reconnect/next-profile, HTTPS start/stop, SSH start/stop, writer revocation, display off, and reboot. `WiFi:next profile` requests the enabled station profile after the active one in priority order and wraps safely; it must never show or change credentials, Wi-Fi profile configuration, serial framing, TLS/SSH keys, or I²C scan controls.
|
||||
|
||||
Stopping a service, Wi-Fi reconnect, Wi-Fi next-profile, writer revocation, and reboot open a confirmation page. Verify that:
|
||||
|
||||
1. Previous/back cancels the confirmation without changing the selected service or writer.
|
||||
2. A short Select press does not execute the action.
|
||||
3. Only one continuous two-second Select hold executes the action once; releasing it does not repeat the action.
|
||||
4. Any simultaneous button chord is ignored and cannot confirm an action.
|
||||
5. An untouched confirmation expires to the menu after 30 seconds.
|
||||
6. A dimmed/off OLED consumes the first button press for wake only; its later release and hold must not navigate or confirm an action.
|
||||
|
||||
For Wi-Fi lifecycle calls, confirm the immediate result says `Requested`, then use the status pages to observe the asynchronous state change. For `WiFi:next profile`, configure at least two enabled station profiles, cancel once with Previous/back, then hold Select for two seconds; verify the current station disconnects, the next enabled profile is attempted, and the Overview/Network `WiFi:<SSID> Ch:<channel>` value (or `wifi status`) changes. Repeat until the selection wraps to the first profile. HTTPS and SSH starts must fail cleanly when neither station nor AP networking is available. Writer revocation must only release the current writer—never assign a replacement—and the UI must never appear as a broker client. For reboot, observe `Restarting...`, then verify all normal boot services and UART0 recovery return.
|
||||
|
||||
Run these checks with UART0 available. Repeat appropriate stop/revoke cases with USB CDC, WebSocket, and SSH clients connected; verify the intended session/service is interrupted, unrelated recovery paths remain responsive, and no action injects serial data.
|
||||
|
||||
### 10. Phase 7E boot animation, persistence, and fault recovery
|
||||
|
||||
On each normal boot with an initialized OLED, first verify the bounded five-second identity animation: the full `ESP32 SERIAL SWISS ARMY KNIFE` name scrolls across the yellow panel; the blue panel shows the compact right-oriented monochrome logo with an upright `>_` prompt and serial/USB cable ends entering from the left. Wi-Fi activity and the cursor blink at the 4 Hz animation cadence. A missing OLED must skip the animation without preventing normal UART0 recovery.
|
||||
|
||||
Then shorten the delays for a bounded aging-policy test:
|
||||
|
||||
```text
|
||||
display status
|
||||
display set dim-seconds 5
|
||||
display set off-seconds 10
|
||||
display save
|
||||
reboot
|
||||
```
|
||||
|
||||
After reboot, `display status` must report the stored 5/10-second values. Verify dim at five seconds, off at ten seconds, and one consumed wake press. Run `display load`, then test `display defaults` without saving and confirm 300/600 seconds are restored only in RAM. Run `display reset`, reboot, and confirm the defaults persisted. Verify invalid combinations such as dim `10` with off `5` are rejected without changing the working values. Set either timeout to `0`, save/reboot, and confirm that transition is disabled; finish with `display reset`.
|
||||
|
||||
Exercise the following fault matrix while keeping UART0 available:
|
||||
|
||||
| Fault/stress | Procedure | Required result |
|
||||
|---|---|---|
|
||||
| OLED absent at boot | Power down, remove the OLED, then boot. | UART0, serial, USB, Wi-Fi, HTTPS/WebSocket, and SSH startup remain independent; no reset or probe loop occurs. |
|
||||
| Reattach | Power down before reconnecting loose wiring, boot, and press one button. If using a connector explicitly suitable for live removal, avoid shorts and reconnect before pressing. | One bounded reprobe initializes the panel; the wake press does not navigate or execute an action. |
|
||||
| NACK/timeout | With safe test wiring, interrupt the display during refresh or hold one bus line low briefly, then release it. | One bounded frame fails and marks the OLED unavailable; no watchdog reset, repeated log flood, serial loss, or broker ownership change occurs. A later new press can recover after the bus is healthy. |
|
||||
| Stuck button | Hold each button continuously for at least ten seconds, then operate each of the other buttons. | The held input is quarantined, the other controls remain usable, and no action repeats. Release and debounce the held input; it must rearm without rebooting. |
|
||||
| Real chord | Press two healthy buttons together before either is quarantined. | The chord is ignored and cannot confirm an action. |
|
||||
| Repeated actions | Re-enter Controls and repeat start/stop, reconnect, next-profile, writer-revoke, and display-off requests during transitions. | Each confirmation hold executes once; stale state produces an error rather than the opposite action; queue saturation, if reached, reports an error and leaves the Wi-Fi alert/counter visible. |
|
||||
|
||||
For the final concurrency regression, connect USB CDC, one WebSocket terminal, and one SSH terminal while UART1 carries sustained bidirectional traffic. Navigate the UI, run the persistence checks, exercise service stop/start and writer revocation, and inject the display/stuck-button faults above. Confirm UART0 remains responsive, the UI never appears as a broker client or injects serial bytes, writer ownership is always the expected client or none, and serial/broker drop counters do not increase unexpectedly. Record `memory`, transport counters, `wifi counters`, and `debug display status` before and after the run.
|
||||
|
||||
## Configuration A: data and handshake pairs
|
||||
|
||||
Connect the following pairs:
|
||||
|
||||
| Driven output | Receiver input | Expected ESP32 logic |
|
||||
|---|---|---|
|
||||
| DE-9 pin 3, `TX` | DE-9 pin 2, `RX` | `RX == TX` |
|
||||
| DE-9 pin 4, `DTR` | DE-9 pin 6, `DSR` | `DSR == DTR` |
|
||||
| DE-9 pin 7, `RTS` | DE-9 pin 8, `CTS` | `CTS == RTS` |
|
||||
|
||||
```text
|
||||
DE-9 pin 3 TX ─────> pin 2 RX
|
||||
DE-9 pin 4 DTR ─────> pin 6 DSR
|
||||
DE-9 pin 7 RTS ─────> pin 8 CTS
|
||||
```
|
||||
|
||||
Run `debug loopback-a`. The test cycles all TX/DTR/RTS states and verifies the three receivers and `VLD`. Driver and receiver inversions cancel, so the resulting ESP32 logic levels must match.
|
||||
|
||||
This configuration also supports `debug valid-test`, `debug uart-loopback <baud> [format] [bytes]`, and `debug uart-suite`; the UART commands need only the pin 3-to-2 connection.
|
||||
|
||||
## Configuration B: remaining receivers
|
||||
|
||||
Remove every configuration A jumper, then connect:
|
||||
|
||||
| Driven output | Receiver input | Expected ESP32 logic |
|
||||
|---|---|---|
|
||||
| DE-9 pin 3, `TX` | DE-9 pin 1, `DCD` | `DCD == TX` |
|
||||
| DE-9 pin 4, `DTR` | DE-9 pin 9, `RI` | `RI == DTR` |
|
||||
| DE-9 pin 7, `RTS` | DE-9 pin 2, `RX` | `RX == RTS` |
|
||||
|
||||
```text
|
||||
DE-9 pin 3 TX ─────> pin 1 DCD
|
||||
DE-9 pin 4 DTR ─────> pin 9 RI
|
||||
DE-9 pin 7 RTS ─────> pin 2 RX
|
||||
```
|
||||
|
||||
Run `debug loopback-b`. Configurations A and B together exercise all three drivers and five receivers.
|
||||
|
||||
## CTS transmit gating
|
||||
|
||||
Disconnect all external DE-9 equipment, power down, and remove previous jumpers. Install only:
|
||||
|
||||
| Driven output | Receiver input | Purpose |
|
||||
|---|---|---|
|
||||
| DE-9 pin 3, `TX` | DE-9 pin 2, `RX` | Return UART1 data for comparison |
|
||||
| DE-9 pin 4, `DTR` | DE-9 pin 8, `CTS` | Present inactive and active CTS states |
|
||||
|
||||
```text
|
||||
DE-9 pin 3 TX ─────> pin 2 RX
|
||||
DE-9 pin 4 DTR ─────> pin 8 CTS
|
||||
```
|
||||
|
||||
Run `debug cts-flow-test`. At 115200 baud with hardware CTS enabled, the firmware first queues 512 bytes while CTS is inactive and confirms that no data is sent for 250 ms. It then activates CTS and verifies automatic, exact transmission and reception of all bytes.
|
||||
|
||||
## RTS receive backpressure
|
||||
|
||||
Disconnect all external DE-9 equipment, power down, and remove previous jumpers. Install only:
|
||||
|
||||
| Driven output | Receiver input | Purpose |
|
||||
|---|---|---|
|
||||
| DE-9 pin 4, `DTR` | DE-9 pin 2, `RX` | Carry UART2-generated data to UART1 RX |
|
||||
| DE-9 pin 7, `RTS` | DE-9 pin 1, `DCD` | Return UART1 RTS to UART2 CTS |
|
||||
|
||||
```text
|
||||
UART2 TX / DE-9 pin 4 DTR ─────> pin 2 RX / UART1 RX
|
||||
UART1 RTS / DE-9 pin 7 RTS ─────> pin 1 DCD / UART2 CTS
|
||||
```
|
||||
|
||||
Run `debug rts-flow-test`. UART2 queues 4096 bytes while UART1 is initially unread. Once UART1 reaches its receive threshold, it deasserts RTS and stops UART2 through CTS. Draining UART1 must reassert RTS and let UART2 finish. `UART_BUFFER_FULL` is expected; FIFO overflows, framing/parity errors, breaks, or any data mismatch are failures.
|
||||
|
||||
## Voltage and polarity checks
|
||||
|
||||
With no DE-9 loopback jumpers installed, measure pins 3, 4, and 7 relative to DE-9 pin 5 (`GND`):
|
||||
|
||||
```text
|
||||
debug drivers 0 0 0
|
||||
```
|
||||
|
||||
Each output should be at a positive RS-232 voltage. Then run:
|
||||
|
||||
```text
|
||||
debug drivers 1 1 1
|
||||
```
|
||||
|
||||
Each output should be negative. Exact voltages depend on supply, load, meter, and charge-pump behavior; polarity is the important result.
|
||||
|
||||
## Recommended order
|
||||
|
||||
1. With no DE-9 jumpers and after `serial stop`, run `debug status`.
|
||||
2. Measure output polarity with `debug drivers 0 0 0` and `debug drivers 1 1 1`.
|
||||
3. Power down, install configuration A, power up, and run `debug loopback-a`.
|
||||
4. Run `debug valid-test` with configuration A still installed.
|
||||
5. Keep only pin 3-to-2 and run `debug uart-loopback 9600 8N1 256`, then `debug uart-suite`.
|
||||
6. Power down, install configuration B, power up, and run `debug loopback-b`.
|
||||
7. Power down, install CTS wiring, power up, and run `debug cts-flow-test`.
|
||||
8. Power down, install RTS wiring, power up, and run `debug rts-flow-test`.
|
||||
|
||||
RTS and CTS remain GPIO signals during static and basic UART tests. Only the flow-control tests assign them to UART peripherals. The firmware shuts down the MAX3243 while changing GPIO-matrix routing and restores outputs to static logic 1 after each test.
|
||||
|
||||
## Session-broker loopback
|
||||
|
||||
This test verifies broker fan-out and writer ownership independently of the diagnostics. Disconnect external peers, power down, remove previous jumpers, and connect only DE-9 pin 3 (`TX`) to pin 2 (`RX`). Power up and run:
|
||||
|
||||
```text
|
||||
serial start
|
||||
broker connect writer
|
||||
broker connect observer
|
||||
broker clients
|
||||
```
|
||||
|
||||
Use the client IDs printed by the device:
|
||||
|
||||
```text
|
||||
broker request-writer <writer-id>
|
||||
broker send-hex <writer-id> 0055aaff1b5b33316d
|
||||
broker read <writer-id> 64
|
||||
broker read <observer-id> 64
|
||||
```
|
||||
|
||||
Both clients should receive `0055aaff1b5b33316d`. A read may return zero if UART1 has not returned the bytes yet; repeat it shortly afterward.
|
||||
|
||||
Verify writer exclusion and administrative reassignment:
|
||||
|
||||
```text
|
||||
broker request-writer <observer-id>
|
||||
broker send-hex <observer-id> dead
|
||||
broker force-writer <observer-id>
|
||||
broker send-hex <observer-id> 112233
|
||||
broker read <writer-id> 64
|
||||
broker read <observer-id> 64
|
||||
broker events <writer-id>
|
||||
broker events <observer-id>
|
||||
```
|
||||
|
||||
The competing request and its initial send should fail. After reassignment, both clients should receive `112233`; events should show denial, revocation, and grant. Clean up:
|
||||
|
||||
```text
|
||||
broker disconnect <observer-id>
|
||||
broker disconnect <writer-id>
|
||||
serial stop
|
||||
```
|
||||
|
||||
Remove the loopback jumper with power off before connecting an external serial device.
|
||||
@@ -0,0 +1,87 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="620" viewBox="0 0 960 620" role="img" aria-labelledby="title description">
|
||||
<title id="title">Phase 7 dual-color OLED overview-screen mockup</title>
|
||||
<desc id="description">An enlarged mockup of the verified 128 by 64 pixel local status display. The upper sixteen yellow rows and lower forty-eight blue rows are separate rendered panels, divided by a narrow physical black separator. The yellow panel shows service, Wi-Fi, client, writer, and alert status. The blue panel shows IP address, RS-232 configuration, modem signals, traffic counters, and page navigation. Three buttons below are labeled previous, OK, and next.</desc>
|
||||
|
||||
<defs>
|
||||
<filter id="yellowGlow" x="-20%" y="-40%" width="140%" height="180%">
|
||||
<feGaussianBlur stdDeviation="1.7" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
<filter id="blueGlow" x="-20%" y="-40%" width="140%" height="180%">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
<linearGradient id="bezel" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#253747"/>
|
||||
<stop offset="0.5" stop-color="#14212c"/>
|
||||
<stop offset="1" stop-color="#091017"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="button" cx="42%" cy="35%" r="70%">
|
||||
<stop offset="0" stop-color="#566777"/>
|
||||
<stop offset="0.65" stop-color="#273440"/>
|
||||
<stop offset="1" stop-color="#111920"/>
|
||||
</radialGradient>
|
||||
<pattern id="pixels" width="5" height="5" patternUnits="userSpaceOnUse">
|
||||
<path d="M5 0H0V5" fill="none" stroke="#ffffff" stroke-opacity="0.022" stroke-width="0.45"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="960" height="620" fill="#0b1118"/>
|
||||
<text x="480" y="35" fill="#d7e3ed" font-family="sans-serif" font-size="20" font-weight="600" text-anchor="middle">Phase 7 — 128×64 local status display</text>
|
||||
|
||||
<rect x="55" y="55" width="850" height="470" rx="25" fill="url(#bezel)" stroke="#456074" stroke-width="3"/>
|
||||
<circle cx="82" cy="82" r="9" fill="#081016" stroke="#667b8b" stroke-width="2"/>
|
||||
<circle cx="878" cy="82" r="9" fill="#081016" stroke="#667b8b" stroke-width="2"/>
|
||||
<circle cx="82" cy="498" r="9" fill="#081016" stroke="#667b8b" stroke-width="2"/>
|
||||
<circle cx="878" cy="498" r="9" fill="#081016" stroke="#667b8b" stroke-width="2"/>
|
||||
|
||||
<!-- The screen is exactly 2:1. Its 320-pixel height represents 64 rows at 5x scale. -->
|
||||
<rect x="160" y="95" width="640" height="320" rx="3" fill="#010406" stroke="#718696" stroke-width="2"/>
|
||||
<rect x="160" y="95" width="640" height="80" fill="#1a1600"/>
|
||||
<rect x="160" y="175" width="640" height="240" fill="#00121a"/>
|
||||
<rect x="160" y="95" width="640" height="320" fill="url(#pixels)"/>
|
||||
<line x1="160" y1="175" x2="800" y2="175" stroke="#77848c" stroke-opacity="0.38"/>
|
||||
|
||||
<g font-family="DejaVu Sans Mono, Liberation Mono, Consolas, monospace" font-size="27" font-weight="600" letter-spacing="0.2">
|
||||
<g fill="#ffd52e" filter="url(#yellowGlow)">
|
||||
<text x="177" y="131">SER● WIFI▮▮▮ U1 W1 S1</text>
|
||||
<text x="177" y="168">WR:SSH CLIENTS:3 !:0</text>
|
||||
</g>
|
||||
<g fill="#20c9ff" filter="url(#blueGlow)">
|
||||
<text x="177" y="211">IP 192.168.122.42</text>
|
||||
<text x="177" y="251">RS232 230400 8N1</text>
|
||||
<text x="177" y="291">FLOW:NONE VLD:1</text>
|
||||
<text x="177" y="331">DCD0 DSR1 CTS1 RI0</text>
|
||||
<text x="177" y="371">RX:1.23M TX:633</text>
|
||||
<text x="177" y="408">‹ OVERVIEW 1/4 ›</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g font-family="sans-serif" font-size="14" fill="#9fb0bd">
|
||||
<text x="812" y="112">row 0</text>
|
||||
<text x="812" y="171">row 15</text>
|
||||
<text x="812" y="190">row 16</text>
|
||||
<text x="812" y="414">row 63</text>
|
||||
<text x="148" y="445" text-anchor="end">128 px</text>
|
||||
<text x="480" y="445" text-anchor="middle">Verified panels: yellow rows 0–15 · black divider · blue rows 16–63</text>
|
||||
</g>
|
||||
|
||||
<g>
|
||||
<circle cx="325" cy="493" r="37" fill="url(#button)" stroke="#728898" stroke-width="2"/>
|
||||
<circle cx="480" cy="493" r="37" fill="url(#button)" stroke="#728898" stroke-width="2"/>
|
||||
<circle cx="635" cy="493" r="37" fill="url(#button)" stroke="#728898" stroke-width="2"/>
|
||||
<g fill="#d7e3ed" font-family="sans-serif" font-size="25" font-weight="700" text-anchor="middle">
|
||||
<text x="325" y="502">◀</text>
|
||||
<text x="480" y="501" font-size="18">OK</text>
|
||||
<text x="635" y="502">▶</text>
|
||||
</g>
|
||||
<g fill="#91a4b2" font-family="sans-serif" font-size="14" text-anchor="middle">
|
||||
<text x="325" y="551">previous / hold: back</text>
|
||||
<text x="480" y="551">select / hold: confirm</text>
|
||||
<text x="635" y="551">next</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<text x="480" y="593" fill="#718594" font-family="sans-serif" font-size="13" text-anchor="middle">Mockup only — glyph metrics remain to be finalized; the physical color boundary is hardware-verified.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="720" viewBox="0 0 1080 720" role="img" aria-labelledby="title description">
|
||||
<title id="title">Phase 7C compact icon status UI mockup</title>
|
||||
<desc id="description">Four 128 by 64 OLED page mockups with the same fixed-position yellow status icons and changing blue page content. Active icons are solid yellow with values beneath; inactive SSH and alert slots are blank.</desc>
|
||||
<defs>
|
||||
<style>
|
||||
.heading{fill:#d8e4ec;font:600 20px sans-serif}.note{fill:#93a8b6;font:14px sans-serif}.screen{fill:#010609;stroke:#668092;stroke-width:2}.yellowbg{fill:#191600}.divider{stroke:#77858d;stroke-opacity:.5;stroke-width:2}.active{fill:#ffd52e;stroke:#ffd52e}.blue{fill:#27c9ff;stroke:#27c9ff}.content{fill:#27c9ff;font:600 18px 'DejaVu Sans Mono',monospace}.value{fill:#ffd52e;font:600 14px 'DejaVu Sans Mono',monospace}.page{fill:#d8e4ec;font:600 17px sans-serif}
|
||||
</style>
|
||||
<!-- Official Material Design Icons usb path, @mdi/svg 7.4.47. -->
|
||||
<symbol id="usb" viewBox="0 0 24 24"><path d="M15,7V11H16V13H13V5H15L12,1L9,5H11V13H8V10.93C8.7,10.56 9.2,9.85 9.2,9C9.2,7.78 8.21,6.8 7,6.8C5.78,6.8 4.8,7.78 4.8,9C4.8,9.85 5.3,10.56 6,10.93V13A2,2 0 0,0 8,15H11V18.05C10.29,18.41 9.8,19.15 9.8,20A2.2,2.2 0 0,0 12,22.2A2.2,2.2 0 0,0 14.2,20C14.2,19.15 13.71,18.41 13,18.05V15H16A2,2 0 0,0 18,13V11H19V7H15Z"/></symbol>
|
||||
<symbol id="serial" viewBox="0 0 24 24"><path d="M5 3h14v5h3v13H2V8h3zm2 2v3h10V5zM5 11v7h14v-7zm2 2h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/></symbol>
|
||||
<symbol id="wifi" viewBox="0 0 24 24"><path d="M1 8q11-9 22 0l-2 3q-9-7-18 0zm5 6q6-5 12 0l-2 3q-4-3-8 0zm4 6a2 2 0 1 1 4 0z"/></symbol>
|
||||
<symbol id="web" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20M4.3 9h3.1A16 16 0 0 1 9 4.7 8.1 8.1 0 0 0 4.3 9m-.2 6h3.3a16 16 0 0 1 0-6H4.3a8 8 0 0 0-.2 6m4.9 4.3A16 16 0 0 1 7.4 15H4.3A8.1 8.1 0 0 0 9 19.3M10 15h4a14 14 0 0 0 0-6h-4a14 14 0 0 0 0 6m1 4.9v-3h2v3a8 8 0 0 1-2 0m0-15.8v3h2v-3a8 8 0 0 0-2 0m4 15.2a16 16 0 0 0 1.6-4.3h3.1a8.1 8.1 0 0 1-4.7 4.3m1.6-10.3h3.1A8.1 8.1 0 0 0 15 4.7 16 16 0 0 1 16.6 9m.1 2a14 14 0 0 1 0 4h3.2a8 8 0 0 0 0-4z"/></symbol>
|
||||
<symbol id="ssh" viewBox="0 0 24 24"><path d="M2 3h20v18H2zm3 4 4 4-4 4 2 2 6-6-6-6zm8 8v2h6v-2z"/></symbol>
|
||||
<symbol id="clients" viewBox="0 0 24 24"><path d="M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8m7-1a3 3 0 1 0 0-6 3 3 0 0 0 0 6M2 21v-3c0-3 3-5 7-5s7 2 7 5v3zm14-8c4 0 6 2 6 5v3h-4v-3c0-2-1-4-3-5z"/></symbol>
|
||||
<symbol id="alert" viewBox="0 0 24 24"><path d="M12 2 1 22h22zm-1 7h2v7h-2zm0 9h2v2h-2z"/></symbol>
|
||||
<g id="status">
|
||||
<g class="active"><use href="#serial" x="12" y="4" width="30" height="30"/><use href="#wifi" x="80" y="4" width="30" height="30"/><use href="#usb" x="150" y="4" width="30" height="30"/><use href="#web" x="220" y="4" width="30" height="30"/><use href="#clients" x="360" y="4" width="30" height="30"/></g>
|
||||
|
||||
<text x="17" y="57" class="value">On</text><text x="91" y="57" class="value">6</text><text x="154" y="57" class="value">Wr</text><text x="231" y="57" class="value">1</text><text x="371" y="57" class="value">3</text>
|
||||
</g>
|
||||
</defs>
|
||||
<rect width="1080" height="720" fill="#0b1118"/><text x="540" y="34" text-anchor="middle" class="heading">Phase 7C — fixed icon dashboard and compact status pages</text><text x="540" y="57" text-anchor="middle" class="note">Yellow strip is identical on every page · inactive icon slots and their supplementary values remain blank</text>
|
||||
|
||||
<g transform="translate(62 98)"><text x="256" y="-16" text-anchor="middle" class="page">Overview</text><rect width="512" height="256" class="screen"/><rect width="512" height="64" class="yellowbg"/><line x1="0" y1="64" x2="512" y2="64" class="divider"/><use href="#status"/>
|
||||
<g class="blue"><use href="#serial" x="8" y="72" width="30" height="30"/><use href="#clients" x="8" y="104" width="30" height="30"/><use href="#wifi" x="8" y="136" width="30" height="30"/><use href="#web" x="8" y="200" width="30" height="30"/></g><text x="45" y="96" class="content">Serial:230400 8N1</text><text x="45" y="128" class="content">Clients:3 Wr:USB</text><text x="45" y="160" class="content">WiFi:LabNet Ch:6</text><text x="45" y="192" class="content">IP:192.168.1.42</text><text x="45" y="224" class="content">Web:1 SSH:0 USB:1</text></g>
|
||||
|
||||
<g transform="translate(592 98)"><text x="256" y="-16" text-anchor="middle" class="page">RS-232 / modem</text><rect width="512" height="256" class="screen"/><rect width="512" height="64" class="yellowbg"/><line x1="0" y1="64" x2="512" y2="64" class="divider"/><use href="#status"/>
|
||||
<g class="blue"><use href="#serial" x="8" y="72" width="30" height="30"/></g><text x="45" y="96" class="content">Mode:230400 8N1</text><text x="45" y="128" class="content">Flow:rts-cts</text><text x="45" y="160" class="content">DCD:0 DSR:1 CTS:1</text><text x="45" y="192" class="content">Valid:Yes RX:1.2M</text><text x="45" y="224" class="content">TX:633 Err:0</text></g>
|
||||
|
||||
<g transform="translate(62 438)"><text x="256" y="-16" text-anchor="middle" class="page">Broker clients</text><rect width="512" height="256" class="screen"/><rect width="512" height="64" class="yellowbg"/><line x1="0" y1="64" x2="512" y2="64" class="divider"/><use href="#status"/>
|
||||
<g class="blue"><use href="#usb" x="8" y="72" width="30" height="30"/><use href="#web" x="8" y="104" width="30" height="30"/><use href="#ssh" x="8" y="136" width="30" height="30"/></g><text x="45" y="96" class="content">Wr:USB Q:0</text><text x="45" y="128" class="content">Ob:web Q:0</text><text x="45" y="160" class="content">Ob:SSH Q:0</text><text x="45" y="224" class="content">Drop:0 Events:0</text></g>
|
||||
|
||||
<g transform="translate(592 438)"><text x="256" y="-16" text-anchor="middle" class="page">Network / services</text><rect width="512" height="256" class="screen"/><rect width="512" height="64" class="yellowbg"/><line x1="0" y1="64" x2="512" y2="64" class="divider"/><use href="#status"/>
|
||||
<g class="blue"><use href="#wifi" x="8" y="72" width="30" height="30"/><use href="#web" x="8" y="168" width="30" height="30"/><use href="#ssh" x="8" y="200" width="30" height="30"/></g><text x="45" y="96" class="content">WiFi:LabNet Ch:6</text><text x="45" y="128" class="content">IP:192.168.1.42</text><text x="45" y="160" class="content">AP:Off Clients:0</text><text x="45" y="192" class="content">HTTPS:On Web:1</text><text x="45" y="224" class="content">SSH:On Sessions:0</text></g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
+325
@@ -0,0 +1,325 @@
|
||||
# Implementation roadmap
|
||||
|
||||
This document tracks the implementation and hardware-validation plan for the ESP32 Serial Swiss Army Knife. It describes intended sequencing rather than a release schedule. A phase is complete only when its implementation, documentation, build validation, and relevant hardware tests have passed.
|
||||
|
||||
## Status legend
|
||||
|
||||
- **Complete** — implemented and validated on the target hardware.
|
||||
- **In progress** — implementation or validation is actively underway, but the overall phase is not complete.
|
||||
- **Implemented; validation pending** — code is present and builds, but the current implementation still needs the listed hardware checks.
|
||||
- **Planned** — accepted project direction, not yet implemented.
|
||||
- **Under evaluation** — useful candidate whose feasibility, security, or resource cost must be measured before it becomes a commitment.
|
||||
|
||||
## Project-wide design constraints
|
||||
|
||||
These constraints apply across all phases:
|
||||
|
||||
- UART0 remains the trusted physical administration and recovery console.
|
||||
- UART1 and the MAX3243 provide the single physical RS-232 data port.
|
||||
- All serial transports use the central session broker: one writer, multiple observers.
|
||||
- A slow or failed client must not block UART reception or unrelated clients.
|
||||
- Serial data paths remain binary-transparent; transports must not reserve in-band control sequences.
|
||||
- Network serial access must be authenticated and encrypted. No unauthenticated plaintext TCP serial service is planned.
|
||||
- Secrets must not be exposed by routine status output, command completion, or logs.
|
||||
- Resource use must remain bounded and observable on the ESP32-S3 N16R8 target.
|
||||
- New work must preserve USB CDC and UART0 recovery when Wi-Fi or a network service fails.
|
||||
- Project code remains `GPL-3.0-only`; third-party license notices and compatibility must be preserved.
|
||||
|
||||
## Phase overview
|
||||
|
||||
| Phase | Scope | Status |
|
||||
|---|---|---|
|
||||
| 0 | RS-232 hardware characterization and diagnostics | **Complete** |
|
||||
| 1 | Persistent UART1 serial-service foundation | **Complete** |
|
||||
| 2 | Transport-neutral session broker | **Complete** |
|
||||
| 3 | Native USB CDC-ACM serial transport | **Complete** |
|
||||
| 4 | Wi-Fi station/AP foundation and network diagnostics | **Complete** |
|
||||
| 5A | Authenticated HTTPS administration foundation | **Complete** |
|
||||
| 5B | Offline xterm.js WebSocket serial terminal | **Complete** |
|
||||
| 6 | Authenticated SSH serial transport | **Complete** |
|
||||
| 7 | Local display and button interface | **Complete** |
|
||||
| 8 | Role-based users and administrative access | **In progress (8A–8C complete; current 8D guided workflows implemented, all 8D target validation pending)** |
|
||||
| 9 | Security and production hardening | **Planned** |
|
||||
| 10 | Authenticated, rollback-capable OTA | **Planned** |
|
||||
| 11 | BLE serial transport and provisioning evaluation | **Planned** |
|
||||
| 12 | Advanced network integration | **Under evaluation** |
|
||||
| 13 | Optional filesystem-backed features | **Under evaluation** |
|
||||
|
||||
## Completed phases
|
||||
|
||||
### Phase 0 — RS-232 hardware characterization
|
||||
|
||||
Implemented and hardware-validated:
|
||||
|
||||
- Central board pin profile for the ESP32-S3 N16R8 and MAX3243 breakout.
|
||||
- MAX3243 enable, static driver, receiver, valid-voltage, and modem-signal checks.
|
||||
- UART loopback across supported framing and baud-rate combinations.
|
||||
- Hardware CTS transmit gating and RTS receive-backpressure tests.
|
||||
- Exclusive RS-232 port ownership and fault-safe cleanup.
|
||||
- RGB status indication for diagnostic state.
|
||||
- Phase 0 hardware diagnostics, including signal status, are isolated under the `debug` CLI submenu.
|
||||
|
||||
Detailed procedures are in [Electrical tests](electrical_tests.md) and [Hardware wiring](wiring.md).
|
||||
|
||||
### Phase 1 — Serial-service foundation
|
||||
|
||||
Implemented and hardware-validated:
|
||||
|
||||
- Persistent, versioned UART configuration with safe defaults.
|
||||
- UART1 lifecycle management and exclusive ownership.
|
||||
- Nonblocking RX and TX software streams.
|
||||
- Baud rate, data bits, parity, stop bits, RTS/CTS, DTR policy, and RTS threshold controls.
|
||||
- Runtime counters and safe restart after working-configuration changes.
|
||||
- Explicit save/load/default/reset behavior; edits are not silently persisted.
|
||||
|
||||
### Phase 2 — Session broker
|
||||
|
||||
Implemented and hardware-validated:
|
||||
|
||||
- Up to eight generation-safe broker clients.
|
||||
- Exactly one writer lease with multiple observers.
|
||||
- Bounded per-client output and event queues.
|
||||
- Slow-observer isolation and drop accounting.
|
||||
- Writer grant, release, denial, revocation, and forced administrative reassignment.
|
||||
- DTR `on-connect` integration.
|
||||
- Partial-transfer and retryable no-progress semantics for transports.
|
||||
- Broker payload storage placed preferentially in PSRAM while FreeRTOS control structures remain in internal RAM.
|
||||
|
||||
### Phase 3 — Native USB CDC-ACM
|
||||
|
||||
Implemented and hardware-validated:
|
||||
|
||||
- Native ESP32-S3 USB CDC-ACM serial transport.
|
||||
- Automatic broker connection when the host opens the port with DTR asserted.
|
||||
- Opportunistic writer acquisition with observer fallback.
|
||||
- Binary-transparent bidirectional serial data.
|
||||
- Host line-coding visibility for diagnostics; UART1 remains under explicit serial configuration control.
|
||||
- Disconnect cleanup, bounded buffering, counters, and UART0 lifecycle controls.
|
||||
|
||||
### Phase 4 — Wi-Fi foundation
|
||||
|
||||
Implemented and hardware-validated:
|
||||
|
||||
- Four persistent station profiles with priority ordering.
|
||||
- WPA2/WPA3 mixed mode and WPA3-SAE-required mode.
|
||||
- Explicit no-echo secret entry through UART0.
|
||||
- AP policies `off`, `fallback`, and `always`.
|
||||
- Random persistent fallback-AP password and MAC-derived default SSID.
|
||||
- Bounded profile attempts, DHCP deadlines, failover, and retry backoff.
|
||||
- Root and `wifi` aliases for `ping`, `nslookup`, and `traceroute`.
|
||||
- Wi-Fi/lwIP payload allocation configured to prefer PSRAM with capacities pinned in `sdkconfig.defaults`.
|
||||
|
||||
Post-validation enhancements implemented; regression validation pending:
|
||||
|
||||
- Edits to disabled station profiles are staged without restarting the active radio; enable-state changes and edits affecting enabled radio policy apply asynchronously.
|
||||
- Configurable STA-only `sak-<suffix>.local` mDNS hostname with an independent versioned NVS record, live hostname updates, automatic withdrawal/restoration across STA IPv4 transitions, and nonfatal failure isolation. The responder initializes at most once and keeps its task stack internal while general metadata prefers PSRAM.
|
||||
|
||||
A WPA3-required profile correctly rejects an AP that does not advertise the required Protected Management Frame capability; mixed mode is the compatible choice for such an AP.
|
||||
|
||||
### Phase 5A — Authenticated HTTPS foundation
|
||||
|
||||
Implemented and hardware-validated:
|
||||
|
||||
- HTTPS-only service on TCP port 443 using ESP-IDF mbedTLS.
|
||||
- Persistent random shared `admin` credential, later retained only as migration/recovery material by Phase 8.
|
||||
- Persistent device-specific ECDSA P-256 certificate and private key.
|
||||
- Physical-console credential retrieval, rotation, certificate inspection, rotation, and recovery.
|
||||
- Explicit failure behavior that preserves UART0, USB, serial, and Wi-Fi recovery paths.
|
||||
- Credential rotation revocation hooks shared with WebSocket and SSH sessions.
|
||||
|
||||
### Phase 5B — Offline browser terminal
|
||||
|
||||
Implemented and hardware-validated:
|
||||
|
||||
- Vendored xterm.js and FitAddon assets; no Internet or CDN dependency.
|
||||
- CSP-compatible page structure with scripts served as separate resources.
|
||||
- Authenticated status and one-time WebSocket-ticket APIs.
|
||||
- Binary WebSocket serial frames and broker writer/observer integration.
|
||||
- Two bounded browser-terminal sessions.
|
||||
- Writer request/release controls and role indication.
|
||||
- Bounded TLS/socket behavior and slow-observer isolation.
|
||||
- Viewport-constrained terminal fitting without recursive page growth.
|
||||
- Validation with multiple clients, ANSI colors, advanced escape sequences, and full-screen terminal software.
|
||||
|
||||
Post-validation enhancement implemented; browser regression validation pending: the combined Connect/Disconnect control explicitly closes the terminal WebSocket and pauses automatic reconnect until Connect is selected.
|
||||
|
||||
### Phase 6 — Authenticated SSH serial transport
|
||||
|
||||
Implemented and target-hardware validated:
|
||||
|
||||
- wolfSSH on TCP port 22 with a separate persistent ECDSA P-256 host key.
|
||||
- Two bounded interactive shell/PTY sessions; no `exec`, file transfer, forwarding, or subsystems.
|
||||
- Authenticated, binary-transparent broker forwarding with opportunistic writer acquisition and observer fallback.
|
||||
- Bounded handshake attempts/deadlines, receive work, buffers, and a single wolfSSH owner task pinned to CPU 1.
|
||||
- UART0 lifecycle, session, counter, and host-key administration.
|
||||
- PSRAM-preferred allocations with internal fallback and internal task stacks.
|
||||
- Bounded broker/WebSocket work, software mbedTLS AES, and software wolfCrypt AES/SHA to avoid the validated shared-hardware-crypto/PSRAM watchdog failure while retaining ESP-IDF SHA/MPI acceleration.
|
||||
- Concurrent USB, WebSocket, and SSH operation validated with USB and SSH alternately holding the writer lease.
|
||||
|
||||
### Phase 7 — Local display and buttons
|
||||
|
||||
Implemented and target-hardware validated:
|
||||
|
||||
- Optional 128×64 SSD1315-compatible I²C OLED on GPIO11/12 and active-low buttons on GPIO10/13/14; assignments remain centralized in `board_pins.h`.
|
||||
- Display service with a static framebuffer, bounded dirty-page commits, and separate yellow status rows 0–15 plus blue content rows 16–63.
|
||||
- Low-priority status/control task built from copied public snapshots; no service lock is held across I²C and the UI never becomes a broker client.
|
||||
- Overview, RS-232, broker, and network/service pages with no credential or key material.
|
||||
- Confirmation-protected local controls for service lifecycle, Wi-Fi reconnect/profile rotation, writer release, display off, and reboot; the UI cannot assign a writer or edit secrets/configuration.
|
||||
- Persistent dim/off settings, bounded boot animation, consumed wake presses, stuck-button quarantine, and one-shot confirmations.
|
||||
- Missing, disconnected, or unresponsive display hardware remains nonfatal and can recover through one bounded reprobe.
|
||||
- Concurrent serial, UART0, USB, WebSocket, SSH, and injected display/button fault behavior validated. See [Electrical tests](electrical_tests.md) and [Hardware wiring](wiring.md).
|
||||
|
||||
## Current and planned phases
|
||||
|
||||
Phase 8A through 8C are complete and target-hardware validated. The current Phase 8D browser-session, admin-shell, typed serial/user/Wi-Fi/display, broker-client, and writer-transfer implementation is present, but all Phase 8D target-hardware validation remains pending. Later work remains planned or under evaluation. Optional features must not weaken the completed serial and recovery paths.
|
||||
|
||||
### Phase 8 — Role-based users and administrative access
|
||||
|
||||
Replace the single shared network credential with a bounded, persistent user system. Both roles can use the authenticated web serial/status interface. Over SSH, `user` routes to the broker-backed serial stream while `admin` routes exclusively to the administration shell and receives no broker client or writer lease. UART0 remains the physical recovery and bootstrap authority.
|
||||
|
||||
Implementation sequence:
|
||||
|
||||
1. **Phase 8A — User database and UART0 administration — Complete**
|
||||
- Versioned NVS database for up to eight `user`/`admin` accounts, random account IDs and authentication generations, final-admin protection, and three Ed25519/P-256 keys per account.
|
||||
- Salted PBKDF2-HMAC-SHA256 password verifiers, bounded no-echo entry, one-time generated passwords, and secret-free account/key status.
|
||||
- Transactional migration of the legacy credential to an unprivileged account, explicit UART0 bootstrap, and UART0-only recovery that replaces only the unavailable user blob.
|
||||
- Migration, bootstrap, CRUD, persistence, and command basics target-hardware validated; the full fault-injection matrix remains regression coverage. NVS remains unencrypted and offline guessing remains possible.
|
||||
2. **Phase 8B — Role-aware HTTPS and SSH authentication — Complete**
|
||||
- HTTPS Basic plus SSH password/public-key authentication use the common database and copied secret-free principals; unavailable user storage fails closed.
|
||||
- One-time principal-bound WebSocket tickets and ongoing principal-currentness checks prevent stale admission or input.
|
||||
- Account mutations request targeted WebSocket/SSH revocation; authentication generations provide fail-safe invalidation while unrelated accounts remain connected.
|
||||
- Legacy credentials remain migration/recovery-only after bootstrap. Password/key login, ticket behavior, targeted revocation, recovery, and concurrent transport operation are target-hardware validated.
|
||||
3. **Phase 8C — SSH administrative shell — Complete**
|
||||
- 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.
|
||||
- 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 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 execute through existing synchronous owner APIs from a separate bounded control task. Initial `user bootstrap` and explicit `user recover --force` remain physical-UART0 operations; admin SSH also rejects generating a replacement password for its own account.
|
||||
- `ssh sessions` and `ssh counters` identify broker versus admin-console routes, worker command state, queued admin output, admission failures, and input backpressure. `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort application-buffer draining. 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.
|
||||
- Target-hardware validation passed for route separation, 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. Stress at 460800 baud with SSH and WebSocket clients caused substantial expected packet drops and slower display controls, but did not exhaust memory or require lowering the supported baud-rate range.
|
||||
4. **Phase 8D — Integrated web administration — In progress**
|
||||
- **Implemented, validation pending:** replace browser-facing HTTP Basic authentication with a same-origin HTTPS login page, explicit logout, and bounded opaque server-side sessions. Store only a digest of each random session token with a copied secret-free principal, expiry, CSRF state, and authentication-generation binding. Send the raw token only in a host-only `__Host-` cookie with `Secure`, `HttpOnly`, `SameSite=Strict`, `Path=/`, no `Domain`, and an explicit lifetime; never retain passwords, Basic headers, raw tokens, verifiers, or SSH-key blobs in snapshots or logs.
|
||||
- **Implemented, validation pending:** logout invalidates the current server-side session, expires its cookie, closes that session's serial and administrative WebSockets, and redirects to login. Password/role/key mutation, deletion, recreation, and explicit revocation invalidate the affected account's web sessions and tickets without disturbing unrelated accounts. Require the CSRF token plus strict same-origin checks for every state-changing request, including logout, and rate-limit login attempts with bounded secret-free accounting.
|
||||
- **Implemented, validation pending:** an admin-only **Serial terminal**/**Admin shell** selector. The administrative route uses a short-lived, single-use, admin-principal-bound ticket and a bounded WebSocket frontend for the same serialized command registry used by UART0 and admin SSH; it is not a generic HTTP command-execution endpoint. Normal users retain the existing serial interface and cannot mint, upgrade, or invoke administrative routes. Remote policy still rejects physical-only `user bootstrap` and `user recover --force`.
|
||||
- **Implemented, validation pending:** switching the visible terminal between Serial and Admin changes only the displayed terminal route. It must not disconnect the browser's serial broker client, release its writer lease, or silently stop serial observation. Keep the writer/observer badge plus Request control/Release control visible in both modes so an administrator knows the retained state and cannot unintentionally lose the lease to another client. Explicit Disconnect, logout, revocation, session expiry, or an explicit release/transfer operation still performs normal broker cleanup.
|
||||
- **Implemented, validation pending:** the admin-only Settings area uses typed, bounded APIs rather than generated CLI strings. Full serial framing/lifecycle/load/default/save controls and Wi-Fi lifecycle/profile-rotation actions remain available.
|
||||
- **Implemented, validation pending:** `/api/admin/users` provides guided account CRUD, role changes, entered or one-time generated passwords, and authorized Ed25519/P-256 key add/remove. Existing-account mutations carry both the optimistic database generation and stable user ID, so stale state and delete/recreate races fail without retargeting. `user_admin_service` serializes CLI and web mutations with `admin_command_gate`; after a committed mutation it requests best-effort web and SSH revocation without rolling back the database if notification fails.
|
||||
- **Implemented, validation pending:** `/api/admin/wifi-config` provides typed station profile, enable/disable/delete, AP policy/SSID/channel, write-only station/AP secret, and Save operations. Reads disclose only `secret_set` flags. Every edit is a full validated compare-and-swap against the expected nonzero working generation, Save persists exactly the expected generation, and generation conflicts or exhaustion fail closed.
|
||||
- **Implemented, validation pending:** `/api/admin/display` provides typed display-aging Apply/Save/Load/Defaults/Reset operations with the same validation and persistence semantics as the console. All five operations serialize with console display writers through `admin_command_gate`.
|
||||
- **Implemented, validation pending:** the common body-backed URL-form parser decodes in place and remains bounded to 512 bytes and 10 unique fields. The browser clears entered and generated secrets when Settings closes or an operation fails; stale user and Wi-Fi editors reload current state without replaying the mutation.
|
||||
- **Implemented, validation pending:** contextual admin quick settings on the existing status cards. Hover, keyboard focus, or click on **Serial** opens a popover with current framing/lifecycle state, safe common edits, apply/save semantics, and a link to full Serial settings. The **Wi-Fi** card similarly exposes connection/profile controls and a link to full Network settings without revealing saved secrets by default. Touch and keyboard users must receive the same functionality as pointer hover, with Escape/outside-click dismissal and no action triggered merely by opening a popover.
|
||||
- **Implemented, validation pending:** hover, focus, or click on **Broker clients** opens a live, secret-free client list with IDs, transport, writer/observer state, and bounded queue/drop information. Activating **Active writer** opens an admin-only transfer dialog listing current eligible clients; transfer is an explicit confirmed generation-safe administrative assignment, never a side effect of opening or hovering. Stale/disconnected targets fail visibly without changing the current lease. Normal users may retain ordinary aggregate status but receive neither client-management details nor mutation controls.
|
||||
- **Implemented, validation pending:** HTTPS lifecycle operations are fully serialized and generation-tagged. TLS certificate/material replacement requires a post-commit TLS refresh; explicit newer start/stop intent wins races. Admin-transport HTTPD API work is disabled and tracked during teardown, failed HTTPD stop retains ownership for retry, and incomplete post-stop admin finalization remains pending and is retried before a later start.
|
||||
- **Implemented, validation pending:** preserve strict CSP, no-referrer/frame-denial policy, no-store responses for login/session/admin material, secret-safe JSON encoding, one-time generated-password display, and bounded request/response bodies. Keep UART0 recovery, native USB UART1 access, and existing SSH behavior available if web sessions or administration cannot initialize.
|
||||
- **Implemented, validation pending:** secret-free counters/snapshots cover session capacity, expiry, login failure/backoff, logout, invalidation, CSRF/origin rejection, admin-console admission/backpressure, typed-API failure, and writer-transfer races.
|
||||
- **Not provided as guided forms:** broader service/session administration, network diagnostics, security and danger-zone operations, and unusual hardware/debug commands remain available through the canonical Admin shell under its existing remote policy.
|
||||
|
||||
Completion requires login/logout and account switching without browser credential-cache dependence; stale-session, CSRF, origin, capacity, expiry, and revocation tests; hidden and server-rejected normal-user administration; shared admin-console serialization and backpressure; terminal switching that demonstrably preserves the browser broker client and writer lease; accessible Serial/Wi-Fi/client/writer popovers; generation-safe explicit writer transfer; guided user/key, Wi-Fi secret/CAS, display validation, bounded-parser, and HTTPS teardown/finalizer tests; concurrent USB/WebSocket/user-SSH/admin-SSH/browser-admin operation; and continued UART0 recovery. None of the current Phase 8D target-hardware checks has passed yet.
|
||||
|
||||
### Phase 9 — Security and production hardening
|
||||
|
||||
Harden stored credentials, boot integrity, update trust, failure diagnostics, and dependency maintenance. This phase must be staged carefully because some ESP32 eFuse operations are irreversible.
|
||||
|
||||
Planned work:
|
||||
|
||||
- Define development, test, and production security profiles.
|
||||
- Enable and validate NVS encryption for Wi-Fi, HTTPS, and SSH material.
|
||||
- Evaluate flash encryption and PSRAM encryption for the target hardware and threat model.
|
||||
- Enable secure boot with a documented signing-key and recovery process.
|
||||
- Define secret-safe core-dump handling; do not expose credentials or session material in diagnostics.
|
||||
- Audit key and session-material zeroization across application, mbedTLS, wolfSSL, and wolfSSH allocations.
|
||||
- Add authentication throttling, lockout/backoff, and security-event counters where appropriate.
|
||||
- Review TLS/SSH algorithms, certificate policy, password policy, and browser security headers.
|
||||
- Review current wolfSSL/wolfSSH and ESP-IDF security releases instead of treating integration versions as permanent pins.
|
||||
- Recheck all third-party licenses and preserve source/license notices.
|
||||
- Document provisioning, key rotation, factory reset, backup, recovery, and device decommissioning.
|
||||
|
||||
Security features must be validated first on expendable hardware before any irreversible production configuration is recommended.
|
||||
|
||||
### Phase 10 — Authenticated OTA and rollback
|
||||
|
||||
Use the existing dual 4 MiB application slots and `otadata` partition to add safe firmware updates.
|
||||
|
||||
Planned work:
|
||||
|
||||
- Accept only authenticated, integrity-checked firmware images compatible with the selected secure-boot policy.
|
||||
- Support an update path that does not expose a plaintext management endpoint.
|
||||
- Stream downloads with bounded RAM use and explicit progress/error reporting.
|
||||
- Preserve serial, Wi-Fi, HTTPS, and SSH configuration across successful updates.
|
||||
- Mark new firmware pending until a post-boot health check succeeds.
|
||||
- Roll back automatically after failed boot or health confirmation.
|
||||
- Define power-loss behavior for every update stage.
|
||||
- Keep UART0 recovery and wired re-flashing documented and functional.
|
||||
- Add version/compatibility checks and reject accidental downgrade unless an explicit policy allows it.
|
||||
|
||||
OTA is complete only after successful update, interrupted-update, invalid-image, rollback, and configuration-persistence tests on hardware.
|
||||
|
||||
### Phase 11 — BLE
|
||||
|
||||
Evaluate and, if resource limits permit, add BLE as another bounded transport or provisioning path.
|
||||
|
||||
Planned work:
|
||||
|
||||
- Decide whether BLE is primarily a serial transport, a provisioning interface, or both.
|
||||
- Require authenticated pairing/bonding appropriate to the device threat model.
|
||||
- Integrate serial access through the existing broker rather than bypassing writer ownership.
|
||||
- Preserve binary transparency through explicit framing and MTU-aware partial transfers.
|
||||
- Bound connections, queues, retries, and advertising behavior.
|
||||
- Measure coexistence with 2.4 GHz Wi-Fi, HTTPS, WebSocket, SSH, USB CDC, and UART service.
|
||||
- Store bond/provisioning data under the hardened secret-storage policy.
|
||||
- Provide UART0 controls and a physical recovery path for clearing BLE state.
|
||||
|
||||
BLE remains subordinate to stable Wi-Fi and serial operation; it should be omitted if coexistence or memory costs cannot be bounded acceptably.
|
||||
|
||||
### Phase 12 — Advanced network integration
|
||||
|
||||
These features are candidates, not current commitments:
|
||||
|
||||
- Additional DNS-SD service advertisement and any certificate-name integration beyond the implemented configurable STA hostname.
|
||||
- Enterprise Wi-Fi support, subject to credential-storage and certificate-validation design.
|
||||
- IPv6 behavior and diagnostics beyond the current basic support.
|
||||
- WireGuard feasibility evaluation, including RAM, CPU, licensing, key storage, routing, and recovery impact.
|
||||
|
||||
The device is not intended to become a general-purpose router. Captive-portal interception, unauthenticated DNS redirection, NAPT, and a plaintext serial listener remain out of scope unless the project requirements are explicitly revised.
|
||||
|
||||
### Phase 13 — Optional filesystem-backed features
|
||||
|
||||
The `storage` partition is reserved but not currently mounted. Possible uses must be justified individually:
|
||||
|
||||
- LittleFS-backed web assets to decouple large static files from the application image.
|
||||
- Bounded diagnostic logs with explicit retention and secret-redaction rules.
|
||||
- Import/export of non-secret configuration.
|
||||
- Optional user files needed by later administration features.
|
||||
|
||||
Before enabling storage, define corruption recovery, wear limits, quotas, atomic update behavior, compatibility across OTA slots, and whether encryption is required. Serial operation and recovery must not depend on a mountable filesystem.
|
||||
|
||||
## Cross-phase release gates
|
||||
|
||||
Every phase should satisfy the following before being marked complete:
|
||||
|
||||
1. A clean release build for the ESP32-S3 N16R8 target.
|
||||
2. No new compiler or project diagnostics attributable to the change.
|
||||
3. Focused automated tests where practical and documented hardware tests where hardware behavior is involved.
|
||||
4. UART0, native USB CDC, and previously completed network transports still operate.
|
||||
5. Broker writer/observer semantics and binary transparency remain intact.
|
||||
6. Failure, disconnect, stop/start, reboot, and stale-session cleanup paths are exercised.
|
||||
7. Runtime memory, stack, socket, and queue bounds are measured under maximum supported concurrency.
|
||||
8. Secrets remain absent from ordinary status output and logs.
|
||||
9. [Command reference](command_reference.md), [Hardware wiring](wiring.md), electrical tests, and this roadmap are updated as applicable.
|
||||
10. New dependencies receive security, maintenance, and GPL-3.0 compatibility review.
|
||||
|
||||
## Explicitly deferred work
|
||||
|
||||
The following are not implemented merely because flash partitions or library support exist:
|
||||
|
||||
- NVS, flash, or PSRAM encryption.
|
||||
- Secure boot or production eFuse provisioning.
|
||||
- OTA download, image confirmation, or rollback policy.
|
||||
- Core-dump collection or secret-safe core-dump processing.
|
||||
- Filesystem mounting.
|
||||
|
||||
- SFTP, SCP, SSH `exec`, forwarding, or subsystems.
|
||||
- General routing, NAPT, captive-portal interception, or unauthenticated serial access.
|
||||
|
||||
Deferring these features is intentional: each changes the security model, recovery behavior, or bounded-resource assumptions and therefore requires its own design and validation gate.
|
||||
@@ -0,0 +1,224 @@
|
||||
# User administration and authentication tests
|
||||
|
||||
This document retains phase-specific regression procedures. The Phase 8A and 8B sections describe historical baselines rather than the current end-to-end behavior. Phase 8C's shared UART0/admin-SSH shell is validated. The current Phase 8D worktree adds explicit browser sessions, the canonical browser admin shell, typed serial/user/Wi-Fi/display workflows, and broker controls; use the Phase 8D section for pending browser validation. Never include generated or entered passwords, Wi-Fi secrets, private keys, tickets, cookies, CSRF values, or verifier material in test logs.
|
||||
|
||||
## Historical Phase 8A baseline — role-based database and UART0 administration
|
||||
|
||||
Keep UART0 physically attached throughout these tests. At the Phase 8A baseline, HTTPS and SSH still used the legacy shared credential while the new database and public keys were prepared for the Phase 8B cutover.
|
||||
|
||||
### 1. First-boot migration and persistence
|
||||
|
||||
Upgrade a device that already has working HTTPS/SSH credentials and no `user_db` blob. After boot:
|
||||
|
||||
1. Run `user status` and `user show admin`.
|
||||
2. Confirm exactly one `admin`-named account exists with role `user`, `bootstrapped=no`, no displayed password/salt/verifier, and no public keys.
|
||||
3. Confirm the pre-upgrade credential still authenticates to HTTPS and SSH and that serial terminal behavior is unchanged.
|
||||
4. Reboot and confirm `user status` reports the same account ID and authentication generation.
|
||||
5. Confirm routine boot logs, `web status`, and `ssh status` contain no user password, verifier, salt, or raw public-key blob.
|
||||
|
||||
A transient failure reading the legacy web credential must not create or commit an empty user database. If a valid `user_db` blob already exists, it must still load without the legacy credential being readable.
|
||||
|
||||
### 2. Administrator bootstrap
|
||||
|
||||
Run `user bootstrap`, verify that typed password characters are not echoed, enter a 12–64 character printable password twice, and confirm `user show admin` now reports role `admin` and a higher authentication generation. Reboot and confirm `bootstrapped=yes` and one administrator persist. On a separate resettable test image, exercise `user bootstrap --generate`; record the generated password securely for the test, verify it is displayed once, then clear the terminal scrollback after use.
|
||||
|
||||
During Phase 8A, verify the newly bootstrapped password does **not** replace the legacy HTTPS/SSH login yet and the existing network credential continues to work. This separation is intentional until Phase 8B. A second `user bootstrap` must be rejected without changing the account.
|
||||
|
||||
### 3. Account CRUD, capacity, and invariants
|
||||
|
||||
Use generated test passwords where practical:
|
||||
|
||||
```text
|
||||
user add operator user --generate
|
||||
user add maint admin --generate
|
||||
user list
|
||||
user show operator
|
||||
user role operator admin --force
|
||||
user role operator user --force
|
||||
user password operator --generate
|
||||
```
|
||||
|
||||
Confirm unique valid usernames are accepted; uppercase, empty, oversized, or punctuation-invalid usernames are rejected. Confirm duplicate users are rejected. Fill all eight slots and verify a ninth account is rejected without altering existing records; delete a non-final account and confirm its slot can be reused with a new account ID.
|
||||
|
||||
With two administrators, demote or delete one and confirm the operation persists. Then attempt both deletion and demotion of the final administrator and confirm both are rejected. Before bootstrap on a separate migration run, verify `user delete admin --force` is rejected so legacy synchronization remains recoverable. `Ctrl-C` during either hidden password prompt must cancel without changing the account or leaving stale input in the next console command.
|
||||
|
||||
### 4. SSH public-key import and fingerprints
|
||||
|
||||
Generate disposable Ed25519 and ECDSA P-256 OpenSSH keys on a trusted host. For each supported type, run `user key add <username>`, paste the single `.pub` line, and confirm `user show <username>` reports the type, stable slot index, and expected `SHA256:` fingerprint without displaying the Base64 blob or comment.
|
||||
|
||||
Verify all of the following fail without changing the account generation or key list:
|
||||
|
||||
- Unsupported RSA, certificate, or unknown key types.
|
||||
- Truncated/invalid Base64, mismatched outer and embedded types, trailing blob data, malformed Ed25519 lengths, and an invalid/off-curve ECDSA point.
|
||||
- Adding the same key again to the same account.
|
||||
- Adding a fourth key to an account that already has three.
|
||||
- Deleting an empty/out-of-range slot.
|
||||
|
||||
Assign the same public key to a second account and confirm it is accepted, receives an independent slot, and authenticates as the username selected by the SSH client.
|
||||
|
||||
Then exercise `user key delete <username> <index> --force` and `user key clear <username> --force`, reboot, and confirm the exact remaining fingerprints persist. Phase 8A stores these keys but does not yet accept SSH key login.
|
||||
|
||||
### 5. Legacy credential rotation boundary and reboot reconciliation
|
||||
|
||||
Before administrator bootstrap on a resettable test image:
|
||||
|
||||
1. Run `web credentials rotate --force`.
|
||||
2. Confirm the console reports that the pre-bootstrap migrated verifier was synchronized.
|
||||
3. Verify the new legacy credential works for HTTPS and SSH, reboot, and confirm `user show admin` retains the synchronized authentication generation.
|
||||
4. Where NVS fault injection or controlled power interruption is available, interrupt after the web credential commits but before the user blob commits. On the next boot, confirm reconciliation updates the migrated verifier rather than committing an empty database or losing network recovery.
|
||||
|
||||
After bootstrap, rotate the legacy credential again. Confirm the console explicitly says it remains separate from user passwords, existing network sessions are revoked, the new legacy credential works, and the bootstrapped administrator generation/password are not silently replaced. Apply the same checks to `web reset --force`, additionally confirming HTTPS certificate recovery/restart behavior remains intact.
|
||||
|
||||
### 6. Explicit database recovery and storage failures
|
||||
|
||||
Using a disposable device or test NVS image, inject a wrong-size/version or malformed `user_db` blob and reboot. Confirm normal database initialization fails without erasing shared NVS, but the `user` command is still registered. `user status` must report the database unavailable. Run:
|
||||
|
||||
```text
|
||||
user recover --force
|
||||
user status
|
||||
user bootstrap --generate
|
||||
```
|
||||
|
||||
Confirm recovery explicitly replaces only `user_db`, imports the current legacy credential as role `user`, and leaves serial, Wi-Fi, HTTPS identity, SSH host key, and legacy network login intact. Confirm recovery is rejected while a valid database is initialized. Simulate NVS write/commit exhaustion or failure during create, password, role, key, and recovery operations; after reboot, each operation must expose either the complete old blob or complete new blob, never a partially live mutation.
|
||||
|
||||
Because NVS is not yet encrypted, this test validates logical verifier storage rather than resistance to physical extraction. PBKDF2 verifiers remain susceptible to offline guessing, and append-oriented NVS may retain historical plaintext legacy credentials until a full partition/flash erase.
|
||||
|
||||
### 7. Phase 8A regression
|
||||
|
||||
With the maximum supported mix of USB CDC, WebSocket, and SSH serial clients active, repeat user list/show and several UART0 mutations. Confirm password prompts remain physical-UART-only, no user operation acquires a broker client or writer lease, existing Phase 8A network authentication and revocation behavior remains functional, and serial/broker counters show no unexplained loss. Record `memory`, `web counters`, and `ssh counters` before and after the run.
|
||||
|
||||
## Historical Phase 8B baseline — role-aware HTTPS and SSH authentication
|
||||
|
||||
These procedures preserve the Phase 8B regression baseline. Complete the Phase 8A bootstrap first and retain two disposable accounts, one `user` and one `admin`. At that baseline both roles received the serial/status interface; current Phase 8C firmware instead routes SSH administrators to the administration shell.
|
||||
|
||||
### 1. HTTPS passwords and fail-closed behavior
|
||||
|
||||
For both roles, authenticate to `/`, `/api/status`, and the browser terminal with the account password. Confirm the full 16-character username and a 64-character password work, while absent, malformed, oversized, wrong-user, and wrong-password Basic headers fail without exposing which component was wrong. After bootstrap, confirm the value from `web credentials show` no longer authenticates unless it independently equals a current user password.
|
||||
|
||||
On a disposable malformed-database image, confirm HTTPS returns authentication-service unavailable and never falls back to the legacy credential. Recover through physical UART0 only. Verify `web status` reports user-database authentication and active WebSocket sessions show account, user role, and password method without secrets.
|
||||
|
||||
### 2. SSH password and public-key login
|
||||
|
||||
For both `user` and `admin`, verify password SSH login reaches the same broker-backed serial stream as before. Confirm `ssh sessions` shows the account, user role, password method, and writer/observer role. An administrator must not receive UART0 commands yet.
|
||||
|
||||
Import one Ed25519 key and one ECDSA P-256 key through `user key add`, then verify each matching private key authenticates without a password and `ssh sessions` reports `public-key`. Confirm an unregistered key, wrong username, unsupported RSA key, certificate key, and three failed completed attempts are rejected. Normal client probe-then-sign behavior must consume one completed attempt, not two; unsigned probes alone must never create an authenticated session or broker client.
|
||||
|
||||
### 3. WebSocket ticket binding
|
||||
|
||||
Authenticate separately as two users and request tickets. Confirm each ticket is accepted exactly once, expires after 30 seconds, and cannot be reused or altered. Change one account after ticket issuance but before WebSocket connection; its ticket must fail closed without creating a broker client, while the other account's ticket still works. Repeat with delete followed by recreation of the same username and confirm the old ticket remains invalid.
|
||||
|
||||
### 4. Targeted session revocation
|
||||
|
||||
Connect simultaneous WebSocket and SSH sessions for two different users and place one affected session in writer state. One mutation at a time, exercise password change, role change, SSH-key add/delete/clear, and account deletion. Required results:
|
||||
|
||||
1. Outstanding tickets and active sessions for the changed username are revoked promptly and any writer lease is released.
|
||||
2. The stale session cannot inject additional serial bytes or reacquire writer ownership.
|
||||
3. The unrelated user's WebSocket/SSH sessions remain connected and retain their expected broker roles.
|
||||
4. New authentication succeeds only with the updated password/key/role state.
|
||||
5. Deleting and recreating the same username does not revive old tickets or sessions.
|
||||
|
||||
Run `web status`, `ssh sessions`, `web counters`, `ssh counters`, and `broker clients` around each mutation. Also rotate `web credentials rotate --force` after administrator bootstrap and confirm it changes only recovery material: role-based HTTPS/SSH sessions remain connected and user passwords are unchanged. `web reset --force` restarts HTTPS because the TLS identity changes, but must not revoke unrelated SSH sessions.
|
||||
|
||||
### 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.
|
||||
|
||||
## Phase 8C SSH administrative shell
|
||||
|
||||
**Validation status: passed on target hardware.** The full matrix below passed, including concurrent USB/WebSocket/user-SSH/admin-SSH operation. At 460800 baud with SSH and WebSocket clients in parallel, substantial packet drops and slower display controls were observed under load, but memory exhaustion could not be provoked and no lower baud-rate cap is planned.
|
||||
|
||||
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`, `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`, `wifi next`, and `ssh host-key i`; confirm the same hints are offered by UART0 and SSH. For an ambiguous prefix, confirm both routes print the same matching candidates and redraw the unchanged input line instead of cycling candidates; unique/common prefixes must redraw cleanly without inserting escape-sequence bytes into the command. Type a command, use Left/Right to insert and delete characters in its middle, then use Home/End and the terminal's Pos1/Ende keys; verify cursor placement, Backspace, and Delete change exactly the intended byte before submission.
|
||||
|
||||
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 `user bootstrap` and `user recover --force` remain unavailable from SSH and continue to work through physical UART0. Also confirm that an administrator cannot run `user password <own-username> --generate` over its own SSH session; entered replacement passwords and generated passwords for other accounts remain available. 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.
|
||||
|
||||
## Phase 8D integrated web administration
|
||||
|
||||
**Validation status: all current Phase 8D target-hardware validation is pending.** The browser-session/admin-shell foundation and typed serial, user, Wi-Fi, display, client, and writer workflows are implemented. None of the checks below should be marked passed until exercised on target hardware.
|
||||
|
||||
### 1. Integrated login and authorization
|
||||
|
||||
Authenticate as both roles through the same-origin login page, explicitly log out, and switch accounts without clearing browser state or relying on an HTTP Basic credential cache. Verify bounded session capacity, expiry, login throttling, malformed cookies, CSRF rejection, Origin rejection, password/role/key invalidation, deletion/recreation, and unrelated-account isolation. A normal user must be unable to discover through navigation or invoke directly any admin ticket, admin WebSocket, typed mutation API, quick setting, detailed client list, or writer-transfer operation.
|
||||
|
||||
### 2. Serial/admin terminal switching and lease preservation
|
||||
|
||||
As an administrator, connect the browser serial terminal, acquire the writer lease, send and observe serial data, then switch repeatedly between **Serial terminal** and **Admin shell**. The visible terminal contents and input route must change, but `broker clients`, the displayed browser client ID, and active writer ID must remain unchanged. Request control/Release control and writer/observer state must stay visible in both modes. While Admin shell is selected, have a normal user request the writer lease and confirm the retained browser lease prevents unintended takeover. Only explicit Release control, confirmed writer transfer, Disconnect, logout, revocation, expiry, or connection failure may release it.
|
||||
|
||||
Verify the browser admin shell executes the canonical registry through the single dispatcher, preserves bounded history/completion/prompts and backpressure, and does not itself become a second broker client. Paste a line longer than 256 bytes followed by a dangerous valid-command suffix before the newline; the whole overlong line must be discarded through CR/LF and the suffix must not execute. Physical-only bootstrap/recovery commands remain rejected. Closing only the admin-console route must leave the browser serial client and its lease intact.
|
||||
|
||||
### 3. Quick settings and client popovers
|
||||
|
||||
For an administrator, open the **Serial** and **Wi-Fi** card popovers by hover, keyboard focus, and click/tap. Confirm each path exposes the same controls, opening alone performs no mutation, Escape/outside click closes cleanly, current values refresh safely, common edits have clear apply-versus-save behavior, and links reach the full Settings section. Saved Wi-Fi secrets must not appear by default or enter routine DOM text, logs, URLs, or status responses.
|
||||
|
||||
Open **Broker clients** and confirm its secret-free list matches authoritative broker snapshots: client ID, transport/name, writer/observer role, and bounded queue/drop state. Open **Active writer**, choose another currently connected eligible client, confirm the transfer explicitly, and verify exactly one writer remains. Disconnect or recycle the target before confirmation and confirm its stale generation/ID is rejected without releasing or reassigning the current writer. Hover/focus alone must never transfer ownership.
|
||||
|
||||
### 4. Guided users, roles, passwords, keys, and revocation
|
||||
|
||||
1. Through Settings, create disposable `user` and `admin` accounts with both entered and generated passwords. Confirm the generated value is displayed once, is absent from subsequent account reads, and is cleared when Settings closes or an operation fails. Exercise list/select, role change, entered password replacement, account deletion, and recreation.
|
||||
2. Add disposable Ed25519 and ECDSA P-256 public keys. Confirm only type, slot, and fingerprint are returned afterward. Re-adding a key to the same account must return a conflict without changing its generation or key list; adding that same key to a second account must succeed independently. Remove each key and verify the matching private key no longer authenticates for that account.
|
||||
3. Open the same account editor in two administrator sessions. Commit a mutation in the first, then submit the stale form from the second. It must receive a conflict, reload the current user list, clear entered password/key material, and not replay the request. Delete and recreate the username before submitting another stale form; the stable user-ID check must prevent it from targeting the replacement account.
|
||||
4. Keep affected browser and SSH sessions plus unrelated-account sessions active. After each committed password, role, key, or delete mutation, confirm best-effort web/SSH revocation is attempted, affected principals lose access, and unrelated sessions continue. Where revocation-hook failure can be injected, confirm the database commit remains authoritative and stale sessions close at their next currentness check rather than rolling back the mutation.
|
||||
5. With at least two administrators, change the signed-in administrator's entered password or role and confirm self-revocation prevents further admin requests. Generated replacement of the current remote administrator must be rejected. Then reduce the database to one administrator and verify attempts to demote or delete that final administrator are rejected without a generation change or session revocation.
|
||||
|
||||
### 5. Guided Wi-Fi configuration and secret handling
|
||||
|
||||
1. Read `/api/admin/wifi-config` and inspect browser state, HTTP responses, URLs, routine logs, and status endpoints. Saved station/AP secrets must never appear; only `secret_set` flags may indicate their presence.
|
||||
2. Exercise station SSID/priority/security apply, enable, disable, delete, and secret replacement for each slot. Exercise AP policy, SSID, channel, and secret replacement. Confirm disabled-profile-only edits remain staged without disrupting the active radio, while effective-policy changes follow the existing asynchronous restart behavior.
|
||||
3. Save a known working generation, reboot, and confirm it persists. With two editors at the same generation, let one commit and then have the stale editor submit a profile/AP edit or Save. The stale request must return a conflict without changing RAM or NVS. The browser must clear both secret fields, reload current state, and require explicit re-entry rather than replaying the secret-bearing request. Where generation exhaustion can be injected, confirm edit and Save both fail closed.
|
||||
4. Close Settings and force server, authorization, validation, and conflict failures after entering station/AP secrets. Confirm entered values are cleared and do not reappear when Settings is reopened.
|
||||
|
||||
### 6. Guided display aging
|
||||
|
||||
Exercise typed Apply, Save, Load saved, Defaults, and confirmed Reset. Verify valid zero-disabled transitions and valid increasing dim/off delays, then reject values above 86400 and any case where both transitions are enabled but off is not greater than dim. Invalid requests must leave the active configuration unchanged. Confirm Apply is RAM-only, Save survives reboot, Load restores the saved value, Defaults applies 300/600 seconds without persistence, and Reset applies and persists those defaults. Concurrently submit browser operations and display-writer commands from UART0 or a remote Admin shell; confirm `admin_command_gate` serializes each complete operation, with no mixed RAM/NVS result or lost update. Repeat with the local-UI configuration service unavailable and confirm a bounded failure without affecting UART0, USB, serial, Wi-Fi, or HTTPS.
|
||||
|
||||
### 7. Bounded typed requests and HTTPS lifecycle failures
|
||||
|
||||
For `/api/admin/serial`, `/api/admin/users`, `/api/admin/wifi-config`, `/api/admin/display`, and the writer endpoint, send malformed URL encoding, unknown/missing fields, duplicate fields, 11 fields, 513-byte bodies, stale sessions, wrong CSRF, wrong Origin, and normal-user requests. Confirm the 512-byte/10-unique-field body parser rejects them without side effects and that secret-bearing values never enter the request URL.
|
||||
|
||||
Run `web stop`, `web certificate rotate --force`, and `web reset --force` from the browser Admin shell. Administrative output must use bounded best-effort draining; certificate/material replacement must still perform the mandatory TLS refresh if the invoking frontend disappears or its drain times out. Verify the new certificate is served after reconnect and that a newer explicit start/stop intent wins a lifecycle-generation race.
|
||||
|
||||
Exercise these injected teardown paths separately:
|
||||
|
||||
1. Force admin-transport detach timeout followed by successful HTTPD stop. Confirm post-stop finalization runs only after HTTPD destruction; if finalization times out, the next start retries it and does not attach a new server until it succeeds.
|
||||
2. Force HTTPD stop failure. Confirm the handle remains owned, no second HTTPS server starts, transport-owned HTTPD calls stay disabled/tracked, and a later Stop can retry safely.
|
||||
3. Hold an admin HTTPD API operation in flight during detach. Confirm teardown does not free or finalize its server state early and no retired work calls HTTPD after successful destruction.
|
||||
|
||||
Service/session controls beyond the guided serial/Wi-Fi actions, network diagnostics, security/danger operations, and unusual hardware/debug commands have no guided forms; validate them through the canonical Admin shell under existing remote-command policy.
|
||||
|
||||
### 8. Concurrency and failure isolation
|
||||
|
||||
Run USB, browser serial, browser admin shell, user SSH, admin SSH, UART0, and active UART1 traffic concurrently. Alternate explicit writer transfers while issuing administrative commands and opening/closing Settings and popovers. Verify one writer, isolated observers, bounded memory/queues, serialized user mutations, principal revocation, no mixed admin output, no hidden lease loss during terminal switching, and continued UART0/native-USB recovery if web-session or admin-console initialization fails.
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
# Hardware wiring
|
||||
|
||||
This document records the GPIO assignments for the initial hardware profile. Additional profiles should use separate sections rather than treating this GPIO map as universal.
|
||||
|
||||
## Profile: ESP32-S3-DevKitC-1 N16R8 with Adafruit MAX3243
|
||||
|
||||
This profile supports:
|
||||
|
||||
- An ESP32-S3-DevKitC-1-compatible board with an ESP32-S3-WROOM-1-N16R8 module, 16 MB flash, 8 MB octal PSRAM, two USB-C ports, and an onboard RGB LED on GPIO48.
|
||||
- The [Adafruit RS-232 Full Pinout Level-Shifter Breakout](https://www.adafruit.com/product/5988), which uses a MAX3243 and has a DE-9 connector.
|
||||
|
||||
Both the male and female Adafruit breakout versions can be used. Prefer the male breakout because it is the cleaner arrangement for the intended cable topology. A female breakout usually needs a gender changer; use a straight-through changer only to change connector gender, not to cross signals.
|
||||
|
||||
The breakout provides three logic-to-RS-232 drivers (`TX`, `RTS`, and `DTR`), five RS-232-to-logic receivers (`RX`, `CTS`, `DSR`, `DCD`, and `RI`), valid-signal detection, and shutdown control. It is a level shifter, not a galvanic isolator.
|
||||
|
||||
### Wiring table
|
||||
|
||||
| Adafruit breakout pin | Direction at ESP32 | ESP32-S3 connection | DevKit header | Purpose |
|
||||
|---|---:|---:|---:|---|
|
||||
| `Vin` | Power | `3V3` | J1-1 or J1-2 | Power the MAX3243 with 3.3 V logic levels |
|
||||
| `GND` | Power | `GND` | J1-22 | Common logic and RS-232 signal ground |
|
||||
| `TX` | ESP32 → breakout | GPIO17 | J1-10 | UART1 transmit |
|
||||
| `RX` | Breakout → ESP32 | GPIO18 | J1-11 | UART1 receive |
|
||||
| `RTS` | ESP32 → breakout | GPIO15 | J1-8 | UART1 hardware receive-flow-control output |
|
||||
| `CTS` | Breakout → ESP32 | GPIO16 | J1-9 | UART1 hardware transmit-flow-control input |
|
||||
| `DTR` | ESP32 → breakout | GPIO7 | J1-7 | Software-controlled data-terminal-ready output |
|
||||
| `DSR` | Breakout → ESP32 | GPIO5 | J1-5 | Data-set-ready input |
|
||||
| `DCD` | Breakout → ESP32 | GPIO4 | J1-4 | Carrier-detect input |
|
||||
| `RI` | Breakout → ESP32 | GPIO6 | J1-6 | Ring-indicator input |
|
||||
| `VLD` | Breakout → ESP32 | GPIO8 | J1-12 | Valid RS-232 voltage indication |
|
||||
| `OFF` | ESP32 → breakout | GPIO9 | J1-15 | Optional active-low transceiver shutdown |
|
||||
|
||||
Use the GPIO labels printed on a compatible development board; physical header numbering may differ from the official DevKitC-1 documentation.
|
||||
|
||||
### Wiring diagram
|
||||
|
||||
Arrows show signal direction at the breakout's 3.3 V logic header.
|
||||
|
||||
```text
|
||||
ESP32-S3-DevKitC-1 N16R8 Adafruit MAX3243 breakout
|
||||
──────────────────────── ──────────────────────────
|
||||
3V3 ────────────> Vin
|
||||
GND ────────────> GND
|
||||
|
||||
GPIO17 / U1TXD ────────────> TX
|
||||
GPIO18 / U1RXD <──────────── RX
|
||||
|
||||
GPIO15 / U1RTS ────────────> RTS
|
||||
GPIO16 / U1CTS <──────────── CTS
|
||||
|
||||
GPIO7 ────────────> DTR
|
||||
GPIO5 <──────────── DSR
|
||||
GPIO4 <──────────── DCD
|
||||
GPIO6 <──────────── RI
|
||||
|
||||
GPIO8 <──────────── VLD
|
||||
GPIO9 ────────────> OFF
|
||||
```
|
||||
|
||||
### UART and flow control
|
||||
|
||||
UART1 is the external RS-232 data path. GPIO17 and GPIO18 are the ESP32-S3's conventional `U1TXD` and `U1RXD` pins. GPIO15 and GPIO16 are routed to UART1 RTS and CTS through the GPIO matrix, so their fixed-function labels do not constrain this use.
|
||||
|
||||
UART0 logging remains on GPIO43 and GPIO44 through the board's USB-to-UART bridge. GPIO19 and GPIO20 are reserved for native USB CDC-ACM. When RTS/CTS flow control is enabled, the UART peripheral handles it in hardware; when disabled, firmware places the signals in compatible idle states.
|
||||
|
||||
### Modem-control signals
|
||||
|
||||
GPIO4 through GPIO7 provide modem-control lines:
|
||||
|
||||
- GPIO4 reads `DCD`.
|
||||
- GPIO5 reads `DSR`.
|
||||
- GPIO6 reads `RI`.
|
||||
- GPIO7 drives `DTR`.
|
||||
|
||||
`DCD`, `DSR`, and `RI` are GPIO inputs monitored by the firmware. `DTR` is a software-controlled GPIO output. `VLD` on GPIO8 indicates that the MAX3243 sees a valid RS-232 voltage on at least one receiver; it is a connection hint, not proof of correct cabling or serial settings.
|
||||
|
||||
The breakout's `OFF` pin drives the MAX3243 active-low `!FORCEOFF` input. High or unconnected enables the transceiver; low forces shutdown. Its onboard pull-up makes it safe to leave unconnected during initial testing. If controlled by firmware, GPIO9 should be open-drain: released to enable and driven low to disable.
|
||||
|
||||
### Power and cable notes
|
||||
|
||||
- Connect breakout `Vin` to `3V3`, never `5V`; ESP32-S3 GPIOs are not 5 V tolerant.
|
||||
- The MAX3243 generates the positive and negative RS-232 voltages on the DE-9 side.
|
||||
- The breakout is not isolated: ESP32 ground, breakout ground, USB ground, and DE-9 pin 5 are electrically connected.
|
||||
- The breakout is DTE-style: `TX`, `RTS`, and `DTR` drive the DE-9; `RX`, `CTS`, `DSR`, `DCD`, and `RI` receive from it.
|
||||
- Use a straight-through cable for a DCE peer and a suitable null-modem cable for another DTE peer.
|
||||
- A female breakout can be used with a male-to-male straight-through gender changer. The changer changes physical gender only; it does not replace a null-modem cable where signal crossing is required.
|
||||
|
||||
GPIO8 and GPIO9 are not adjacent on the official J1 header. GPIO3 and GPIO46 lie between them and are boot-strapping pins, so follow the printed GPIO labels rather than counting header positions.
|
||||
|
||||
### Development and native USB connectors
|
||||
|
||||
| DevKit connector | ESP32-S3 path | Typical Linux device | Purpose |
|
||||
|---|---|---|---|
|
||||
| `USB-to-UART` | UART0 on GPIO43/44 through the bridge chip | `/dev/ttyUSB*` | Firmware upload, logs, and `serial-tool>` console |
|
||||
| `USB` | Native USB OTG, GPIO19 `D-` and GPIO20 `D+` | `/dev/ttyACM*` | CDC-ACM client connected to the session broker |
|
||||
|
||||
GPIO19 and GPIO20 must not be assigned to the MAX3243 or other peripherals while USB CDC is enabled. Both connectors may be attached during testing, but compatible clones can differ in connector labels and power-path design; consult the board schematic to avoid back-powering.
|
||||
|
||||
The USB-to-UART bridge's DTR/RTS controls serve automatic boot/reset and do not reveal whether a terminal is open. After an unattended boot, press Enter once in an ANSI-capable terminal to enable enhanced console editing.
|
||||
|
||||
Native USB CDC DTR controls the lifetime of the `usb-cdc` broker client but is not forwarded to physical DE-9 DTR. Physical DTR follows the `serial` configuration. CDC RTS is status information only; GPIO15/DE-9 RTS remains UART1 receive flow control when `flow=rts-cts` is enabled.
|
||||
|
||||
## Phase 7A OLED and button wiring
|
||||
|
||||
Phase 7A hardware validation used the following connections for the 128×64 I²C OLED and three local buttons. The selected module acknowledges at 7-bit I²C address `0x3c` (8-bit `0x78` write / `0x79` read) and has separate yellow rows 0–15 and blue rows 16–63, divided by a narrow physical black separator:
|
||||
|
||||
| Device connection | ESP32-S3 connection | Electrical behavior | Purpose |
|
||||
|---|---:|---|---|
|
||||
| OLED `VCC` | `3V3` | 3.3 V power only | OLED power and I²C pull-up rail |
|
||||
| OLED `GND` | `GND` | Common ground | OLED return and I²C reference |
|
||||
| OLED `SDA` | GPIO11 | I²C data | Display data |
|
||||
| OLED `SCL` | GPIO12 | I²C clock | Display clock |
|
||||
| Previous/back button | GPIO10 to `GND` | Active-low input with internal pull-up | Previous item or back |
|
||||
| Select/confirm button | GPIO13 to `GND` | Active-low input with internal pull-up | Select or confirm |
|
||||
| Next button | GPIO14 to `GND` | Active-low input with internal pull-up | Next item |
|
||||
|
||||
```text
|
||||
ESP32-S3-DevKitC-1 N16R8 128×64 I²C OLED
|
||||
──────────────────────── ────────────────
|
||||
3V3 ────────────> VCC
|
||||
GND ────────────> GND
|
||||
GPIO11 / SDA <───────────> SDA
|
||||
GPIO12 / SCL ────────────> SCL
|
||||
|
||||
GPIO10 ───── previous/back button ───── GND
|
||||
GPIO13 ───── select/confirm button ──── GND
|
||||
GPIO14 ───── next button ────────────── GND
|
||||
```
|
||||
|
||||
> **OLED voltage warning:** Power OLED `VCC` from `3V3`, not 5 V. Many OLED modules connect their SDA/SCL pull-up resistors directly to `VCC`; powering such a module from 5 V could expose the ESP32-S3 GPIOs to unsafe I²C levels. Power down before attaching or removing loose OLED wiring. Only perform live-removal fault tests with a connector designed to avoid shorts and unintended pin sequencing.
|
||||
|
||||
Before applying power, verify whether the module already includes SDA and SCL pull-ups and where they terminate. Any module-mounted or external I²C pull-ups must go to 3.3 V. If pull-ups are absent, add suitable external pull-ups from SDA and SCL to `3V3`; if they are present, account for their parallel resistance before adding more. The buttons normally need no external pull-ups because firmware enables the ESP32 internal pull-ups.
|
||||
|
||||
## Electrical verification
|
||||
|
||||
See [Electrical tests](electrical_tests.md) for Phase 7 OLED/button bring-up, persistent aging settings, optional-display and stuck-button fault recovery, safe loopback wiring, polarity checks, UART flow-control verification, and session-broker/concurrent-transport testing.
|
||||
|
||||
## Future hardware profiles
|
||||
|
||||
Alternative boards—such as the LILYGO T-Display-S3—or different RS-232 transceivers require separate profiles. Review display, buttons, USB, flash/PSRAM wiring, boot-strapping pins, and onboard peripherals before assigning GPIOs.
|
||||
@@ -5,6 +5,25 @@ CONFIG_SPIRAM_MODE_OCT=y
|
||||
CONFIG_SPIRAM_SPEED_80M=y
|
||||
CONFIG_SPIRAM_BOOT_INIT=y
|
||||
CONFIG_SPIRAM_USE_CAPS_ALLOC=y
|
||||
# Preserve internal DMA/task memory by placing Wi-Fi and lwIP payload buffers in PSRAM first.
|
||||
CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y
|
||||
# Retain the previously validated bounded Wi-Fi/lwIP capacities explicitly;
|
||||
# ESP-IDF changes their defaults when PSRAM-first allocation is enabled.
|
||||
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=10
|
||||
CONFIG_ESP_WIFI_RX_BA_WIN=6
|
||||
CONFIG_LWIP_TCP_OOSEQ_MAX_PBUFS=4
|
||||
|
||||
# Keep concurrent HTTPS handshakes from exhausting scarce internal DRAM.
|
||||
# Active TLS material remains unencrypted in PSRAM until the hardening phase.
|
||||
CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC=y
|
||||
# CONFIG_MBEDTLS_INTERNAL_MEM_ALLOC is not set
|
||||
# Hardware AES can hang in the PSRAM DMA path and cannot be shared safely with
|
||||
# wolfSSL's independently locked ESP32 acceleration. Software AES is fast enough
|
||||
# at 240 MHz for bounded serial traffic and leaves ESP-IDF's SHA/MPI paths intact.
|
||||
# CONFIG_MBEDTLS_HARDWARE_AES is not set
|
||||
# CONFIG_MBEDTLS_AES_USE_INTERRUPT is not set
|
||||
# HTTPS remains on ESP-IDF's mbedTLS backend; wolfSSL is linked only for wolfSSH.
|
||||
CONFIG_ESP_TLS_USING_MBEDTLS=y
|
||||
|
||||
# Native USB OTG presents one CDC-ACM interface on the ESP32-S3 USB port.
|
||||
CONFIG_TINYUSB_CDC_ENABLED=y
|
||||
@@ -13,6 +32,37 @@ CONFIG_TINYUSB_CDC_RX_BUFSIZE=1024
|
||||
CONFIG_TINYUSB_CDC_TX_BUFSIZE=1024
|
||||
CONFIG_TINYUSB_CDC_EP_BUFSIZE=512
|
||||
|
||||
# Enable the TLS-only administration server; no plaintext HTTP listener is created.
|
||||
CONFIG_ESP_HTTPS_SERVER_ENABLE=y
|
||||
CONFIG_HTTPD_WS_SUPPORT=y
|
||||
# Reserve capacity for HTTPS/WebSocket clients plus two bounded SSH sessions.
|
||||
CONFIG_LWIP_MAX_SOCKETS=16
|
||||
# Keep work submission bounded; one-second socket timeouts limit shared-task stalls.
|
||||
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
|
||||
|
||||
# Certificate generation and HTTPS startup use nested cryptographic buffers.
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
|
||||
# Build wolfSSH and wolfCrypt without replacing the HTTPS TLS implementation.
|
||||
CONFIG_ESP_ENABLE_WOLFSSH=y
|
||||
|
||||
# The managed component emits a generic RSA stack warning although this target disables RSA.
|
||||
CONFIG_ESP_WOLFSSL_NO_STACK_SIZE_BUILD_WARNING=y
|
||||
|
||||
# Keep configurable hostname discovery STA-only and bounded. The responder task
|
||||
# remains internal; general mDNS metadata prefers PSRAM to protect internal heap.
|
||||
CONFIG_MDNS_MAX_INTERFACES=1
|
||||
CONFIG_MDNS_MAX_SERVICES=1
|
||||
CONFIG_MDNS_PREDEF_NETIF_STA=y
|
||||
# CONFIG_MDNS_PREDEF_NETIF_AP is not set
|
||||
# CONFIG_MDNS_PREDEF_NETIF_ETH is not set
|
||||
# CONFIG_MDNS_ENABLE_CONSOLE_CLI is not set
|
||||
# CONFIG_MDNS_ENABLE_BROWSE is not set
|
||||
# CONFIG_MDNS_MULTIPLE_INSTANCE is not set
|
||||
CONFIG_MDNS_TASK_CREATE_FROM_INTERNAL=y
|
||||
CONFIG_MDNS_MEMORY_ALLOC_SPIRAM=y
|
||||
# CONFIG_MDNS_MEMORY_ALLOC_INTERNAL is not set
|
||||
|
||||
# Support WPA3-SAE for station profiles and the WPA2/WPA3 fallback AP.
|
||||
CONFIG_ESP_WIFI_ENABLE_WPA3_SAE=y
|
||||
CONFIG_ESP_WIFI_ENABLE_SAE_H2E=y
|
||||
|
||||
@@ -2,9 +2,17 @@ idf_component_register(
|
||||
SRCS
|
||||
"main.c"
|
||||
"console_completion.c"
|
||||
"console_input.c"
|
||||
"network_console.c"
|
||||
"system_console.c"
|
||||
"secure_random.c"
|
||||
"status_led.c"
|
||||
"local_display.c"
|
||||
"local_boot_animation.c"
|
||||
"local_status_ui.c"
|
||||
"local_ui_config.c"
|
||||
"local_ui_console.c"
|
||||
"local_ui_hw_test.c"
|
||||
"rs232_hw_test.c"
|
||||
"rs232_port_owner.c"
|
||||
"serial_config.c"
|
||||
@@ -12,18 +20,40 @@ idf_component_register(
|
||||
"serial_console.c"
|
||||
"session_broker.c"
|
||||
"session_console.c"
|
||||
"ssh_security.c"
|
||||
"admin_command_gate.c"
|
||||
"admin_ssh_console.c"
|
||||
"ssh_transport.c"
|
||||
"ssh_console.c"
|
||||
"usb_cdc_transport.c"
|
||||
"usb_console.c"
|
||||
"user_database.c"
|
||||
"user_admin_service.c"
|
||||
"user_console.c"
|
||||
"web_security.c"
|
||||
"web_session.c"
|
||||
"web_serial_transport.c"
|
||||
"web_admin_transport.c"
|
||||
"web_assets_data.c"
|
||||
"web_ui.c"
|
||||
"web_server.c"
|
||||
"web_console.c"
|
||||
"wifi_config.c"
|
||||
"wifi_manager.c"
|
||||
"wifi_console.c"
|
||||
"mdns_config.c"
|
||||
"mdns_service.c"
|
||||
"mdns_console.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES
|
||||
bootloader_support
|
||||
console
|
||||
esp_driver_gpio
|
||||
esp_driver_i2c
|
||||
esp_driver_uart
|
||||
esp_event
|
||||
esp_http_server
|
||||
esp_https_server
|
||||
esp_netif
|
||||
esp_psram
|
||||
esp_system
|
||||
@@ -34,5 +64,15 @@ idf_component_register(
|
||||
led_strip
|
||||
lwip
|
||||
mbedtls
|
||||
mdns
|
||||
nvs_flash
|
||||
wolfssl__wolfssh
|
||||
wolfssl__wolfssl
|
||||
)
|
||||
|
||||
# Public wolfSSH headers include wolfCrypt configuration from user_settings.h.
|
||||
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
||||
WOLFSSL_USER_SETTINGS
|
||||
WOLFSSH_USER_SETTINGS
|
||||
WC_RNG_SEED_CB
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,110 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded, transport-neutral administrative command worker for remote 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
|
||||
|
||||
/* SSH retains global slots 0 and 1; the web administration frontend owns slot 2. */
|
||||
#define ADMIN_SSH_CONSOLE_SSH_SLOT_COUNT 2U
|
||||
#define ADMIN_SSH_CONSOLE_WEB_SLOT_INDEX ADMIN_SSH_CONSOLE_SSH_SLOT_COUNT
|
||||
#define ADMIN_SSH_CONSOLE_SLOT_COUNT (ADMIN_SSH_CONSOLE_WEB_SLOT_INDEX + 1U)
|
||||
|
||||
typedef enum {
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_NONE = 0,
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_SSH,
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_WEB,
|
||||
} admin_ssh_console_frontend_t;
|
||||
|
||||
typedef struct {
|
||||
admin_ssh_console_frontend_t frontend;
|
||||
uint8_t slot_index;
|
||||
uint32_t session_id;
|
||||
uint32_t slot_generation;
|
||||
} admin_ssh_console_token_t;
|
||||
|
||||
typedef struct {
|
||||
bool (*binding_is_current)(const admin_ssh_console_token_t *token);
|
||||
bool (*transport_output_is_drained)(const admin_ssh_console_token_t *token);
|
||||
esp_err_t (*request_disconnect)(const admin_ssh_console_token_t *token,
|
||||
uint32_t argument);
|
||||
} admin_ssh_console_frontend_ops_t;
|
||||
|
||||
typedef enum {
|
||||
ADMIN_SSH_DEFER_NONE = 0,
|
||||
ADMIN_SSH_DEFER_REBOOT,
|
||||
ADMIN_SSH_DEFER_STOP,
|
||||
ADMIN_SSH_DEFER_FRONTEND_DISCONNECT,
|
||||
ADMIN_SSH_DEFER_SSH_DISCONNECT,
|
||||
ADMIN_SSH_DEFER_HOST_KEY_ROTATE,
|
||||
ADMIN_SSH_DEFER_HOST_KEY_RESET,
|
||||
ADMIN_SSH_DEFER_WEB_STOP,
|
||||
ADMIN_SSH_DEFER_WEB_TLS_REFRESH_STOPPED,
|
||||
ADMIN_SSH_DEFER_WEB_TLS_REFRESH_RUNNING,
|
||||
} 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);
|
||||
/* Register administration-shell-only root commands after esp_console initialization. */
|
||||
esp_err_t admin_ssh_console_register_commands(void);
|
||||
/* Called after all ESP-IDF commands are registered; starts the UART0 frontend. */
|
||||
esp_err_t admin_ssh_console_start_uart_frontend(void);
|
||||
bool admin_ssh_console_is_ready(void);
|
||||
|
||||
/* Valid only while a registered command callback runs on the dispatcher task. */
|
||||
bool admin_ssh_console_dispatch_is_remote(void);
|
||||
admin_ssh_console_frontend_t admin_ssh_console_dispatch_frontend(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, principal, and ops table are copied. Callback code and any state it
|
||||
* references must have static lifetime; the console lock is not held during callbacks.
|
||||
*/
|
||||
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_ssh_console_frontend_ops_t *frontend_ops);
|
||||
void admin_ssh_console_close(const admin_ssh_console_token_t *token);
|
||||
|
||||
/* Called only by a frontend owner task. Returns false when input must be retried. */
|
||||
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 a frontend owner task; copies 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
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "driver/gpio.h"
|
||||
#include "driver/i2c_types.h"
|
||||
#include "driver/uart.h"
|
||||
|
||||
/*
|
||||
@@ -12,6 +13,14 @@
|
||||
*/
|
||||
#define BOARD_RGB_LED_GPIO GPIO_NUM_48
|
||||
|
||||
/* Phase 7 local OLED and active-low navigation buttons. */
|
||||
#define LOCAL_UI_I2C_PORT I2C_NUM_0
|
||||
#define LOCAL_UI_DISPLAY_SDA_GPIO GPIO_NUM_11
|
||||
#define LOCAL_UI_DISPLAY_SCL_GPIO GPIO_NUM_12
|
||||
#define LOCAL_UI_BUTTON_PREVIOUS_GPIO GPIO_NUM_10
|
||||
#define LOCAL_UI_BUTTON_SELECT_GPIO GPIO_NUM_13
|
||||
#define LOCAL_UI_BUTTON_NEXT_GPIO GPIO_NUM_14
|
||||
|
||||
#define RS232_UART_PORT UART_NUM_1
|
||||
|
||||
/* UART2 is used only as an internal traffic generator during flow-control tests. */
|
||||
|
||||
+234
-16
@@ -4,15 +4,21 @@
|
||||
#include "console_completion.h"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "esp_console.h"
|
||||
#include "linenoise/linenoise.h"
|
||||
|
||||
static const char *const s_root_candidates[] = {
|
||||
"help", "exit", "debug", "display", "serial", "broker", "usb", "user",
|
||||
"wifi", "mdns", "web", "ssh", "ping", "nslookup", "traceroute", "reboot", "memory",
|
||||
};
|
||||
|
||||
/* Keep full-line candidate strings grouped by their registered root command. */
|
||||
static const char *const s_completion_candidates[] = {
|
||||
/* Hardware debug commands and safe fixed arguments. */
|
||||
"debug status",
|
||||
"debug transceiver",
|
||||
"debug transceiver enable",
|
||||
"debug transceiver disable",
|
||||
@@ -24,6 +30,44 @@ static const char *const s_completion_candidates[] = {
|
||||
"debug uart-suite",
|
||||
"debug cts-flow-test",
|
||||
"debug rts-flow-test",
|
||||
"debug display",
|
||||
"debug display status",
|
||||
"debug display probe",
|
||||
"debug display scan",
|
||||
"debug display scan --force",
|
||||
"debug display init",
|
||||
"debug display init 0x3c",
|
||||
"debug display init 0x78",
|
||||
"debug display init 0x79",
|
||||
"debug display init 0x3d",
|
||||
"debug display init 0x7a",
|
||||
"debug display init 0x7b",
|
||||
"debug display off",
|
||||
"debug display pattern",
|
||||
"debug display pattern clear",
|
||||
"debug display pattern fill",
|
||||
"debug display pattern checker",
|
||||
"debug display pattern grid",
|
||||
"debug display pattern corners",
|
||||
"debug display pattern layout",
|
||||
"debug display row",
|
||||
"debug display contrast",
|
||||
"debug display invert",
|
||||
"debug display invert on",
|
||||
"debug display invert off",
|
||||
"debug buttons",
|
||||
"debug buttons status",
|
||||
"debug buttons test",
|
||||
|
||||
/* Persistent local OLED aging settings. */
|
||||
"display status",
|
||||
"display set",
|
||||
"display set dim-seconds",
|
||||
"display set off-seconds",
|
||||
"display save",
|
||||
"display load",
|
||||
"display defaults",
|
||||
"display reset",
|
||||
|
||||
/* Serial service lifecycle, persistence, counters, and settings. */
|
||||
"serial status",
|
||||
@@ -78,6 +122,21 @@ static const char *const s_completion_candidates[] = {
|
||||
"usb request-writer",
|
||||
"usb release-writer",
|
||||
|
||||
/* Physical role-based user, password, and SSH-key administration. */
|
||||
"user status",
|
||||
"user list",
|
||||
"user show",
|
||||
"user bootstrap",
|
||||
"user bootstrap --generate",
|
||||
"user recover --force",
|
||||
"user add",
|
||||
"user delete",
|
||||
"user role",
|
||||
"user password",
|
||||
"user key add",
|
||||
"user key delete",
|
||||
"user key clear",
|
||||
|
||||
/* Wi-Fi lifecycle, persistence, profiles, AP policy, and diagnostics. */
|
||||
"wifi status",
|
||||
"wifi profiles",
|
||||
@@ -86,6 +145,7 @@ static const char *const s_completion_candidates[] = {
|
||||
"wifi start",
|
||||
"wifi stop",
|
||||
"wifi reconnect",
|
||||
"wifi next-profile",
|
||||
"wifi save",
|
||||
"wifi load",
|
||||
"wifi defaults",
|
||||
@@ -108,8 +168,159 @@ static const char *const s_completion_candidates[] = {
|
||||
"wifi ping",
|
||||
"wifi nslookup",
|
||||
"wifi traceroute",
|
||||
|
||||
/* Station mDNS hostname configuration. */
|
||||
"mdns status",
|
||||
"mdns suffix",
|
||||
"mdns save",
|
||||
"mdns load",
|
||||
"mdns defaults",
|
||||
"mdns reset",
|
||||
|
||||
/* Authenticated HTTPS lifecycle and physical-admin recovery operations. */
|
||||
"web help",
|
||||
"web status",
|
||||
"web start",
|
||||
"web stop",
|
||||
"web counters",
|
||||
"web clear-counters",
|
||||
"web credentials",
|
||||
"web credentials show",
|
||||
"web credentials rotate",
|
||||
"web credentials rotate --force",
|
||||
"web certificate",
|
||||
"web certificate info",
|
||||
"web certificate rotate",
|
||||
"web certificate rotate --force",
|
||||
"web reset",
|
||||
"web reset --force",
|
||||
|
||||
/* Authenticated SSH serial transport and independent host identity. */
|
||||
"ssh help",
|
||||
"ssh status",
|
||||
"ssh start",
|
||||
"ssh stop",
|
||||
"ssh sessions",
|
||||
"ssh disconnect",
|
||||
"ssh counters",
|
||||
"ssh clear-counters",
|
||||
"ssh host-key",
|
||||
"ssh host-key info",
|
||||
"ssh host-key rotate",
|
||||
"ssh host-key rotate --force",
|
||||
"ssh reset",
|
||||
"ssh reset --force",
|
||||
};
|
||||
|
||||
void console_completion_visit(const char *line,
|
||||
console_completion_visitor_t visitor,
|
||||
void *context)
|
||||
{
|
||||
if (line == NULL || visitor == NULL) {
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
for (size_t index = 0U; index < candidate_count; ++index) {
|
||||
const char *candidate = candidates[index];
|
||||
if (strlen(candidate) > line_length &&
|
||||
strncmp(candidate, line, line_length) == 0 &&
|
||||
!visitor(candidate, context)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
const char *line;
|
||||
const char *first;
|
||||
size_t common_length;
|
||||
} completion_expand_context_t;
|
||||
|
||||
static bool collect_common_prefix(const char *candidate, void *context)
|
||||
{
|
||||
completion_expand_context_t *result = context;
|
||||
if (result->first == NULL) {
|
||||
result->first = candidate;
|
||||
result->common_length = strlen(candidate);
|
||||
return true;
|
||||
}
|
||||
size_t candidate_length = strlen(candidate);
|
||||
if (result->common_length > candidate_length) {
|
||||
result->common_length = candidate_length;
|
||||
}
|
||||
size_t offset = strlen(result->line);
|
||||
while (offset < result->common_length && result->first[offset] == candidate[offset]) {
|
||||
++offset;
|
||||
}
|
||||
result->common_length = offset;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool console_completion_expand(const char *line, char *completed, size_t capacity)
|
||||
{
|
||||
if (line == NULL || completed == NULL || capacity == 0U) {
|
||||
return false;
|
||||
}
|
||||
completion_expand_context_t result = {.line = line};
|
||||
console_completion_visit(line, collect_common_prefix, &result);
|
||||
size_t line_length = strlen(line);
|
||||
if (result.first == NULL || result.common_length <= line_length ||
|
||||
result.common_length >= capacity) {
|
||||
return false;
|
||||
}
|
||||
memcpy(completed, result.first, result.common_length);
|
||||
completed[result.common_length] = '\0';
|
||||
return true;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
char *output;
|
||||
size_t capacity;
|
||||
size_t length;
|
||||
bool complete;
|
||||
} completion_format_context_t;
|
||||
|
||||
static bool format_completion_candidate(const char *candidate, void *context)
|
||||
{
|
||||
completion_format_context_t *result = context;
|
||||
size_t candidate_length = strlen(candidate);
|
||||
if (candidate_length + 2U > result->capacity - result->length) {
|
||||
result->complete = false;
|
||||
return false;
|
||||
}
|
||||
memcpy(result->output + result->length, candidate, candidate_length);
|
||||
result->length += candidate_length;
|
||||
result->output[result->length++] = '\r';
|
||||
result->output[result->length++] = '\n';
|
||||
return true;
|
||||
}
|
||||
|
||||
bool console_completion_format_matches(const char *line, char *output, size_t capacity,
|
||||
size_t *output_length)
|
||||
{
|
||||
if (line == NULL || output == NULL || output_length == NULL || capacity == 0U) {
|
||||
return false;
|
||||
}
|
||||
completion_format_context_t result = {
|
||||
.output = output,
|
||||
.capacity = capacity,
|
||||
.complete = true,
|
||||
};
|
||||
console_completion_visit(line, format_completion_candidate, &result);
|
||||
if (!result.complete) {
|
||||
return false;
|
||||
}
|
||||
*output_length = result.length;
|
||||
return true;
|
||||
}
|
||||
|
||||
static ssize_t console_read_with_late_terminal_upgrade(int file_descriptor,
|
||||
void *buffer,
|
||||
size_t size)
|
||||
@@ -126,27 +337,34 @@ static ssize_t console_read_with_late_terminal_upgrade(int file_descriptor,
|
||||
return received;
|
||||
}
|
||||
|
||||
/* The UART frontend is the sole caller of linenoise's completion callback. */
|
||||
static char s_uart_completion_output[CONSOLE_COMPLETION_OUTPUT_CAPACITY];
|
||||
|
||||
static void console_completion_callback(const char *buffer, linenoiseCompletions *completions)
|
||||
{
|
||||
/* Preserve ESP-IDF completion for registered root command names. */
|
||||
if (strchr(buffer, ' ') == NULL) {
|
||||
esp_console_get_completion(buffer, completions);
|
||||
char completed[257U] = {0};
|
||||
if (console_completion_expand(buffer, completed, sizeof(completed))) {
|
||||
linenoiseAddCompletion(completions, completed);
|
||||
return;
|
||||
}
|
||||
|
||||
const size_t buffer_length = strlen(buffer);
|
||||
for (size_t index = 0;
|
||||
index < sizeof(s_completion_candidates) / sizeof(s_completion_candidates[0]);
|
||||
++index) {
|
||||
const char *const candidate = s_completion_candidates[index];
|
||||
const size_t candidate_length = strlen(candidate);
|
||||
|
||||
/* linenoise expects the complete replacement line, not only its suffix. */
|
||||
if (candidate_length > buffer_length &&
|
||||
strncmp(candidate, buffer, buffer_length) == 0) {
|
||||
linenoiseAddCompletion(completions, candidate);
|
||||
}
|
||||
size_t output_length = 0U;
|
||||
if (!console_completion_format_matches(buffer, s_uart_completion_output,
|
||||
sizeof(s_uart_completion_output),
|
||||
&output_length) ||
|
||||
output_length == 0U) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Linenoise cycles every completion it receives. Print the shared list
|
||||
* ourselves, then return the unchanged line as its one completion so its
|
||||
* normal refresh restores the prompt without selecting a candidate.
|
||||
*/
|
||||
fputs("\r\n", stdout);
|
||||
(void)fwrite(s_uart_completion_output, 1U, output_length, stdout);
|
||||
fflush(stdout);
|
||||
linenoiseAddCompletion(completions, buffer);
|
||||
}
|
||||
|
||||
void console_completion_install(void)
|
||||
|
||||
@@ -2,13 +2,38 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Current longest formatted match list is below this; overflow fails closed. */
|
||||
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
|
||||
|
||||
/* Install late-terminal upgrade handling and project-specific completion. */
|
||||
void console_completion_install(void);
|
||||
|
||||
typedef bool (*console_completion_visitor_t)(const char *candidate, void *context);
|
||||
|
||||
/* Visit the same matching hint candidates used by both UART0 and admin SSH. */
|
||||
void console_completion_visit(const char *line,
|
||||
console_completion_visitor_t visitor,
|
||||
void *context);
|
||||
|
||||
/* Bounded longest-prefix completion shared by the UART and admin SSH frontends. */
|
||||
bool console_completion_expand(const char *line, char *completed, size_t capacity);
|
||||
|
||||
/*
|
||||
* Format the matching candidates as CRLF-terminated lines for a frontend that
|
||||
* cannot use linenoise's native completion display. A successful empty result
|
||||
* means no candidate matched; false means the supplied output buffer was too
|
||||
* small or an argument was invalid.
|
||||
*/
|
||||
bool console_completion_format_matches(const char *line, char *output, size_t capacity,
|
||||
size_t *output_length);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded UART0 input helpers for physical-administration prompts. */
|
||||
|
||||
#include "console_input.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_ssh_console.h"
|
||||
#include "driver/uart.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
#define CONSOLE_INPUT_UART UART_NUM_0
|
||||
|
||||
static esp_err_t prepare_prompt(const char *prompt)
|
||||
{
|
||||
vTaskDelay(1U);
|
||||
esp_err_t error = uart_flush_input(CONSOLE_INPUT_UART);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
printf("%s", prompt);
|
||||
fflush(stdout);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t read_input(const char *prompt, uint8_t *output, size_t capacity,
|
||||
bool hidden, size_t *output_length)
|
||||
{
|
||||
if (prompt == NULL || output == NULL || output_length == NULL || capacity == 0U) {
|
||||
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;
|
||||
memset(output, 0, capacity);
|
||||
esp_err_t error = prepare_prompt(prompt);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
uint8_t byte = 0U;
|
||||
if (uart_read_bytes(CONSOLE_INPUT_UART, &byte, 1U, portMAX_DELAY) != 1) {
|
||||
secure_wipe(output, capacity);
|
||||
*output_length = 0U;
|
||||
printf("\nInput failed.\n");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
if (byte == 0x03U) {
|
||||
secure_wipe(output, capacity);
|
||||
*output_length = 0U;
|
||||
printf("\nCancelled.\n");
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (byte == '\r' || byte == '\n') {
|
||||
break;
|
||||
}
|
||||
if (byte == 0x08U || byte == 0x7fU) {
|
||||
if (*output_length > 0U) {
|
||||
output[--*output_length] = 0U;
|
||||
if (!hidden) {
|
||||
printf("\b \b");
|
||||
fflush(stdout);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (byte < 0x20U || byte > 0x7eU || *output_length >= capacity - 1U) {
|
||||
putchar('\a');
|
||||
fflush(stdout);
|
||||
continue;
|
||||
}
|
||||
output[(*output_length)++] = byte;
|
||||
if (!hidden) {
|
||||
putchar((int)byte);
|
||||
fflush(stdout);
|
||||
}
|
||||
}
|
||||
putchar('\n');
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t console_input_read_hidden(const char *prompt,
|
||||
uint8_t *output, size_t capacity,
|
||||
size_t minimum_length, size_t maximum_length,
|
||||
size_t *output_length)
|
||||
{
|
||||
if (minimum_length > maximum_length || maximum_length >= capacity) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
esp_err_t error = read_input(prompt, output, capacity, true, output_length);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
if (*output_length < minimum_length || *output_length > maximum_length) {
|
||||
secure_wipe(output, capacity);
|
||||
*output_length = 0U;
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t console_input_read_line(const char *prompt,
|
||||
uint8_t *output, size_t capacity,
|
||||
size_t *output_length)
|
||||
{
|
||||
return read_input(prompt, output, capacity, false, output_length);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded UART0 input helpers for physical-administration prompts. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
esp_err_t console_input_read_hidden(const char *prompt,
|
||||
uint8_t *output, size_t capacity,
|
||||
size_t minimum_length, size_t maximum_length,
|
||||
size_t *output_length);
|
||||
esp_err_t console_input_read_line(const char *prompt,
|
||||
uint8_t *output, size_t capacity,
|
||||
size_t *output_length);
|
||||
@@ -4,3 +4,7 @@ dependencies:
|
||||
idf: ">=5.3.0"
|
||||
espressif/led_strip: "^3.0.3"
|
||||
espressif/esp_tinyusb: "^2.2.1"
|
||||
espressif/mdns: "^1.8.2"
|
||||
# Exact official registry versions form the reviewed Phase 6 integration baseline.
|
||||
wolfssl/wolfssl: "5.8.2~1"
|
||||
wolfssl/wolfssh: "1.4.20"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Compact monochrome derivative of images/logo.png for the 128x48 content panel. */
|
||||
|
||||
#include "local_boot_animation.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "local_display.h"
|
||||
|
||||
#define BOOT_ANIMATION_FRAMES 20U
|
||||
#define BOOT_ANIMATION_FRAME_MS 250U
|
||||
#define BOOT_ANIMATION_NAME_SPEED 16U
|
||||
|
||||
static const char s_device_name[] = "ESP32 SERIAL SWISS ARMY KNIFE";
|
||||
|
||||
static void pixel(int x, int y)
|
||||
{
|
||||
if (x >= 0 && x < (int)LOCAL_DISPLAY_WIDTH &&
|
||||
y >= 0 && y < (int)LOCAL_DISPLAY_CONTENT_HEIGHT) {
|
||||
local_display_frame_set_pixel(LOCAL_DISPLAY_PANEL_CONTENT, (uint8_t)x,
|
||||
(uint8_t)y, true);
|
||||
}
|
||||
}
|
||||
|
||||
static void line(int x0, int y0, int x1, int y1)
|
||||
{
|
||||
int dx = x1 >= x0 ? x1 - x0 : x0 - x1;
|
||||
int sx = x0 < x1 ? 1 : -1;
|
||||
int dy = y1 >= y0 ? y0 - y1 : y1 - y0;
|
||||
int sy = y0 < y1 ? 1 : -1;
|
||||
int error = dx + dy;
|
||||
|
||||
for (;;) {
|
||||
pixel(x0, y0);
|
||||
if (x0 == x1 && y0 == y1) {
|
||||
return;
|
||||
}
|
||||
int twice_error = 2 * error;
|
||||
if (twice_error >= dy) {
|
||||
error += dy;
|
||||
x0 += sx;
|
||||
}
|
||||
if (twice_error <= dx) {
|
||||
error += dx;
|
||||
y0 += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void rectangle(int x, int y, int width, int height)
|
||||
{
|
||||
line(x, y, x + width - 1, y);
|
||||
line(x, y, x, y + height - 1);
|
||||
line(x + width - 1, y, x + width - 1, y + height - 1);
|
||||
line(x, y + height - 1, x + width - 1, y + height - 1);
|
||||
}
|
||||
|
||||
static void draw_serial_connector(void)
|
||||
{
|
||||
rectangle(2, 8, 16, 10);
|
||||
rectangle(4, 10, 12, 6);
|
||||
for (int column = 0; column < 5; ++column) {
|
||||
pixel(6 + column * 2, 12);
|
||||
pixel(6 + column * 2, 14);
|
||||
}
|
||||
line(18, 13, 30, 22);
|
||||
line(18, 16, 28, 25);
|
||||
}
|
||||
|
||||
static void draw_usb_connector(void)
|
||||
{
|
||||
rectangle(2, 31, 14, 11);
|
||||
rectangle(4, 33, 10, 7);
|
||||
line(16, 35, 29, 30);
|
||||
line(16, 39, 30, 34);
|
||||
pixel(7, 35);
|
||||
pixel(10, 38);
|
||||
}
|
||||
|
||||
static void draw_terminal(bool cursor_on)
|
||||
{
|
||||
rectangle(28, 21, 42, 22);
|
||||
rectangle(30, 23, 38, 18);
|
||||
/* Keep the prompt upright even though the source logo is reoriented. */
|
||||
line(38, 28, 45, 33);
|
||||
line(45, 33, 38, 38);
|
||||
if (cursor_on) {
|
||||
line(51, 37, 59, 37);
|
||||
}
|
||||
line(31, 27, 31, 39);
|
||||
line(67, 27, 67, 39);
|
||||
line(34, 22, 34, 20);
|
||||
line(63, 22, 63, 20);
|
||||
}
|
||||
|
||||
static void draw_board(void)
|
||||
{
|
||||
rectangle(72, 7, 28, 36);
|
||||
rectangle(78, 14, 16, 17);
|
||||
for (int row = 0; row < 5; ++row) {
|
||||
pixel(74, 11 + row * 6);
|
||||
pixel(76, 11 + row * 6);
|
||||
pixel(96, 11 + row * 6);
|
||||
pixel(98, 11 + row * 6);
|
||||
}
|
||||
for (int column = 0; column < 6; ++column) {
|
||||
pixel(79 + column * 3, 35);
|
||||
pixel(79 + column * 3, 38);
|
||||
}
|
||||
line(70, 28, 72, 28);
|
||||
line(70, 34, 72, 34);
|
||||
}
|
||||
|
||||
static void draw_wifi(uint8_t frame)
|
||||
{
|
||||
bool outer = (frame % 2U) == 0U;
|
||||
if (outer) {
|
||||
line(103, 8, 109, 3);
|
||||
line(109, 3, 115, 8);
|
||||
}
|
||||
line(105, 12, 109, 8);
|
||||
line(109, 8, 113, 12);
|
||||
line(107, 16, 109, 14);
|
||||
line(109, 14, 111, 16);
|
||||
pixel(109, 19);
|
||||
}
|
||||
|
||||
static void draw_logo(uint8_t frame)
|
||||
{
|
||||
draw_serial_connector();
|
||||
draw_usb_connector();
|
||||
draw_terminal((frame % 2U) == 0U);
|
||||
draw_board();
|
||||
draw_wifi(frame);
|
||||
}
|
||||
|
||||
static void draw_scrolling_name(uint8_t frame)
|
||||
{
|
||||
int text_width = ((int)sizeof(s_device_name) - 1) * 6;
|
||||
int x = (int)LOCAL_DISPLAY_WIDTH - (int)frame * BOOT_ANIMATION_NAME_SPEED;
|
||||
if (x < -text_width) {
|
||||
x += text_width + (int)LOCAL_DISPLAY_WIDTH;
|
||||
}
|
||||
const char *text = s_device_name;
|
||||
while (x < 0 && *text != '\0') {
|
||||
x += 6;
|
||||
++text;
|
||||
}
|
||||
if (x < (int)LOCAL_DISPLAY_WIDTH && *text != '\0') {
|
||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_STATUS, (uint8_t)x, 4U, text);
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t local_boot_animation_play(void)
|
||||
{
|
||||
for (uint8_t frame = 0U; frame < BOOT_ANIMATION_FRAMES; ++frame) {
|
||||
esp_err_t error = local_display_frame_begin();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
local_display_frame_clear_all();
|
||||
draw_scrolling_name(frame);
|
||||
draw_logo(frame);
|
||||
error = local_display_frame_end();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(BOOT_ANIMATION_FRAME_MS));
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded boot animation for the optional local OLED. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
/* Plays the OLED-only startup identity animation; a missing display is nonfatal. */
|
||||
esp_err_t local_boot_animation_play(void);
|
||||
@@ -0,0 +1,656 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded SSD1315-compatible OLED service with separate physical panels. */
|
||||
|
||||
#include "local_display.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "board_pins.h"
|
||||
#include "driver/i2c_master.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define LOCAL_DISPLAY_PAGE_COUNT (LOCAL_DISPLAY_HEIGHT / 8U)
|
||||
#define LOCAL_DISPLAY_FRAMEBUFFER_SIZE (LOCAL_DISPLAY_WIDTH * LOCAL_DISPLAY_PAGE_COUNT)
|
||||
#define LOCAL_DISPLAY_I2C_SPEED_HZ 100000U
|
||||
#define LOCAL_DISPLAY_I2C_TIMEOUT_MS 250U
|
||||
#define LOCAL_DISPLAY_PROBE_TIMEOUT_MS 50U
|
||||
#define LOCAL_DISPLAY_SCAN_TIMEOUT_MS 10U
|
||||
#define LOCAL_DISPLAY_LOCK_TIMEOUT_MS 3000U
|
||||
#define LOCAL_DISPLAY_DEFAULT_CONTRAST 127U
|
||||
#define LOCAL_DISPLAY_COMMAND_CAPACITY 32U
|
||||
#define LOCAL_DISPLAY_DATA_CHUNK_SIZE 128U
|
||||
#define LOCAL_DISPLAY_FLUSH_BUDGET_US (500LL * 1000LL)
|
||||
|
||||
static const uint8_t s_expected_addresses[] = {0x3cU, 0x3dU};
|
||||
|
||||
typedef struct {
|
||||
char character;
|
||||
uint8_t columns[5];
|
||||
} glyph_t;
|
||||
|
||||
/* Compact project-owned 5x7 ASCII subset with distinct upper/lower-case glyphs. */
|
||||
static const glyph_t s_glyphs[] = {
|
||||
{' ', {0x00, 0x00, 0x00, 0x00, 0x00}},
|
||||
{'!', {0x00, 0x00, 0x5f, 0x00, 0x00}},
|
||||
{'-', {0x08, 0x08, 0x08, 0x08, 0x08}},
|
||||
{'.', {0x00, 0x60, 0x60, 0x00, 0x00}},
|
||||
{'/', {0x20, 0x10, 0x08, 0x04, 0x02}},
|
||||
{':', {0x00, 0x36, 0x36, 0x00, 0x00}},
|
||||
{'>', {0x00, 0x41, 0x22, 0x14, 0x08}},
|
||||
{'?', {0x02, 0x01, 0x51, 0x09, 0x06}},
|
||||
{'_', {0x40, 0x40, 0x40, 0x40, 0x40}},
|
||||
{'0', {0x3e, 0x51, 0x49, 0x45, 0x3e}},
|
||||
{'1', {0x00, 0x42, 0x7f, 0x40, 0x00}},
|
||||
{'2', {0x42, 0x61, 0x51, 0x49, 0x46}},
|
||||
{'3', {0x21, 0x41, 0x45, 0x4b, 0x31}},
|
||||
{'4', {0x18, 0x14, 0x12, 0x7f, 0x10}},
|
||||
{'5', {0x27, 0x45, 0x45, 0x45, 0x39}},
|
||||
{'6', {0x3c, 0x4a, 0x49, 0x49, 0x30}},
|
||||
{'7', {0x01, 0x71, 0x09, 0x05, 0x03}},
|
||||
{'8', {0x36, 0x49, 0x49, 0x49, 0x36}},
|
||||
{'9', {0x06, 0x49, 0x49, 0x29, 0x1e}},
|
||||
{'A', {0x7e, 0x11, 0x11, 0x11, 0x7e}},
|
||||
{'B', {0x7f, 0x49, 0x49, 0x49, 0x36}},
|
||||
{'C', {0x3e, 0x41, 0x41, 0x41, 0x22}},
|
||||
{'D', {0x7f, 0x41, 0x41, 0x22, 0x1c}},
|
||||
{'E', {0x7f, 0x49, 0x49, 0x49, 0x41}},
|
||||
{'F', {0x7f, 0x09, 0x09, 0x09, 0x01}},
|
||||
{'G', {0x3e, 0x41, 0x49, 0x49, 0x7a}},
|
||||
{'H', {0x7f, 0x08, 0x08, 0x08, 0x7f}},
|
||||
{'I', {0x00, 0x41, 0x7f, 0x41, 0x00}},
|
||||
{'J', {0x20, 0x40, 0x41, 0x3f, 0x01}},
|
||||
{'K', {0x7f, 0x08, 0x14, 0x22, 0x41}},
|
||||
{'L', {0x7f, 0x40, 0x40, 0x40, 0x40}},
|
||||
{'M', {0x7f, 0x02, 0x0c, 0x02, 0x7f}},
|
||||
{'N', {0x7f, 0x04, 0x08, 0x10, 0x7f}},
|
||||
{'O', {0x3e, 0x41, 0x41, 0x41, 0x3e}},
|
||||
{'P', {0x7f, 0x09, 0x09, 0x09, 0x06}},
|
||||
{'Q', {0x3e, 0x41, 0x51, 0x21, 0x5e}},
|
||||
{'R', {0x7f, 0x09, 0x19, 0x29, 0x46}},
|
||||
{'S', {0x46, 0x49, 0x49, 0x49, 0x31}},
|
||||
{'T', {0x01, 0x01, 0x7f, 0x01, 0x01}},
|
||||
{'U', {0x3f, 0x40, 0x40, 0x40, 0x3f}},
|
||||
{'V', {0x1f, 0x20, 0x40, 0x20, 0x1f}},
|
||||
{'W', {0x7f, 0x20, 0x18, 0x20, 0x7f}},
|
||||
{'X', {0x63, 0x14, 0x08, 0x14, 0x63}},
|
||||
{'Y', {0x03, 0x04, 0x78, 0x04, 0x03}},
|
||||
{'Z', {0x61, 0x51, 0x49, 0x45, 0x43}},
|
||||
{'a', {0x20, 0x54, 0x54, 0x54, 0x78}},
|
||||
{'b', {0x7f, 0x48, 0x44, 0x44, 0x38}},
|
||||
{'c', {0x38, 0x44, 0x44, 0x44, 0x20}},
|
||||
{'d', {0x38, 0x44, 0x44, 0x48, 0x7f}},
|
||||
{'e', {0x38, 0x54, 0x54, 0x54, 0x18}},
|
||||
{'f', {0x08, 0x7e, 0x09, 0x01, 0x02}},
|
||||
{'g', {0x0c, 0x52, 0x52, 0x52, 0x3e}},
|
||||
{'h', {0x7f, 0x08, 0x04, 0x04, 0x78}},
|
||||
{'i', {0x00, 0x44, 0x7d, 0x40, 0x00}},
|
||||
{'j', {0x20, 0x40, 0x44, 0x3d, 0x00}},
|
||||
{'k', {0x7f, 0x10, 0x28, 0x44, 0x00}},
|
||||
{'l', {0x00, 0x41, 0x7f, 0x40, 0x00}},
|
||||
{'m', {0x7c, 0x04, 0x18, 0x04, 0x78}},
|
||||
{'n', {0x7c, 0x08, 0x04, 0x04, 0x78}},
|
||||
{'o', {0x38, 0x44, 0x44, 0x44, 0x38}},
|
||||
{'p', {0x7c, 0x14, 0x14, 0x14, 0x08}},
|
||||
{'q', {0x08, 0x14, 0x14, 0x18, 0x7c}},
|
||||
{'r', {0x7c, 0x08, 0x04, 0x04, 0x08}},
|
||||
{'s', {0x48, 0x54, 0x54, 0x54, 0x20}},
|
||||
{'t', {0x04, 0x3f, 0x44, 0x40, 0x20}},
|
||||
{'u', {0x3c, 0x40, 0x40, 0x20, 0x7c}},
|
||||
{'v', {0x1c, 0x20, 0x40, 0x20, 0x1c}},
|
||||
{'w', {0x3c, 0x40, 0x30, 0x40, 0x3c}},
|
||||
{'x', {0x44, 0x28, 0x10, 0x28, 0x44}},
|
||||
{'y', {0x0c, 0x50, 0x50, 0x50, 0x3c}},
|
||||
{'z', {0x44, 0x64, 0x54, 0x4c, 0x44}},
|
||||
};
|
||||
|
||||
static i2c_master_bus_handle_t s_bus;
|
||||
static i2c_master_dev_handle_t s_device;
|
||||
static StaticSemaphore_t s_mutex_storage;
|
||||
static SemaphoreHandle_t s_mutex;
|
||||
static uint8_t s_framebuffer[LOCAL_DISPLAY_FRAMEBUFFER_SIZE];
|
||||
static bool s_bus_ready;
|
||||
static bool s_initialized;
|
||||
static bool s_frame_active;
|
||||
static TaskHandle_t s_frame_owner;
|
||||
static uint8_t s_address;
|
||||
static uint8_t s_contrast = LOCAL_DISPLAY_DEFAULT_CONTRAST;
|
||||
static bool s_inverted;
|
||||
static uint8_t s_dirty_pages;
|
||||
static esp_err_t s_last_error = ESP_ERR_INVALID_STATE;
|
||||
|
||||
static TickType_t milliseconds_to_ticks(uint32_t milliseconds)
|
||||
{
|
||||
TickType_t ticks = pdMS_TO_TICKS(milliseconds);
|
||||
return (milliseconds > 0U && ticks == 0U) ? 1U : ticks;
|
||||
}
|
||||
|
||||
static esp_err_t take_lock(void)
|
||||
{
|
||||
if (s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
return xSemaphoreTake(s_mutex, milliseconds_to_ticks(LOCAL_DISPLAY_LOCK_TIMEOUT_MS)) == pdTRUE
|
||||
? ESP_OK
|
||||
: ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
static void give_lock(void)
|
||||
{
|
||||
if (s_mutex != NULL) {
|
||||
(void)xSemaphoreGive(s_mutex);
|
||||
}
|
||||
}
|
||||
|
||||
static void set_last_error(esp_err_t error)
|
||||
{
|
||||
s_last_error = error;
|
||||
}
|
||||
|
||||
static esp_err_t send_commands_locked(const uint8_t *commands, size_t count)
|
||||
{
|
||||
if (!s_initialized || s_device == NULL || commands == NULL || count == 0U ||
|
||||
count > LOCAL_DISPLAY_COMMAND_CAPACITY) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
uint8_t transfer[LOCAL_DISPLAY_COMMAND_CAPACITY + 1U];
|
||||
transfer[0] = 0x00U;
|
||||
memcpy(&transfer[1], commands, count);
|
||||
return i2c_master_transmit(s_device, transfer, count + 1U,
|
||||
LOCAL_DISPLAY_I2C_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
static esp_err_t send_command_locked(uint8_t command)
|
||||
{
|
||||
return send_commands_locked(&command, 1U);
|
||||
}
|
||||
|
||||
static esp_err_t flush_dirty_locked(void)
|
||||
{
|
||||
if (!s_initialized || s_device == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
int64_t started = esp_timer_get_time();
|
||||
uint8_t transfer[LOCAL_DISPLAY_DATA_CHUNK_SIZE + 1U];
|
||||
transfer[0] = 0x40U;
|
||||
for (uint8_t page = 0U; page < LOCAL_DISPLAY_PAGE_COUNT; ++page) {
|
||||
uint8_t page_mask = (uint8_t)(1U << page);
|
||||
if ((s_dirty_pages & page_mask) == 0U) {
|
||||
continue;
|
||||
}
|
||||
if ((esp_timer_get_time() - started) >= LOCAL_DISPLAY_FLUSH_BUDGET_US) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
const uint8_t commands[] = {
|
||||
0x21U, 0x00U, (uint8_t)(LOCAL_DISPLAY_WIDTH - 1U),
|
||||
0x22U, page, page,
|
||||
};
|
||||
esp_err_t error = send_commands_locked(commands, sizeof(commands));
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
if ((esp_timer_get_time() - started) >= LOCAL_DISPLAY_FLUSH_BUDGET_US) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
memcpy(&transfer[1], &s_framebuffer[(size_t)page * LOCAL_DISPLAY_WIDTH],
|
||||
LOCAL_DISPLAY_WIDTH);
|
||||
error = i2c_master_transmit(s_device, transfer, sizeof(transfer),
|
||||
LOCAL_DISPLAY_I2C_TIMEOUT_MS);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
s_dirty_pages &= (uint8_t)~page_mask;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void set_pixel_raw(uint8_t x, uint8_t y, bool on)
|
||||
{
|
||||
if (x >= LOCAL_DISPLAY_WIDTH || y >= LOCAL_DISPLAY_HEIGHT) {
|
||||
return;
|
||||
}
|
||||
size_t index = (size_t)(y / 8U) * LOCAL_DISPLAY_WIDTH + x;
|
||||
uint8_t mask = (uint8_t)(1U << (y & 7U));
|
||||
uint8_t before = s_framebuffer[index];
|
||||
if (on) {
|
||||
s_framebuffer[index] |= mask;
|
||||
} else {
|
||||
s_framebuffer[index] &= (uint8_t)~mask;
|
||||
}
|
||||
if (before != s_framebuffer[index]) {
|
||||
s_dirty_pages |= (uint8_t)(1U << (y / 8U));
|
||||
}
|
||||
}
|
||||
|
||||
static bool panel_geometry(local_display_panel_t panel, uint8_t *origin_y, uint8_t *height)
|
||||
{
|
||||
if (origin_y == NULL || height == NULL) {
|
||||
return false;
|
||||
}
|
||||
switch (panel) {
|
||||
case LOCAL_DISPLAY_PANEL_STATUS:
|
||||
*origin_y = 0U;
|
||||
*height = LOCAL_DISPLAY_STATUS_HEIGHT;
|
||||
return true;
|
||||
case LOCAL_DISPLAY_PANEL_CONTENT:
|
||||
*origin_y = LOCAL_DISPLAY_STATUS_HEIGHT;
|
||||
*height = LOCAL_DISPLAY_CONTENT_HEIGHT;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static const glyph_t *find_glyph(char character)
|
||||
{
|
||||
for (size_t index = 0U; index < sizeof(s_glyphs) / sizeof(s_glyphs[0]); ++index) {
|
||||
if (s_glyphs[index].character == character) {
|
||||
return &s_glyphs[index];
|
||||
}
|
||||
}
|
||||
for (size_t index = 0U; index < sizeof(s_glyphs) / sizeof(s_glyphs[0]); ++index) {
|
||||
if (s_glyphs[index].character == '?') {
|
||||
return &s_glyphs[index];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static esp_err_t select_device_locked(uint8_t address)
|
||||
{
|
||||
if (address != 0x3cU && address != 0x3dU) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
esp_err_t error = i2c_master_probe(s_bus, address, LOCAL_DISPLAY_PROBE_TIMEOUT_MS);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
if (s_device != NULL && s_address == address) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (s_device != NULL) {
|
||||
error = i2c_master_bus_rm_device(s_device);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
s_device = NULL;
|
||||
s_initialized = false;
|
||||
}
|
||||
|
||||
const i2c_device_config_t config = {
|
||||
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
|
||||
.device_address = address,
|
||||
.scl_speed_hz = LOCAL_DISPLAY_I2C_SPEED_HZ,
|
||||
};
|
||||
error = i2c_master_bus_add_device(s_bus, &config, &s_device);
|
||||
if (error == ESP_OK) {
|
||||
s_address = address;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t initialize_locked(uint8_t address)
|
||||
{
|
||||
s_initialized = false;
|
||||
esp_err_t error = select_device_locked(address);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const uint8_t commands[] = {
|
||||
0xaeU, 0xd5U, 0x80U, 0xa8U, 0x3fU, 0xd3U, 0x00U, 0x40U,
|
||||
0x8dU, 0x14U, 0x20U, 0x00U, 0xa1U, 0xc8U, 0xdaU, 0x12U,
|
||||
0x81U, LOCAL_DISPLAY_DEFAULT_CONTRAST, 0xd9U, 0xf1U, 0xdbU,
|
||||
0x40U, 0xa4U, 0xa6U, 0x2eU,
|
||||
};
|
||||
|
||||
/* send_commands_locked requires initialized, so issue the bootstrap directly. */
|
||||
uint8_t transfer[sizeof(commands) + 1U];
|
||||
transfer[0] = 0x00U;
|
||||
memcpy(&transfer[1], commands, sizeof(commands));
|
||||
error = i2c_master_transmit(s_device, transfer, sizeof(transfer),
|
||||
LOCAL_DISPLAY_I2C_TIMEOUT_MS);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
s_initialized = true;
|
||||
s_contrast = LOCAL_DISPLAY_DEFAULT_CONTRAST;
|
||||
s_inverted = false;
|
||||
memset(s_framebuffer, 0, sizeof(s_framebuffer));
|
||||
s_dirty_pages = (uint8_t)((1U << LOCAL_DISPLAY_PAGE_COUNT) - 1U);
|
||||
error = flush_dirty_locked();
|
||||
if (error == ESP_OK) {
|
||||
error = send_command_locked(0xafU);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
s_initialized = false;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t local_display_init(void)
|
||||
{
|
||||
if (s_bus_ready || s_mutex != NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
s_mutex = xSemaphoreCreateMutexStatic(&s_mutex_storage);
|
||||
if (s_mutex == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
const i2c_master_bus_config_t config = {
|
||||
.i2c_port = LOCAL_UI_I2C_PORT,
|
||||
.sda_io_num = LOCAL_UI_DISPLAY_SDA_GPIO,
|
||||
.scl_io_num = LOCAL_UI_DISPLAY_SCL_GPIO,
|
||||
.clk_source = I2C_CLK_SRC_DEFAULT,
|
||||
.glitch_ignore_cnt = 7,
|
||||
.flags.enable_internal_pullup = false,
|
||||
};
|
||||
esp_err_t error = i2c_new_master_bus(&config, &s_bus);
|
||||
if (error != ESP_OK) {
|
||||
vSemaphoreDelete(s_mutex);
|
||||
s_mutex = NULL;
|
||||
set_last_error(error);
|
||||
return error;
|
||||
}
|
||||
s_bus_ready = true;
|
||||
set_last_error(ESP_OK);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t local_display_probe_expected(uint8_t *address_7bit)
|
||||
{
|
||||
if (address_7bit == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (!s_bus_ready) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
esp_err_t error = take_lock();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
uint8_t found = 0U;
|
||||
for (size_t index = 0U; index < sizeof(s_expected_addresses) / sizeof(s_expected_addresses[0]); ++index) {
|
||||
uint8_t address = s_expected_addresses[index];
|
||||
if (i2c_master_probe(s_bus, address, LOCAL_DISPLAY_PROBE_TIMEOUT_MS) == ESP_OK) {
|
||||
if (found == 0U) {
|
||||
*address_7bit = address;
|
||||
}
|
||||
++found;
|
||||
}
|
||||
}
|
||||
error = found == 0U ? ESP_ERR_NOT_FOUND : ESP_OK;
|
||||
set_last_error(error);
|
||||
give_lock();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t local_display_start_at(uint8_t address_7bit)
|
||||
{
|
||||
if (!s_bus_ready) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
esp_err_t error = take_lock();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = initialize_locked(address_7bit);
|
||||
set_last_error(error);
|
||||
give_lock();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t local_display_start(void)
|
||||
{
|
||||
uint8_t address = 0U;
|
||||
esp_err_t error = local_display_probe_expected(&address);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
return local_display_start_at(address);
|
||||
}
|
||||
|
||||
esp_err_t local_display_stop(void)
|
||||
{
|
||||
if (!s_bus_ready) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
esp_err_t error = take_lock();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
if (!s_initialized) {
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
} else {
|
||||
error = send_command_locked(0xaeU);
|
||||
/* A failed command leaves panel state unknown; force a clean reinit. */
|
||||
s_initialized = false;
|
||||
}
|
||||
set_last_error(error);
|
||||
give_lock();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t local_display_get_snapshot(local_display_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
esp_err_t error = take_lock();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
*snapshot = (local_display_snapshot_t){
|
||||
.bus_ready = s_bus_ready,
|
||||
.initialized = s_initialized,
|
||||
.address_7bit = s_address,
|
||||
.contrast = s_contrast,
|
||||
.inverted = s_inverted,
|
||||
.dirty_page_mask = s_dirty_pages,
|
||||
.last_error = s_last_error,
|
||||
};
|
||||
give_lock();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t local_display_scan(local_display_scan_callback_t callback,
|
||||
void *context,
|
||||
size_t *responding_count)
|
||||
{
|
||||
if (!s_bus_ready) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
esp_err_t error = take_lock();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
uint8_t responses[0x78U - 0x08U];
|
||||
size_t found = 0U;
|
||||
for (uint16_t address = 0x08U; address <= 0x77U; ++address) {
|
||||
if (i2c_master_probe(s_bus, address, LOCAL_DISPLAY_SCAN_TIMEOUT_MS) == ESP_OK) {
|
||||
responses[found++] = (uint8_t)address;
|
||||
}
|
||||
vTaskDelay(1U);
|
||||
}
|
||||
if (responding_count != NULL) {
|
||||
*responding_count = found;
|
||||
}
|
||||
error = found == 0U ? ESP_ERR_NOT_FOUND : ESP_OK;
|
||||
set_last_error(error);
|
||||
give_lock();
|
||||
|
||||
/* Callers may safely use the display service from the callback. */
|
||||
if (callback != NULL) {
|
||||
for (size_t index = 0U; index < found; ++index) {
|
||||
callback(responses[index], context);
|
||||
}
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t local_display_set_contrast(uint8_t contrast)
|
||||
{
|
||||
esp_err_t error = take_lock();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
const uint8_t commands[] = {0x81U, contrast};
|
||||
error = send_commands_locked(commands, sizeof(commands));
|
||||
if (error == ESP_OK) {
|
||||
s_contrast = contrast;
|
||||
} else {
|
||||
s_initialized = false;
|
||||
}
|
||||
set_last_error(error);
|
||||
give_lock();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t local_display_set_inverted(bool inverted)
|
||||
{
|
||||
esp_err_t error = take_lock();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = send_command_locked(inverted ? 0xa7U : 0xa6U);
|
||||
if (error == ESP_OK) {
|
||||
s_inverted = inverted;
|
||||
} else {
|
||||
s_initialized = false;
|
||||
}
|
||||
set_last_error(error);
|
||||
give_lock();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t local_display_frame_begin(void)
|
||||
{
|
||||
esp_err_t error = take_lock();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
if (!s_initialized || s_frame_active) {
|
||||
give_lock();
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_frame_active = true;
|
||||
s_frame_owner = xTaskGetCurrentTaskHandle();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t local_display_frame_end(void)
|
||||
{
|
||||
if (!s_frame_active || s_frame_owner != xTaskGetCurrentTaskHandle()) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
esp_err_t error = flush_dirty_locked();
|
||||
if (error != ESP_OK) {
|
||||
s_initialized = false;
|
||||
}
|
||||
set_last_error(error);
|
||||
s_frame_active = false;
|
||||
s_frame_owner = NULL;
|
||||
give_lock();
|
||||
return error;
|
||||
}
|
||||
|
||||
void local_display_frame_cancel(void)
|
||||
{
|
||||
if (s_frame_active && s_frame_owner == xTaskGetCurrentTaskHandle()) {
|
||||
s_frame_active = false;
|
||||
s_frame_owner = NULL;
|
||||
give_lock();
|
||||
}
|
||||
}
|
||||
|
||||
void local_display_frame_clear(local_display_panel_t panel)
|
||||
{
|
||||
if (!s_frame_active || s_frame_owner != xTaskGetCurrentTaskHandle()) {
|
||||
return;
|
||||
}
|
||||
uint8_t origin_y = 0U;
|
||||
uint8_t height = 0U;
|
||||
if (!panel_geometry(panel, &origin_y, &height)) {
|
||||
return;
|
||||
}
|
||||
for (uint8_t y = origin_y; y < origin_y + height; ++y) {
|
||||
for (uint8_t x = 0U; x < LOCAL_DISPLAY_WIDTH; ++x) {
|
||||
set_pixel_raw(x, y, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void local_display_frame_clear_all(void)
|
||||
{
|
||||
if (!s_frame_active || s_frame_owner != xTaskGetCurrentTaskHandle()) {
|
||||
return;
|
||||
}
|
||||
for (size_t index = 0U; index < sizeof(s_framebuffer); ++index) {
|
||||
if (s_framebuffer[index] != 0U) {
|
||||
s_framebuffer[index] = 0U;
|
||||
s_dirty_pages |= (uint8_t)(1U << (index / LOCAL_DISPLAY_WIDTH));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void local_display_frame_set_pixel(local_display_panel_t panel,
|
||||
uint8_t x,
|
||||
uint8_t y,
|
||||
bool on)
|
||||
{
|
||||
if (!s_frame_active || s_frame_owner != xTaskGetCurrentTaskHandle()) {
|
||||
return;
|
||||
}
|
||||
uint8_t origin_y = 0U;
|
||||
uint8_t height = 0U;
|
||||
if (!panel_geometry(panel, &origin_y, &height) || x >= LOCAL_DISPLAY_WIDTH || y >= height) {
|
||||
return;
|
||||
}
|
||||
set_pixel_raw(x, (uint8_t)(origin_y + y), on);
|
||||
}
|
||||
|
||||
void local_display_frame_draw_text(local_display_panel_t panel,
|
||||
uint8_t x,
|
||||
uint8_t y,
|
||||
const char *text)
|
||||
{
|
||||
if (!s_frame_active || s_frame_owner != xTaskGetCurrentTaskHandle() || text == NULL) {
|
||||
return;
|
||||
}
|
||||
uint8_t origin_y = 0U;
|
||||
uint8_t height = 0U;
|
||||
if (!panel_geometry(panel, &origin_y, &height) || y >= height) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t cursor_x = x;
|
||||
for (const char *character = text; *character != '\0'; ++character) {
|
||||
const glyph_t *glyph = find_glyph(*character);
|
||||
if (glyph == NULL || cursor_x + 5U > LOCAL_DISPLAY_WIDTH) {
|
||||
break;
|
||||
}
|
||||
for (uint8_t column = 0U; column < 5U; ++column) {
|
||||
for (uint8_t row = 0U; row < 7U; ++row) {
|
||||
if ((glyph->columns[column] & (uint8_t)(1U << row)) != 0U &&
|
||||
y + row < height) {
|
||||
set_pixel_raw((uint8_t)(cursor_x + column),
|
||||
(uint8_t)(origin_y + y + row), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor_x += 6U;
|
||||
if (cursor_x >= LOCAL_DISPLAY_WIDTH) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded SSD1315-compatible local OLED service. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define LOCAL_DISPLAY_WIDTH 128U
|
||||
#define LOCAL_DISPLAY_HEIGHT 64U
|
||||
#define LOCAL_DISPLAY_STATUS_HEIGHT 16U
|
||||
#define LOCAL_DISPLAY_CONTENT_HEIGHT 48U
|
||||
|
||||
/* The physical black divider lies between status row 15 and content row 16. */
|
||||
typedef enum {
|
||||
LOCAL_DISPLAY_PANEL_STATUS = 0,
|
||||
LOCAL_DISPLAY_PANEL_CONTENT,
|
||||
} local_display_panel_t;
|
||||
|
||||
typedef struct {
|
||||
bool bus_ready;
|
||||
bool initialized;
|
||||
uint8_t address_7bit;
|
||||
uint8_t contrast;
|
||||
bool inverted;
|
||||
uint8_t dirty_page_mask;
|
||||
esp_err_t last_error;
|
||||
} local_display_snapshot_t;
|
||||
|
||||
typedef void (*local_display_scan_callback_t)(uint8_t address_7bit, void *context);
|
||||
|
||||
/* Set up I2C0 on the board-profile pins. No display probe occurs here. */
|
||||
esp_err_t local_display_init(void);
|
||||
|
||||
/* Probe standard OLED addresses and initialize the first responding display. */
|
||||
esp_err_t local_display_start(void);
|
||||
|
||||
/* Select and initialize one supported 7-bit address (0x3c or 0x3d). */
|
||||
esp_err_t local_display_start_at(uint8_t address_7bit);
|
||||
|
||||
/* Turn off the panel while preserving the I2C bus for later diagnostics/restart. */
|
||||
esp_err_t local_display_stop(void);
|
||||
|
||||
esp_err_t local_display_get_snapshot(local_display_snapshot_t *snapshot);
|
||||
esp_err_t local_display_probe_expected(uint8_t *address_7bit);
|
||||
|
||||
/* Bounded scan of usable 7-bit addresses 0x08 through 0x77. */
|
||||
esp_err_t local_display_scan(local_display_scan_callback_t callback,
|
||||
void *context,
|
||||
size_t *responding_count);
|
||||
|
||||
esp_err_t local_display_set_contrast(uint8_t contrast);
|
||||
esp_err_t local_display_set_inverted(bool inverted);
|
||||
|
||||
/*
|
||||
* A frame holds only the display's own mutex and is owned by the task that
|
||||
* begins it. Callers must never retain a service/broker mutex while beginning
|
||||
* or ending a frame. Only the owning task may end or cancel it; end sends only
|
||||
* modified 8-pixel pages and releases the display mutex on all outcomes.
|
||||
*/
|
||||
esp_err_t local_display_frame_begin(void);
|
||||
esp_err_t local_display_frame_end(void);
|
||||
void local_display_frame_cancel(void);
|
||||
|
||||
/* Drawing coordinates are panel-local and are clipped to the selected panel. */
|
||||
void local_display_frame_clear(local_display_panel_t panel);
|
||||
void local_display_frame_clear_all(void);
|
||||
void local_display_frame_set_pixel(local_display_panel_t panel,
|
||||
uint8_t x,
|
||||
uint8_t y,
|
||||
bool on);
|
||||
void local_display_frame_draw_text(local_display_panel_t panel,
|
||||
uint8_t x,
|
||||
uint8_t y,
|
||||
const char *text);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Local OLED status UI and bounded, direct-API recovery controls. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "local_ui_config.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Starts the low-priority status renderer and local recovery controls. The
|
||||
* task never becomes a broker client or serial writer. The OLED and buttons
|
||||
* are optional, so a missing display is not an error.
|
||||
*/
|
||||
esp_err_t local_status_ui_start(const local_ui_config_t *config);
|
||||
|
||||
/* Runtime settings are copied atomically and never expose display-frame ownership. */
|
||||
esp_err_t local_status_ui_get_config(local_ui_config_t *config);
|
||||
esp_err_t local_status_ui_apply_config(const local_ui_config_t *config);
|
||||
|
||||
/* Preserve a manually selected display diagnostic for a bounded interval. */
|
||||
void local_status_ui_hold_for_diagnostics(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,119 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Versioned persistent configuration for the optional local OLED UI. */
|
||||
|
||||
#include "local_ui_config.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
void local_ui_config_defaults(local_ui_config_t *config)
|
||||
{
|
||||
if (config == NULL) {
|
||||
return;
|
||||
}
|
||||
*config = (local_ui_config_t){
|
||||
.version = LOCAL_UI_CONFIG_VERSION,
|
||||
.dim_timeout_seconds = LOCAL_UI_CONFIG_DEFAULT_DIM_SECONDS,
|
||||
.off_timeout_seconds = LOCAL_UI_CONFIG_DEFAULT_OFF_SECONDS,
|
||||
};
|
||||
}
|
||||
|
||||
esp_err_t local_ui_config_validate(const local_ui_config_t *config)
|
||||
{
|
||||
if (config == NULL || config->version != LOCAL_UI_CONFIG_VERSION ||
|
||||
config->dim_timeout_seconds > LOCAL_UI_CONFIG_MAX_TIMEOUT_SECONDS ||
|
||||
config->off_timeout_seconds > LOCAL_UI_CONFIG_MAX_TIMEOUT_SECONDS) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (config->dim_timeout_seconds != 0U && config->off_timeout_seconds != 0U &&
|
||||
config->off_timeout_seconds <= config->dim_timeout_seconds) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t local_ui_config_load(local_ui_config_t *config, bool *used_stored_config)
|
||||
{
|
||||
if (config == NULL || used_stored_config == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
local_ui_config_defaults(config);
|
||||
*used_stored_config = false;
|
||||
|
||||
esp_err_t error = nvs_flash_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
error = nvs_open(LOCAL_UI_CONFIG_NVS_NAMESPACE, NVS_READONLY, &handle);
|
||||
if (error == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
size_t stored_size = 0U;
|
||||
error = nvs_get_blob(handle, LOCAL_UI_CONFIG_NVS_BLOB_KEY, NULL, &stored_size);
|
||||
if (error == ESP_ERR_NVS_NOT_FOUND || error == ESP_ERR_NVS_TYPE_MISMATCH ||
|
||||
(error == ESP_OK && stored_size != sizeof(local_ui_config_t))) {
|
||||
nvs_close(handle);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
nvs_close(handle);
|
||||
return error;
|
||||
}
|
||||
|
||||
local_ui_config_t stored;
|
||||
error = nvs_get_blob(handle, LOCAL_UI_CONFIG_NVS_BLOB_KEY, &stored, &stored_size);
|
||||
nvs_close(handle);
|
||||
if (error == ESP_ERR_NVS_INVALID_LENGTH) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
if (stored_size != sizeof(stored) || local_ui_config_validate(&stored) != ESP_OK) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
*config = stored;
|
||||
*used_stored_config = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t local_ui_config_save(const local_ui_config_t *config)
|
||||
{
|
||||
esp_err_t error = local_ui_config_validate(config);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = nvs_flash_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
error = nvs_open(LOCAL_UI_CONFIG_NVS_NAMESPACE, NVS_READWRITE, &handle);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = nvs_set_blob(handle, LOCAL_UI_CONFIG_NVS_BLOB_KEY, config, sizeof(*config));
|
||||
if (error == ESP_OK) {
|
||||
error = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t local_ui_config_reset_storage(void)
|
||||
{
|
||||
local_ui_config_t config;
|
||||
local_ui_config_defaults(&config);
|
||||
return local_ui_config_save(&config);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Versioned persistent configuration for the optional local OLED UI. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#define LOCAL_UI_CONFIG_VERSION 1U
|
||||
#define LOCAL_UI_CONFIG_DEFAULT_DIM_SECONDS 300U
|
||||
#define LOCAL_UI_CONFIG_DEFAULT_OFF_SECONDS 600U
|
||||
#define LOCAL_UI_CONFIG_MAX_TIMEOUT_SECONDS 86400U
|
||||
|
||||
#define LOCAL_UI_CONFIG_NVS_NAMESPACE "local_ui"
|
||||
#define LOCAL_UI_CONFIG_NVS_BLOB_KEY "config"
|
||||
|
||||
typedef struct {
|
||||
uint32_t version;
|
||||
/* Zero disables the corresponding inactivity transition. */
|
||||
uint32_t dim_timeout_seconds;
|
||||
uint32_t off_timeout_seconds;
|
||||
} local_ui_config_t;
|
||||
|
||||
void local_ui_config_defaults(local_ui_config_t *config);
|
||||
esp_err_t local_ui_config_validate(const local_ui_config_t *config);
|
||||
esp_err_t local_ui_config_load(local_ui_config_t *config, bool *used_stored_config);
|
||||
esp_err_t local_ui_config_save(const local_ui_config_t *config);
|
||||
esp_err_t local_ui_config_reset_storage(void);
|
||||
@@ -0,0 +1,216 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* UART0 administration commands for local UI aging settings. */
|
||||
|
||||
#include "local_ui_console.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_command_gate.h"
|
||||
#include "esp_console.h"
|
||||
#include "local_display.h"
|
||||
#include "local_status_ui.h"
|
||||
#include "local_ui_config.h"
|
||||
|
||||
static void print_usage(void)
|
||||
{
|
||||
printf("Usage:\n");
|
||||
printf(" display status\n");
|
||||
printf(" display set <dim-seconds|off-seconds> <0..%u>\n",
|
||||
LOCAL_UI_CONFIG_MAX_TIMEOUT_SECONDS);
|
||||
printf(" display save|load|defaults|reset\n");
|
||||
printf("Zero disables the selected inactivity transition.\n");
|
||||
}
|
||||
|
||||
static void print_config(const local_ui_config_t *config)
|
||||
{
|
||||
printf("Local UI configuration v%u: dim-seconds=%u off-seconds=%u\n",
|
||||
(unsigned int)config->version,
|
||||
(unsigned int)config->dim_timeout_seconds,
|
||||
(unsigned int)config->off_timeout_seconds);
|
||||
}
|
||||
|
||||
static bool parse_timeout(const char *text, uint32_t *value)
|
||||
{
|
||||
if (text == NULL || *text == '\0') {
|
||||
return false;
|
||||
}
|
||||
for (const char *character = text; *character != '\0'; ++character) {
|
||||
if (*character < '0' || *character > '9') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
errno = 0;
|
||||
char *end = NULL;
|
||||
unsigned long parsed = strtoul(text, &end, 10);
|
||||
if (errno != 0 || end == text || *end != '\0' ||
|
||||
parsed > LOCAL_UI_CONFIG_MAX_TIMEOUT_SECONDS) {
|
||||
return false;
|
||||
}
|
||||
*value = (uint32_t)parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
static int show_status(void)
|
||||
{
|
||||
local_ui_config_t config;
|
||||
esp_err_t error = local_status_ui_get_config(&config);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read local UI configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
print_config(&config);
|
||||
|
||||
local_display_snapshot_t display;
|
||||
error = local_display_get_snapshot(&display);
|
||||
if (error != ESP_OK) {
|
||||
printf("Display service unavailable: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("OLED: bus=%s initialized=%s address=0x%02x contrast=%u last-error=%s\n",
|
||||
display.bus_ready ? "ready" : "unavailable",
|
||||
display.initialized ? "yes" : "no",
|
||||
display.address_7bit,
|
||||
(unsigned int)display.contrast,
|
||||
esp_err_to_name(display.last_error));
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int apply_parameter(const char *parameter, const char *text)
|
||||
{
|
||||
uint32_t value;
|
||||
if (!parse_timeout(text, &value)) {
|
||||
printf("Timeout must be 0..%u seconds.\n",
|
||||
LOCAL_UI_CONFIG_MAX_TIMEOUT_SECONDS);
|
||||
return 1;
|
||||
}
|
||||
|
||||
local_ui_config_t config;
|
||||
esp_err_t error = local_status_ui_get_config(&config);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read local UI configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(parameter, "dim-seconds") == 0) {
|
||||
config.dim_timeout_seconds = value;
|
||||
} else if (strcmp(parameter, "off-seconds") == 0) {
|
||||
config.off_timeout_seconds = value;
|
||||
} else {
|
||||
printf("Unknown display parameter '%s'.\n", parameter);
|
||||
print_usage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
error = local_status_ui_apply_config(&config);
|
||||
if (error != ESP_OK) {
|
||||
printf("Invalid display configuration: %s. When both timeouts are enabled, off must be later than dim.\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
print_config(&config);
|
||||
printf("Applied in RAM; run 'display save' to persist it.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_display_inner(int argc, char **argv)
|
||||
{
|
||||
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0)) {
|
||||
return show_status();
|
||||
}
|
||||
if (argc == 4 && strcmp(argv[1], "set") == 0) {
|
||||
return apply_parameter(argv[2], argv[3]);
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "save") == 0) {
|
||||
local_ui_config_t config;
|
||||
esp_err_t error = local_status_ui_get_config(&config);
|
||||
if (error == ESP_OK) {
|
||||
error = local_ui_config_save(&config);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not save display configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("Display configuration saved to NVS.\n");
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "load") == 0) {
|
||||
local_ui_config_t config;
|
||||
bool used_stored_config;
|
||||
esp_err_t error = local_ui_config_load(&config, &used_stored_config);
|
||||
if (error == ESP_OK) {
|
||||
error = local_status_ui_apply_config(&config);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not load display configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("Loaded %s display configuration.\n",
|
||||
used_stored_config ? "stored" : "default");
|
||||
print_config(&config);
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "defaults") == 0) {
|
||||
local_ui_config_t config;
|
||||
local_ui_config_defaults(&config);
|
||||
esp_err_t error = local_status_ui_apply_config(&config);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not apply display defaults: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("Display defaults applied in RAM; run 'display save' to persist them.\n");
|
||||
print_config(&config);
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "reset") == 0) {
|
||||
local_ui_config_t previous;
|
||||
local_ui_config_t defaults;
|
||||
local_ui_config_defaults(&defaults);
|
||||
esp_err_t error = local_status_ui_get_config(&previous);
|
||||
if (error == ESP_OK) {
|
||||
error = local_status_ui_apply_config(&defaults);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = local_ui_config_reset_storage();
|
||||
if (error != ESP_OK) {
|
||||
(void)local_status_ui_apply_config(&previous);
|
||||
}
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not reset display configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("Display defaults applied and saved to NVS.\n");
|
||||
print_config(&defaults);
|
||||
return 0;
|
||||
}
|
||||
|
||||
print_usage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int command_display(int argc, char **argv)
|
||||
{
|
||||
esp_err_t error = admin_command_gate_take();
|
||||
if (error != ESP_OK) {
|
||||
printf("Display administration unavailable: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
int result = command_display_inner(argc, argv);
|
||||
admin_command_gate_give();
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t local_ui_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
.command = "display",
|
||||
.help = "Configure persistent local OLED aging timeouts; use 'display' for status",
|
||||
.hint = NULL,
|
||||
.func = &command_display,
|
||||
.argtable = NULL,
|
||||
};
|
||||
return esp_console_cmd_register(&command);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* UART0 administration commands for local UI aging settings. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
esp_err_t local_ui_console_register_commands(void);
|
||||
@@ -0,0 +1,607 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Phase 7A diagnostics built on the Phase 7B local display service. */
|
||||
|
||||
#include "local_ui_hw_test.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "board_pins.h"
|
||||
#include "driver/gpio.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "local_display.h"
|
||||
#include "local_status_ui.h"
|
||||
|
||||
#define BUTTON_POLL_MS 10U
|
||||
#define BUTTON_DEBOUNCE_MS 30U
|
||||
#define BUTTON_LONG_PRESS_MS 1000U
|
||||
#define BUTTON_TEST_DEFAULT_SECONDS 10U
|
||||
#define BUTTON_TEST_MAX_SECONDS 30U
|
||||
|
||||
typedef struct {
|
||||
const char *name;
|
||||
gpio_num_t gpio;
|
||||
} button_definition_t;
|
||||
|
||||
typedef struct {
|
||||
int raw_level;
|
||||
int stable_level;
|
||||
int64_t raw_changed_us;
|
||||
int64_t pressed_us;
|
||||
bool long_reported;
|
||||
uint32_t short_presses;
|
||||
uint32_t long_presses;
|
||||
uint32_t stable_transitions;
|
||||
} button_test_state_t;
|
||||
|
||||
static const button_definition_t s_buttons[] = {
|
||||
{.name = "previous/back", .gpio = LOCAL_UI_BUTTON_PREVIOUS_GPIO},
|
||||
{.name = "select/confirm", .gpio = LOCAL_UI_BUTTON_SELECT_GPIO},
|
||||
{.name = "next", .gpio = LOCAL_UI_BUTTON_NEXT_GPIO},
|
||||
};
|
||||
|
||||
static bool s_buttons_ready;
|
||||
static esp_err_t s_button_initialization_error = ESP_ERR_INVALID_STATE;
|
||||
|
||||
static TickType_t milliseconds_to_ticks(uint32_t milliseconds)
|
||||
{
|
||||
TickType_t ticks = pdMS_TO_TICKS(milliseconds);
|
||||
return (milliseconds > 0U && ticks == 0U) ? 1U : ticks;
|
||||
}
|
||||
|
||||
static bool parse_unsigned(const char *text,
|
||||
unsigned long minimum,
|
||||
unsigned long maximum,
|
||||
unsigned long *value)
|
||||
{
|
||||
if (text == NULL || value == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
char *end = NULL;
|
||||
errno = 0;
|
||||
unsigned long parsed = strtoul(text, &end, 10);
|
||||
if (errno != 0 || end == text || *end != '\0' ||
|
||||
parsed < minimum || parsed > maximum) {
|
||||
return false;
|
||||
}
|
||||
*value = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool parse_display_address(const char *text, uint8_t *address)
|
||||
{
|
||||
if (text == NULL || address == NULL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
char *end = NULL;
|
||||
errno = 0;
|
||||
unsigned long parsed = strtoul(text, &end, 0);
|
||||
if (errno != 0 || end == text || *end != '\0') {
|
||||
return false;
|
||||
}
|
||||
if (parsed == 0x3cU || parsed == 0x78U || parsed == 0x79U) {
|
||||
*address = 0x3cU;
|
||||
return true;
|
||||
}
|
||||
if (parsed == 0x3dU || parsed == 0x7aU || parsed == 0x7bU) {
|
||||
*address = 0x3dU;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static int report_error(const char *operation, esp_err_t error)
|
||||
{
|
||||
printf("%s failed: %s\n", operation, esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
static esp_err_t configure_buttons(void)
|
||||
{
|
||||
const gpio_config_t config = {
|
||||
.pin_bit_mask = (1ULL << LOCAL_UI_BUTTON_PREVIOUS_GPIO) |
|
||||
(1ULL << LOCAL_UI_BUTTON_SELECT_GPIO) |
|
||||
(1ULL << LOCAL_UI_BUTTON_NEXT_GPIO),
|
||||
.mode = GPIO_MODE_INPUT,
|
||||
.pull_up_en = GPIO_PULLUP_ENABLE,
|
||||
.pull_down_en = GPIO_PULLDOWN_DISABLE,
|
||||
.intr_type = GPIO_INTR_DISABLE,
|
||||
};
|
||||
esp_err_t error = gpio_config(&config);
|
||||
if (error == ESP_OK) {
|
||||
s_buttons_ready = true;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
static void print_address(uint8_t address)
|
||||
{
|
||||
printf("7-bit 0x%02x (8-bit 0x%02x write / 0x%02x read)",
|
||||
address, (unsigned int)(address << 1U),
|
||||
(unsigned int)((address << 1U) | 1U));
|
||||
}
|
||||
|
||||
static void print_display_usage(void)
|
||||
{
|
||||
printf("Usage:\n");
|
||||
printf(" debug display status|probe\n");
|
||||
printf(" debug display scan --force\n");
|
||||
printf(" debug display init [0x3c|0x3d|0x78|0x79|0x7a|0x7b]\n");
|
||||
printf(" debug display off\n");
|
||||
printf(" debug display pattern <clear|fill|checker|grid|corners|layout>\n");
|
||||
printf(" debug display row <0..63>\n");
|
||||
printf(" debug display contrast <0..255>\n");
|
||||
printf(" debug display invert <on|off>\n");
|
||||
}
|
||||
|
||||
static int command_display_status(int argc, char **argv)
|
||||
{
|
||||
(void)argv;
|
||||
if (argc != 1) {
|
||||
print_display_usage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
local_display_snapshot_t snapshot;
|
||||
esp_err_t error = local_display_get_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
return report_error("Display status", error);
|
||||
}
|
||||
printf("Local display: bus=%s initialized=%s last-error=%s dirty-pages=0x%02x\n",
|
||||
snapshot.bus_ready ? "ready" : "unavailable",
|
||||
snapshot.initialized ? "yes" : "no",
|
||||
esp_err_to_name(snapshot.last_error),
|
||||
snapshot.dirty_page_mask);
|
||||
if (snapshot.address_7bit != 0U) {
|
||||
printf("OLED: ");
|
||||
print_address(snapshot.address_7bit);
|
||||
printf(" contrast=%u inverted=%s\n", (unsigned int)snapshot.contrast,
|
||||
snapshot.inverted ? "yes" : "no");
|
||||
} else {
|
||||
printf("OLED: no selected address\n");
|
||||
}
|
||||
printf("Panels: status=128x16 rows 0..15; content=128x48 rows 16..63; physical black divider between them\n");
|
||||
printf("Pins: SDA=%d level=%d SCL=%d level=%d; buttons previous=%d select=%d next=%d\n",
|
||||
LOCAL_UI_DISPLAY_SDA_GPIO, gpio_get_level(LOCAL_UI_DISPLAY_SDA_GPIO),
|
||||
LOCAL_UI_DISPLAY_SCL_GPIO, gpio_get_level(LOCAL_UI_DISPLAY_SCL_GPIO),
|
||||
LOCAL_UI_BUTTON_PREVIOUS_GPIO, LOCAL_UI_BUTTON_SELECT_GPIO,
|
||||
LOCAL_UI_BUTTON_NEXT_GPIO);
|
||||
return snapshot.bus_ready && s_buttons_ready ? 0 : 1;
|
||||
}
|
||||
|
||||
static int command_display_probe(int argc, char **argv)
|
||||
{
|
||||
(void)argv;
|
||||
if (argc != 1) {
|
||||
print_display_usage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
uint8_t address = 0U;
|
||||
esp_err_t error = local_display_probe_expected(&address);
|
||||
if (error != ESP_OK) {
|
||||
printf("No OLED response at expected 7-bit addresses 0x3c or 0x3d.\n");
|
||||
return report_error("Display probe", error);
|
||||
}
|
||||
printf("OLED response at ");
|
||||
print_address(address);
|
||||
printf(".\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void scan_print_callback(uint8_t address, void *context)
|
||||
{
|
||||
(void)context;
|
||||
printf(" response: ");
|
||||
print_address(address);
|
||||
printf("\n");
|
||||
}
|
||||
|
||||
static int command_display_scan(int argc, char **argv)
|
||||
{
|
||||
if (argc != 2 || strcmp(argv[1], "--force") != 0) {
|
||||
printf("A full usable-address scan sends an address probe to every 7-bit address from 0x08 through 0x77.\n");
|
||||
print_display_usage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
size_t found = 0U;
|
||||
printf("Scanning usable 7-bit I2C addresses 0x08..0x77 at 100000 Hz.\n");
|
||||
esp_err_t error = local_display_scan(scan_print_callback, NULL, &found);
|
||||
printf("I2C scan complete: %u responding address%s.\n", (unsigned int)found,
|
||||
found == 1U ? "" : "es");
|
||||
return error == ESP_OK ? 0 : report_error("Display scan", error);
|
||||
}
|
||||
|
||||
static int command_display_init(int argc, char **argv)
|
||||
{
|
||||
if (argc > 2) {
|
||||
print_display_usage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
uint8_t address = 0U;
|
||||
esp_err_t error;
|
||||
if (argc == 2) {
|
||||
if (!parse_display_address(argv[1], &address)) {
|
||||
printf("Display address must be 7-bit 0x3c/0x3d or their 8-bit write/read forms.\n");
|
||||
return 1;
|
||||
}
|
||||
error = local_display_start_at(address);
|
||||
} else {
|
||||
error = local_display_start();
|
||||
if (error == ESP_OK) {
|
||||
local_display_snapshot_t snapshot;
|
||||
error = local_display_get_snapshot(&snapshot);
|
||||
address = snapshot.address_7bit;
|
||||
}
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
return report_error("Display initialization", error);
|
||||
}
|
||||
printf("SSD1315-compatible 128x64 display initialized at ");
|
||||
print_address(address);
|
||||
printf(".\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_display_off(int argc, char **argv)
|
||||
{
|
||||
(void)argv;
|
||||
if (argc != 1) {
|
||||
print_display_usage();
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error = local_display_stop();
|
||||
if (error != ESP_OK) {
|
||||
return report_error("Display off", error);
|
||||
}
|
||||
printf("Display switched off; run 'debug display init' to reinitialize it.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool pattern_name_valid(const char *name)
|
||||
{
|
||||
return strcmp(name, "clear") == 0 || strcmp(name, "fill") == 0 ||
|
||||
strcmp(name, "checker") == 0 || strcmp(name, "grid") == 0 ||
|
||||
strcmp(name, "corners") == 0 || strcmp(name, "layout") == 0;
|
||||
}
|
||||
|
||||
static void set_global_pixel(uint8_t x, uint8_t y, bool on)
|
||||
{
|
||||
if (y < LOCAL_DISPLAY_STATUS_HEIGHT) {
|
||||
local_display_frame_set_pixel(LOCAL_DISPLAY_PANEL_STATUS, x, y, on);
|
||||
} else {
|
||||
local_display_frame_set_pixel(LOCAL_DISPLAY_PANEL_CONTENT, x,
|
||||
(uint8_t)(y - LOCAL_DISPLAY_STATUS_HEIGHT), on);
|
||||
}
|
||||
}
|
||||
|
||||
static void draw_pattern(const char *name)
|
||||
{
|
||||
local_display_frame_clear_all();
|
||||
if (strcmp(name, "clear") == 0) {
|
||||
return;
|
||||
}
|
||||
if (strcmp(name, "fill") == 0) {
|
||||
for (uint8_t y = 0U; y < LOCAL_DISPLAY_HEIGHT; ++y) {
|
||||
for (uint8_t x = 0U; x < LOCAL_DISPLAY_WIDTH; ++x) {
|
||||
set_global_pixel(x, y, true);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (strcmp(name, "checker") == 0 || strcmp(name, "grid") == 0) {
|
||||
bool checker = strcmp(name, "checker") == 0;
|
||||
for (uint8_t y = 0U; y < LOCAL_DISPLAY_HEIGHT; ++y) {
|
||||
for (uint8_t x = 0U; x < LOCAL_DISPLAY_WIDTH; ++x) {
|
||||
bool on = checker ? (((x + y) & 1U) == 0U)
|
||||
: ((x % 8U) == 0U || (y % 8U) == 0U);
|
||||
if (on) {
|
||||
set_global_pixel(x, y, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (strcmp(name, "corners") == 0) {
|
||||
for (uint8_t x = 0U; x < LOCAL_DISPLAY_WIDTH; ++x) {
|
||||
set_global_pixel(x, 0U, true);
|
||||
set_global_pixel(x, LOCAL_DISPLAY_HEIGHT - 1U, true);
|
||||
}
|
||||
for (uint8_t y = 0U; y < LOCAL_DISPLAY_HEIGHT; ++y) {
|
||||
set_global_pixel(0U, y, true);
|
||||
set_global_pixel(LOCAL_DISPLAY_WIDTH - 1U, y, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
local_display_frame_clear(LOCAL_DISPLAY_PANEL_STATUS);
|
||||
local_display_frame_clear(LOCAL_DISPLAY_PANEL_CONTENT);
|
||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_STATUS, 0U, 0U, "SER OK");
|
||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_STATUS, 0U, 8U, "WR NONE");
|
||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 0U, "DISPLAY DRIVER");
|
||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 8U, "STATUS 16 PX");
|
||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 24U, "CONTENT 48 PX");
|
||||
local_display_frame_draw_text(LOCAL_DISPLAY_PANEL_CONTENT, 0U, 40U, "SEPARATE PANELS");
|
||||
}
|
||||
|
||||
static int command_display_pattern(int argc, char **argv)
|
||||
{
|
||||
if (argc != 2 || !pattern_name_valid(argv[1])) {
|
||||
print_display_usage();
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error = local_display_frame_begin();
|
||||
if (error != ESP_OK) {
|
||||
return report_error("Display pattern", error);
|
||||
}
|
||||
draw_pattern(argv[1]);
|
||||
error = local_display_frame_end();
|
||||
if (error != ESP_OK) {
|
||||
return report_error("Display pattern", error);
|
||||
}
|
||||
printf("Displayed '%s' test pattern.\n", argv[1]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_display_row(int argc, char **argv)
|
||||
{
|
||||
unsigned long row = 0U;
|
||||
if (argc != 2 || !parse_unsigned(argv[1], 0U, LOCAL_DISPLAY_HEIGHT - 1U, &row)) {
|
||||
print_display_usage();
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error = local_display_frame_begin();
|
||||
if (error != ESP_OK) {
|
||||
return report_error("Display row", error);
|
||||
}
|
||||
local_display_frame_clear_all();
|
||||
for (uint8_t x = 0U; x < LOCAL_DISPLAY_WIDTH; ++x) {
|
||||
set_global_pixel(x, (uint8_t)row, true);
|
||||
}
|
||||
error = local_display_frame_end();
|
||||
if (error != ESP_OK) {
|
||||
return report_error("Display row", error);
|
||||
}
|
||||
printf("Displayed one-pixel horizontal line at row %lu.\n", row);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_display_contrast(int argc, char **argv)
|
||||
{
|
||||
unsigned long contrast = 0U;
|
||||
if (argc != 2 || !parse_unsigned(argv[1], 0U, 255U, &contrast)) {
|
||||
print_display_usage();
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error = local_display_set_contrast((uint8_t)contrast);
|
||||
if (error != ESP_OK) {
|
||||
return report_error("Display contrast", error);
|
||||
}
|
||||
printf("Display contrast set to %lu.\n", contrast);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_display_invert(int argc, char **argv)
|
||||
{
|
||||
if (argc != 2 || (strcmp(argv[1], "on") != 0 && strcmp(argv[1], "off") != 0)) {
|
||||
print_display_usage();
|
||||
return 1;
|
||||
}
|
||||
bool inverted = strcmp(argv[1], "on") == 0;
|
||||
esp_err_t error = local_display_set_inverted(inverted);
|
||||
if (error != ESP_OK) {
|
||||
return report_error("Display inversion", error);
|
||||
}
|
||||
printf("Display inversion %s.\n", inverted ? "enabled" : "disabled");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_display(int argc, char **argv)
|
||||
{
|
||||
if (argc < 2 || strcmp(argv[1], "help") == 0) {
|
||||
print_display_usage();
|
||||
return argc < 2 || argc == 2 ? 0 : 1;
|
||||
}
|
||||
|
||||
/* Keep diagnostic output visible instead of immediately redrawing status pages. */
|
||||
local_status_ui_hold_for_diagnostics();
|
||||
|
||||
if (strcmp(argv[1], "status") == 0) {
|
||||
return command_display_status(argc - 1, argv + 1);
|
||||
}
|
||||
if (strcmp(argv[1], "probe") == 0) {
|
||||
return command_display_probe(argc - 1, argv + 1);
|
||||
}
|
||||
if (strcmp(argv[1], "scan") == 0) {
|
||||
return command_display_scan(argc - 1, argv + 1);
|
||||
}
|
||||
if (strcmp(argv[1], "init") == 0) {
|
||||
return command_display_init(argc - 1, argv + 1);
|
||||
}
|
||||
if (strcmp(argv[1], "off") == 0) {
|
||||
return command_display_off(argc - 1, argv + 1);
|
||||
}
|
||||
if (strcmp(argv[1], "pattern") == 0) {
|
||||
return command_display_pattern(argc - 1, argv + 1);
|
||||
}
|
||||
if (strcmp(argv[1], "row") == 0) {
|
||||
return command_display_row(argc - 1, argv + 1);
|
||||
}
|
||||
if (strcmp(argv[1], "contrast") == 0) {
|
||||
return command_display_contrast(argc - 1, argv + 1);
|
||||
}
|
||||
if (strcmp(argv[1], "invert") == 0) {
|
||||
return command_display_invert(argc - 1, argv + 1);
|
||||
}
|
||||
|
||||
printf("Unknown display diagnostic '%s'.\n", argv[1]);
|
||||
print_display_usage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void print_buttons_usage(void)
|
||||
{
|
||||
printf("Usage:\n");
|
||||
printf(" debug buttons status\n");
|
||||
printf(" debug buttons test [seconds] (1..30, default 10)\n");
|
||||
}
|
||||
|
||||
static int command_buttons_status(int argc, char **argv)
|
||||
{
|
||||
(void)argv;
|
||||
if (argc != 1) {
|
||||
print_buttons_usage();
|
||||
return 1;
|
||||
}
|
||||
if (!s_buttons_ready) {
|
||||
return report_error("Button status", s_button_initialization_error);
|
||||
}
|
||||
|
||||
printf("Buttons are active-low with internal pull-ups:\n");
|
||||
for (size_t index = 0U; index < sizeof(s_buttons) / sizeof(s_buttons[0]); ++index) {
|
||||
int level = gpio_get_level(s_buttons[index].gpio);
|
||||
printf(" %-14s GPIO%d level=%d %s\n", s_buttons[index].name,
|
||||
s_buttons[index].gpio, level, level == 0 ? "pressed" : "released");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_buttons_test(int argc, char **argv)
|
||||
{
|
||||
unsigned long seconds = BUTTON_TEST_DEFAULT_SECONDS;
|
||||
if (argc > 2 ||
|
||||
(argc == 2 && !parse_unsigned(argv[1], 1U, BUTTON_TEST_MAX_SECONDS, &seconds))) {
|
||||
print_buttons_usage();
|
||||
return 1;
|
||||
}
|
||||
if (!s_buttons_ready) {
|
||||
return report_error("Button test", s_button_initialization_error);
|
||||
}
|
||||
|
||||
button_test_state_t states[sizeof(s_buttons) / sizeof(s_buttons[0])];
|
||||
int64_t now = esp_timer_get_time();
|
||||
for (size_t index = 0U; index < sizeof(s_buttons) / sizeof(s_buttons[0]); ++index) {
|
||||
states[index] = (button_test_state_t){
|
||||
.raw_level = gpio_get_level(s_buttons[index].gpio),
|
||||
.stable_level = 1,
|
||||
.raw_changed_us = now,
|
||||
};
|
||||
}
|
||||
|
||||
int64_t deadline = now + (int64_t)seconds * 1000000LL;
|
||||
printf("Testing buttons for %lu second%s; short press each button and hold one for at least %u ms.\n",
|
||||
seconds, seconds == 1U ? "" : "s", (unsigned int)BUTTON_LONG_PRESS_MS);
|
||||
while ((now = esp_timer_get_time()) < deadline) {
|
||||
for (size_t index = 0U; index < sizeof(s_buttons) / sizeof(s_buttons[0]); ++index) {
|
||||
button_test_state_t *state = &states[index];
|
||||
int raw = gpio_get_level(s_buttons[index].gpio);
|
||||
if (raw != state->raw_level) {
|
||||
state->raw_level = raw;
|
||||
state->raw_changed_us = now;
|
||||
}
|
||||
if (raw != state->stable_level &&
|
||||
now - state->raw_changed_us >= (int64_t)BUTTON_DEBOUNCE_MS * 1000LL) {
|
||||
state->stable_level = raw;
|
||||
++state->stable_transitions;
|
||||
if (raw == 0) {
|
||||
state->pressed_us = now;
|
||||
state->long_reported = false;
|
||||
printf("%-14s pressed\n", s_buttons[index].name);
|
||||
} else {
|
||||
int64_t duration_ms = state->pressed_us == 0 ? 0 :
|
||||
(now - state->pressed_us) / 1000LL;
|
||||
if (!state->long_reported) {
|
||||
++state->short_presses;
|
||||
printf("%-14s short release after %lld ms\n",
|
||||
s_buttons[index].name, (long long)duration_ms);
|
||||
} else {
|
||||
printf("%-14s released after %lld ms\n",
|
||||
s_buttons[index].name, (long long)duration_ms);
|
||||
}
|
||||
state->pressed_us = 0;
|
||||
}
|
||||
}
|
||||
if (state->stable_level == 0 && !state->long_reported &&
|
||||
state->pressed_us != 0 &&
|
||||
now - state->pressed_us >= (int64_t)BUTTON_LONG_PRESS_MS * 1000LL) {
|
||||
state->long_reported = true;
|
||||
++state->long_presses;
|
||||
printf("%-14s long press\n", s_buttons[index].name);
|
||||
}
|
||||
}
|
||||
vTaskDelay(milliseconds_to_ticks(BUTTON_POLL_MS));
|
||||
}
|
||||
|
||||
printf("Button test summary:\n");
|
||||
bool stuck = false;
|
||||
for (size_t index = 0U; index < sizeof(s_buttons) / sizeof(s_buttons[0]); ++index) {
|
||||
bool pressed = gpio_get_level(s_buttons[index].gpio) == 0;
|
||||
printf(" %-14s short=%u long=%u transitions=%u final=%s\n",
|
||||
s_buttons[index].name, (unsigned int)states[index].short_presses,
|
||||
(unsigned int)states[index].long_presses,
|
||||
(unsigned int)states[index].stable_transitions,
|
||||
pressed ? "PRESSED" : "released");
|
||||
stuck |= pressed;
|
||||
}
|
||||
if (stuck) {
|
||||
printf("Warning: one or more buttons remained asserted; check for a held or stuck input.\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_buttons(int argc, char **argv)
|
||||
{
|
||||
if (argc < 2 || strcmp(argv[1], "help") == 0) {
|
||||
print_buttons_usage();
|
||||
return argc < 2 || argc == 2 ? 0 : 1;
|
||||
}
|
||||
if (strcmp(argv[1], "status") == 0) {
|
||||
return command_buttons_status(argc - 1, argv + 1);
|
||||
}
|
||||
if (strcmp(argv[1], "test") == 0) {
|
||||
return command_buttons_test(argc - 1, argv + 1);
|
||||
}
|
||||
printf("Unknown button diagnostic '%s'.\n", argv[1]);
|
||||
print_buttons_usage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
esp_err_t local_ui_hw_test_init(void)
|
||||
{
|
||||
if (s_buttons_ready) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_button_initialization_error = configure_buttons();
|
||||
return s_button_initialization_error;
|
||||
}
|
||||
|
||||
int local_ui_hw_test_execute(int argc, char **argv)
|
||||
{
|
||||
if (argc < 1 || argv == NULL || argv[0] == NULL) {
|
||||
return 1;
|
||||
}
|
||||
if (strcmp(argv[0], "display") == 0) {
|
||||
return command_display(argc, argv);
|
||||
}
|
||||
if (strcmp(argv[0], "buttons") == 0) {
|
||||
return command_buttons(argc, argv);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
void local_ui_hw_test_print_usage(void)
|
||||
{
|
||||
printf(" debug display [status|probe|scan|init|off|pattern|row|contrast|invert]\n");
|
||||
printf(" debug buttons [status|test]\n");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Phase 7A local display/button diagnostics retained for Phase 7B validation. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Configure active-low button inputs; the Phase 7B display service owns I2C. */
|
||||
esp_err_t local_ui_hw_test_init(void);
|
||||
|
||||
/* Handle argv beginning with either "display" or "buttons". */
|
||||
int local_ui_hw_test_execute(int argc, char **argv);
|
||||
|
||||
/* Append the Phase 7A command forms to the existing debug-group usage. */
|
||||
void local_ui_hw_test_print_usage(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+205
-9
@@ -1,21 +1,42 @@
|
||||
#include <string.h>
|
||||
|
||||
#include "driver/uart.h"
|
||||
#include "admin_ssh_console.h"
|
||||
#include "console_completion.h"
|
||||
#include "esp_console.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_psram.h"
|
||||
#include "network_console.h"
|
||||
#include "local_display.h"
|
||||
#include "local_boot_animation.h"
|
||||
#include "local_status_ui.h"
|
||||
#include "local_ui_config.h"
|
||||
#include "local_ui_console.h"
|
||||
#include "local_ui_hw_test.h"
|
||||
#include "mdns_config.h"
|
||||
#include "mdns_console.h"
|
||||
#include "mdns_service.h"
|
||||
#include "rs232_hw_test.h"
|
||||
#include "rs232_port_owner.h"
|
||||
#include "secure_random.h"
|
||||
#include "serial_config.h"
|
||||
#include "serial_console.h"
|
||||
#include "serial_service.h"
|
||||
#include "session_broker.h"
|
||||
#include "session_console.h"
|
||||
#include "ssh_console.h"
|
||||
#include "ssh_security.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "status_led.h"
|
||||
#include "system_console.h"
|
||||
#include "usb_cdc_transport.h"
|
||||
#include "usb_console.h"
|
||||
#include "user_console.h"
|
||||
#include "user_database.h"
|
||||
#include "web_console.h"
|
||||
#include "web_security.h"
|
||||
#include "web_server.h"
|
||||
#include "wifi_config.h"
|
||||
#include "wifi_console.h"
|
||||
#include "wifi_manager.h"
|
||||
@@ -28,7 +49,6 @@ static const char *TAG = "firmware";
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "ESP32-S3 Serial Swiss Army Knife Wi-Fi foundation phase started");
|
||||
|
||||
if (esp_psram_is_initialized()) {
|
||||
ESP_LOGI(TAG, "PSRAM initialized: %u bytes", (unsigned int)esp_psram_get_size());
|
||||
@@ -36,13 +56,55 @@ void app_main(void)
|
||||
ESP_LOGW(TAG, "PSRAM is not initialized");
|
||||
}
|
||||
|
||||
/* Seed credential generation before any future RF or ADC initialization. */
|
||||
esp_err_t wifi_entropy_error = wifi_config_entropy_init();
|
||||
/* Seed the sole device DRBG before any future RF, Bluetooth, or ADC use. */
|
||||
esp_err_t random_error = secure_random_init();
|
||||
if (random_error != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Secure random initialization failed: %s",
|
||||
esp_err_to_name(random_error));
|
||||
}
|
||||
|
||||
/* Blue means the firmware is initialized and waiting for a console command. */
|
||||
ESP_ERROR_CHECK(status_led_init());
|
||||
ESP_ERROR_CHECK(rs232_port_owner_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. */
|
||||
esp_err_t local_display_error = local_display_init();
|
||||
if (local_display_error != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Local display bus unavailable: %s",
|
||||
esp_err_to_name(local_display_error));
|
||||
} else {
|
||||
local_display_error = local_display_start();
|
||||
if (local_display_error != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Local display unavailable: %s",
|
||||
esp_err_to_name(local_display_error));
|
||||
} else {
|
||||
local_display_error = local_boot_animation_play();
|
||||
if (local_display_error != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Local boot animation unavailable: %s",
|
||||
esp_err_to_name(local_display_error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t local_ui_error = local_ui_hw_test_init();
|
||||
if (local_ui_error != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Local UI button diagnostics unavailable: %s",
|
||||
esp_err_to_name(local_ui_error));
|
||||
}
|
||||
|
||||
local_ui_config_t local_ui_config;
|
||||
bool used_stored_local_ui_config = false;
|
||||
esp_err_t local_ui_config_error =
|
||||
local_ui_config_load(&local_ui_config, &used_stored_local_ui_config);
|
||||
if (local_ui_config_error != ESP_OK) {
|
||||
local_ui_config_defaults(&local_ui_config);
|
||||
ESP_LOGW(TAG,
|
||||
"NVS local UI configuration unavailable (%s); using RAM defaults",
|
||||
esp_err_to_name(local_ui_config_error));
|
||||
}
|
||||
|
||||
serial_config_t serial_config;
|
||||
bool used_stored_config = false;
|
||||
@@ -59,9 +121,83 @@ void app_main(void)
|
||||
/* Native USB owns GPIO19/20; UART0 logging stays on the USB-to-UART bridge. */
|
||||
ESP_ERROR_CHECK(usb_cdc_transport_init());
|
||||
|
||||
/* Provision HTTPS identity before Wi-Fi starts; failures leave UART/USB recovery intact. */
|
||||
web_security_load_result_t web_security_source = WEB_SECURITY_LOAD_STORED;
|
||||
esp_err_t web_security_error = random_error;
|
||||
if (web_security_error == ESP_OK) {
|
||||
web_security_error = web_security_init(&web_security_source);
|
||||
}
|
||||
if (web_security_error != ESP_OK) {
|
||||
ESP_LOGE(TAG,
|
||||
"HTTPS security material unavailable (%s); use UART0 'web reset --force' to replace it",
|
||||
esp_err_to_name(web_security_error));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Using %s HTTPS identity and legacy recovery credential",
|
||||
web_security_source == WEB_SECURITY_LOAD_STORED ? "stored" : "newly generated");
|
||||
}
|
||||
|
||||
user_database_load_result_t user_database_source = USER_DATABASE_LOAD_EMPTY;
|
||||
web_security_credentials_t legacy_credentials;
|
||||
memset(&legacy_credentials, 0, sizeof(legacy_credentials));
|
||||
user_database_legacy_credentials_t legacy = {0};
|
||||
const user_database_legacy_credentials_t *legacy_pointer = NULL;
|
||||
if (web_security_error == ESP_OK &&
|
||||
web_security_show_credentials(&legacy_credentials) == ESP_OK) {
|
||||
legacy = (user_database_legacy_credentials_t){
|
||||
.username = (const uint8_t *)legacy_credentials.username,
|
||||
.username_length = legacy_credentials.username_length,
|
||||
.password = (const uint8_t *)legacy_credentials.password,
|
||||
.password_length = legacy_credentials.password_length,
|
||||
};
|
||||
legacy_pointer = &legacy;
|
||||
}
|
||||
esp_err_t user_database_error =
|
||||
user_database_init(legacy_pointer, &user_database_source);
|
||||
secure_wipe(&legacy_credentials, sizeof(legacy_credentials));
|
||||
secure_wipe(&legacy, sizeof(legacy));
|
||||
if (user_database_error != ESP_OK) {
|
||||
ESP_LOGE(TAG, "User database unavailable: %s; HTTPS and SSH authentication will fail closed; use UART0 'user recover --force'",
|
||||
esp_err_to_name(user_database_error));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Using %s user database",
|
||||
user_database_source == USER_DATABASE_LOAD_STORED
|
||||
? "stored"
|
||||
: (user_database_source == USER_DATABASE_LOAD_MIGRATED_LEGACY
|
||||
? "newly migrated user-level"
|
||||
: "new empty"));
|
||||
}
|
||||
|
||||
esp_err_t web_runtime_error = web_server_init();
|
||||
if (web_runtime_error != ESP_OK) {
|
||||
ESP_LOGE(TAG, "HTTPS runtime initialization failed: %s",
|
||||
esp_err_to_name(web_runtime_error));
|
||||
}
|
||||
|
||||
ssh_security_load_result_t ssh_security_source = SSH_SECURITY_LOAD_STORED;
|
||||
esp_err_t ssh_security_error = random_error;
|
||||
if (ssh_security_error == ESP_OK) {
|
||||
ssh_security_error = ssh_security_init(&ssh_security_source);
|
||||
}
|
||||
if (ssh_security_error != ESP_OK) {
|
||||
ESP_LOGE(TAG,
|
||||
"SSH host key unavailable (%s); use UART0 'ssh reset --force' to replace it",
|
||||
esp_err_to_name(ssh_security_error));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Using %s SSH host key",
|
||||
ssh_security_source == SSH_SECURITY_LOAD_STORED
|
||||
? "stored"
|
||||
: "newly generated");
|
||||
}
|
||||
|
||||
esp_err_t ssh_runtime_error = ssh_transport_init();
|
||||
if (ssh_runtime_error != ESP_OK) {
|
||||
ESP_LOGE(TAG, "SSH runtime initialization failed: %s",
|
||||
esp_err_to_name(ssh_runtime_error));
|
||||
}
|
||||
|
||||
wifi_app_config_t wifi_config;
|
||||
wifi_config_load_source_t wifi_config_source;
|
||||
esp_err_t wifi_config_error = wifi_entropy_error;
|
||||
esp_err_t wifi_config_error = random_error;
|
||||
if (wifi_config_error == ESP_OK) {
|
||||
wifi_config_error = wifi_config_load(&wifi_config, &wifi_config_source);
|
||||
}
|
||||
@@ -82,8 +218,26 @@ void app_main(void)
|
||||
"Stored Wi-Fi configuration is incompatible; using RAM defaults without overwriting it");
|
||||
}
|
||||
|
||||
mdns_config_t mdns_config;
|
||||
bool used_stored_mdns_config = false;
|
||||
esp_err_t mdns_config_error = mdns_config_load(&mdns_config, &used_stored_mdns_config);
|
||||
if (mdns_config_error != ESP_OK) {
|
||||
mdns_config_defaults(&mdns_config);
|
||||
ESP_LOGW(TAG, "NVS mDNS configuration unavailable (%s); using RAM defaults",
|
||||
esp_err_to_name(mdns_config_error));
|
||||
}
|
||||
esp_err_t mdns_service_error = mdns_service_init(&mdns_config);
|
||||
if (mdns_service_error != ESP_OK) {
|
||||
ESP_LOGW(TAG, "mDNS configuration service unavailable: %s; Wi-Fi will continue",
|
||||
esp_err_to_name(mdns_service_error));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Using %s mDNS suffix sak-%s.local",
|
||||
used_stored_mdns_config ? "stored" : "default", mdns_config.suffix);
|
||||
}
|
||||
|
||||
esp_err_t wifi_error = wifi_config_error;
|
||||
if (wifi_config_error == ESP_OK) {
|
||||
esp_err_t wifi_error = wifi_manager_init(&wifi_config);
|
||||
wifi_error = wifi_manager_init(&wifi_config);
|
||||
if (wifi_error == ESP_OK && wifi_config.enabled_at_boot != 0U) {
|
||||
wifi_error = wifi_manager_start();
|
||||
}
|
||||
@@ -103,6 +257,38 @@ void app_main(void)
|
||||
}
|
||||
wifi_config_secure_wipe(&wifi_config, sizeof(wifi_config));
|
||||
|
||||
if (wifi_error == ESP_OK && web_security_error == ESP_OK &&
|
||||
web_runtime_error == ESP_OK) {
|
||||
esp_err_t start_error = web_server_start();
|
||||
if (start_error != ESP_OK) {
|
||||
ESP_LOGE(TAG, "HTTPS startup failed: %s; UART0 recovery remains available",
|
||||
esp_err_to_name(start_error));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Authenticated HTTPS listening on TCP port 443");
|
||||
}
|
||||
}
|
||||
if (wifi_error == ESP_OK && web_security_error == ESP_OK &&
|
||||
ssh_security_error == ESP_OK && ssh_runtime_error == ESP_OK) {
|
||||
esp_err_t start_error = ssh_transport_start();
|
||||
if (start_error != ESP_OK) {
|
||||
ESP_LOGE(TAG, "SSH startup failed: %s; UART0 recovery remains available",
|
||||
esp_err_to_name(start_error));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "Authenticated SSH listening on TCP port %u",
|
||||
SSH_TRANSPORT_PORT);
|
||||
}
|
||||
}
|
||||
|
||||
if (local_ui_error == ESP_OK) {
|
||||
esp_err_t local_status_ui_error = local_status_ui_start(&local_ui_config);
|
||||
if (local_status_ui_error != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Local status UI unavailable: %s",
|
||||
esp_err_to_name(local_status_ui_error));
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Using %s local UI configuration",
|
||||
used_stored_local_ui_config ? "stored" : "default");
|
||||
ESP_LOGI(
|
||||
TAG,
|
||||
"Using %s serial configuration; UART service starts on 'serial start' or native USB open",
|
||||
@@ -110,8 +296,9 @@ void app_main(void)
|
||||
|
||||
esp_console_repl_config_t repl_config = ESP_CONSOLE_REPL_CONFIG_DEFAULT();
|
||||
repl_config.prompt = "serial-tool> ";
|
||||
repl_config.max_cmdline_length = 160;
|
||||
repl_config.task_stack_size = 8192;
|
||||
repl_config.max_cmdline_length = ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY;
|
||||
/* 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
|
||||
@@ -128,16 +315,25 @@ void app_main(void)
|
||||
|
||||
/* The REPL constructor initializes esp_console and installs `help`. */
|
||||
ESP_ERROR_CHECK(rs232_hw_test_register_console_commands());
|
||||
ESP_ERROR_CHECK(local_ui_console_register_commands());
|
||||
ESP_ERROR_CHECK(serial_console_register_commands());
|
||||
ESP_ERROR_CHECK(session_console_register_commands());
|
||||
ESP_ERROR_CHECK(usb_console_register_commands());
|
||||
ESP_ERROR_CHECK(user_console_register_commands());
|
||||
ESP_ERROR_CHECK(wifi_console_register_commands());
|
||||
if (mdns_service_error == ESP_OK) {
|
||||
ESP_ERROR_CHECK(mdns_console_register_commands());
|
||||
}
|
||||
ESP_ERROR_CHECK(web_console_register_commands());
|
||||
ESP_ERROR_CHECK(ssh_console_register_commands());
|
||||
ESP_ERROR_CHECK(network_console_register_root_commands());
|
||||
ESP_ERROR_CHECK(system_console_register_commands());
|
||||
ESP_ERROR_CHECK(admin_ssh_console_register_commands());
|
||||
/* Upgrade late UART terminals safely and add nested completion. */
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Versioned persistent configuration for the station mDNS hostname. */
|
||||
|
||||
#include "mdns_config.h"
|
||||
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_mac.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
_Static_assert(sizeof(mdns_config_t) == MDNS_CONFIG_BLOB_SIZE,
|
||||
"mDNS config schema size changed");
|
||||
|
||||
static bool suffix_character_is_valid(char character)
|
||||
{
|
||||
return (character >= 'a' && character <= 'z') ||
|
||||
(character >= '0' && character <= '9') || character == '-';
|
||||
}
|
||||
|
||||
void mdns_config_defaults(mdns_config_t *config)
|
||||
{
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
uint8_t mac[6] = {0};
|
||||
|
||||
if (config == NULL) {
|
||||
return;
|
||||
}
|
||||
|
||||
memset(config, 0, sizeof(*config));
|
||||
config->schema_version = MDNS_CONFIG_SCHEMA_VERSION;
|
||||
config->blob_size = MDNS_CONFIG_BLOB_SIZE;
|
||||
if (esp_read_mac(mac, ESP_MAC_WIFI_STA) != ESP_OK) {
|
||||
return;
|
||||
}
|
||||
for (size_t index = 0U; index < sizeof(mac); ++index) {
|
||||
config->suffix[index * 2U] = hex[mac[index] >> 4U];
|
||||
config->suffix[index * 2U + 1U] = hex[mac[index] & 0x0fU];
|
||||
}
|
||||
config->suffix_len = sizeof(mac) * 2U;
|
||||
}
|
||||
|
||||
esp_err_t mdns_config_validate(const mdns_config_t *config)
|
||||
{
|
||||
if (config == NULL || config->schema_version != MDNS_CONFIG_SCHEMA_VERSION ||
|
||||
config->blob_size != MDNS_CONFIG_BLOB_SIZE || config->suffix_len == 0U ||
|
||||
config->suffix_len > MDNS_CONFIG_SUFFIX_MAX_LEN || config->reserved != 0U ||
|
||||
config->suffix[config->suffix_len] != '\0') {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (config->suffix[0] == '-' || config->suffix[config->suffix_len - 1U] == '-') {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
for (size_t index = 0U; index < MDNS_CONFIG_SUFFIX_MAX_LEN; ++index) {
|
||||
if (index < config->suffix_len) {
|
||||
if (!suffix_character_is_valid(config->suffix[index])) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
} else if (config->suffix[index] != '\0') {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t mdns_config_load(mdns_config_t *config, bool *used_stored_config)
|
||||
{
|
||||
if (config == NULL || used_stored_config == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
mdns_config_defaults(config);
|
||||
*used_stored_config = false;
|
||||
if (mdns_config_validate(config) != ESP_OK) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
esp_err_t error = nvs_flash_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
nvs_handle_t handle;
|
||||
error = nvs_open(MDNS_CONFIG_NVS_NAMESPACE, NVS_READONLY, &handle);
|
||||
if (error == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
size_t size = 0U;
|
||||
error = nvs_get_blob(handle, MDNS_CONFIG_NVS_BLOB_KEY, NULL, &size);
|
||||
if (error == ESP_ERR_NVS_NOT_FOUND || error == ESP_ERR_NVS_TYPE_MISMATCH ||
|
||||
(error == ESP_OK && size != sizeof(*config))) {
|
||||
nvs_close(handle);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
nvs_close(handle);
|
||||
return error;
|
||||
}
|
||||
mdns_config_t stored = {0};
|
||||
error = nvs_get_blob(handle, MDNS_CONFIG_NVS_BLOB_KEY, &stored, &size);
|
||||
nvs_close(handle);
|
||||
if (error == ESP_ERR_NVS_INVALID_LENGTH) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
if (size == sizeof(stored) && mdns_config_validate(&stored) == ESP_OK) {
|
||||
*config = stored;
|
||||
*used_stored_config = true;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t mdns_config_save(const mdns_config_t *config)
|
||||
{
|
||||
esp_err_t error = mdns_config_validate(config);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = nvs_flash_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
nvs_handle_t handle;
|
||||
error = nvs_open(MDNS_CONFIG_NVS_NAMESPACE, NVS_READWRITE, &handle);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = nvs_set_blob(handle, MDNS_CONFIG_NVS_BLOB_KEY, config, sizeof(*config));
|
||||
if (error == ESP_OK) {
|
||||
error = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t mdns_config_reset_storage(void)
|
||||
{
|
||||
mdns_config_t config;
|
||||
mdns_config_defaults(&config);
|
||||
return mdns_config_save(&config);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Versioned persistent configuration for the station mDNS hostname. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#define MDNS_CONFIG_SCHEMA_VERSION 1U
|
||||
#define MDNS_CONFIG_BLOB_SIZE 64U
|
||||
#define MDNS_CONFIG_SUFFIX_MAX_LEN 55U
|
||||
#define MDNS_CONFIG_NVS_NAMESPACE "mdns_cfg"
|
||||
#define MDNS_CONFIG_NVS_BLOB_KEY "config"
|
||||
|
||||
typedef struct {
|
||||
uint32_t schema_version;
|
||||
uint16_t blob_size;
|
||||
uint8_t suffix_len;
|
||||
uint8_t reserved;
|
||||
char suffix[MDNS_CONFIG_SUFFIX_MAX_LEN + 1U];
|
||||
} mdns_config_t;
|
||||
|
||||
void mdns_config_defaults(mdns_config_t *config);
|
||||
esp_err_t mdns_config_validate(const mdns_config_t *config);
|
||||
esp_err_t mdns_config_load(mdns_config_t *config, bool *used_stored_config);
|
||||
esp_err_t mdns_config_save(const mdns_config_t *config);
|
||||
esp_err_t mdns_config_reset_storage(void);
|
||||
@@ -0,0 +1,149 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Administration console for the station mDNS hostname. */
|
||||
|
||||
#include "mdns_console.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_console.h"
|
||||
#include "mdns_config.h"
|
||||
#include "mdns_service.h"
|
||||
#include "wifi_manager.h"
|
||||
|
||||
static void print_usage(void)
|
||||
{
|
||||
printf("Usage: mdns status|suffix <lowercase-suffix>|save|load|defaults|reset\n");
|
||||
}
|
||||
|
||||
static int request_reannounce(void)
|
||||
{
|
||||
esp_err_t error = wifi_manager_mdns_reannounce();
|
||||
if (error != ESP_OK) {
|
||||
printf("mDNS configuration updated; it will be used when Wi-Fi STA receives an IP (%s).\n",
|
||||
esp_err_to_name(error));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int show_status(void)
|
||||
{
|
||||
mdns_service_snapshot_t snapshot;
|
||||
esp_err_t error = mdns_service_get_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
printf("mDNS unavailable: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("mDNS: initialized=%s announced=%s hostname=%s.local suffix=%s last-error=%s\n",
|
||||
snapshot.initialized ? "yes" : "no", snapshot.announced ? "yes" : "no",
|
||||
snapshot.hostname, snapshot.suffix, esp_err_to_name(snapshot.last_error));
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int set_suffix(const char *suffix)
|
||||
{
|
||||
size_t length = strlen(suffix);
|
||||
mdns_config_t config;
|
||||
esp_err_t error = mdns_service_get_config(&config);
|
||||
if (error == ESP_OK) {
|
||||
memset(config.suffix, 0, sizeof(config.suffix));
|
||||
if (length <= MDNS_CONFIG_SUFFIX_MAX_LEN) {
|
||||
memcpy(config.suffix, suffix, length);
|
||||
config.suffix_len = (uint8_t)length;
|
||||
error = mdns_service_set_config(&config);
|
||||
} else {
|
||||
error = ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Suffix must be 1..%u lowercase letters, digits, or hyphens, and cannot begin or end with a hyphen.\n",
|
||||
MDNS_CONFIG_SUFFIX_MAX_LEN);
|
||||
return 1;
|
||||
}
|
||||
printf("mDNS suffix updated in RAM; hostname is sak-%s.local; use 'mdns save' to persist it.\n",
|
||||
suffix);
|
||||
return request_reannounce();
|
||||
}
|
||||
|
||||
static int save_config(void)
|
||||
{
|
||||
mdns_config_t config;
|
||||
esp_err_t error = mdns_service_get_config(&config);
|
||||
if (error == ESP_OK) {
|
||||
error = mdns_config_save(&config);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not save mDNS configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("mDNS configuration saved to NVS.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int load_config(void)
|
||||
{
|
||||
mdns_config_t config;
|
||||
bool stored = false;
|
||||
esp_err_t error = mdns_config_load(&config, &stored);
|
||||
if (error == ESP_OK) {
|
||||
error = mdns_service_set_config(&config);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not load mDNS configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("Loaded %s mDNS configuration into RAM.\n", stored ? "stored" : "default");
|
||||
return request_reannounce();
|
||||
}
|
||||
|
||||
static int apply_defaults(bool persist)
|
||||
{
|
||||
mdns_config_t config;
|
||||
mdns_config_defaults(&config);
|
||||
esp_err_t error = mdns_service_set_config(&config);
|
||||
if (error == ESP_OK && persist) {
|
||||
error = mdns_config_save(&config);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not apply mDNS defaults: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("MAC-derived mDNS defaults applied%s.\n", persist ? " and saved" : " in RAM");
|
||||
return request_reannounce();
|
||||
}
|
||||
|
||||
static int command_mdns(int argc, char **argv)
|
||||
{
|
||||
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0)) {
|
||||
return show_status();
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "suffix") == 0) {
|
||||
return set_suffix(argv[2]);
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "save") == 0) {
|
||||
return save_config();
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "load") == 0) {
|
||||
return load_config();
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "defaults") == 0) {
|
||||
return apply_defaults(false);
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "reset") == 0) {
|
||||
return apply_defaults(true);
|
||||
}
|
||||
print_usage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
esp_err_t mdns_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
.command = "mdns",
|
||||
.help = "Configure the STA mDNS hostname; use 'mdns' for status",
|
||||
.hint = NULL,
|
||||
.func = &command_mdns,
|
||||
.argtable = NULL,
|
||||
};
|
||||
return esp_console_cmd_register(&command);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
esp_err_t mdns_console_register_commands(void);
|
||||
@@ -0,0 +1,163 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* mDNS runtime service; its lifecycle is owned by wifi_manager. */
|
||||
|
||||
#include "mdns_service.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "mdns.h"
|
||||
|
||||
static SemaphoreHandle_t s_mutex;
|
||||
static mdns_config_t s_config;
|
||||
static bool s_component_initialized;
|
||||
static bool s_initialization_failed;
|
||||
static bool s_announced;
|
||||
static esp_err_t s_last_error;
|
||||
|
||||
static void lock_service(void)
|
||||
{
|
||||
(void)xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
}
|
||||
|
||||
static void unlock_service(void)
|
||||
{
|
||||
(void)xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
static void make_hostname(const mdns_config_t *config, char *hostname, size_t size)
|
||||
{
|
||||
(void)snprintf(hostname, size, "sak-%s", config->suffix);
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_init(const mdns_config_t *config)
|
||||
{
|
||||
if (mdns_config_validate(config) != ESP_OK) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_mutex != NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
s_mutex = xSemaphoreCreateMutex();
|
||||
if (s_mutex == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
s_config = *config;
|
||||
s_last_error = ESP_OK;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_get_config(mdns_config_t *config)
|
||||
{
|
||||
if (config == NULL || s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
lock_service();
|
||||
*config = s_config;
|
||||
unlock_service();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_set_config(const mdns_config_t *config)
|
||||
{
|
||||
if (s_mutex == NULL || mdns_config_validate(config) != ESP_OK) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
lock_service();
|
||||
s_config = *config;
|
||||
unlock_service();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL || s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
lock_service();
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
snapshot->initialized = true;
|
||||
snapshot->announced = s_announced;
|
||||
memcpy(snapshot->suffix, s_config.suffix, s_config.suffix_len);
|
||||
make_hostname(&s_config, snapshot->hostname, sizeof(snapshot->hostname));
|
||||
snapshot->last_error = s_last_error;
|
||||
unlock_service();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_start(void)
|
||||
{
|
||||
if (s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
lock_service();
|
||||
if (s_component_initialized) {
|
||||
s_announced = true;
|
||||
unlock_service();
|
||||
return ESP_OK;
|
||||
}
|
||||
if (s_initialization_failed) {
|
||||
esp_err_t error = s_last_error;
|
||||
unlock_service();
|
||||
return error;
|
||||
}
|
||||
mdns_config_t config = s_config;
|
||||
unlock_service();
|
||||
|
||||
esp_err_t error = mdns_init();
|
||||
if (error == ESP_OK) {
|
||||
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
|
||||
make_hostname(&config, hostname, sizeof(hostname));
|
||||
error = mdns_hostname_set(hostname);
|
||||
if (error == ESP_OK) {
|
||||
error = mdns_instance_name_set("ESP32 Serial Swiss Army Knife");
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
mdns_free();
|
||||
}
|
||||
}
|
||||
|
||||
lock_service();
|
||||
s_component_initialized = error == ESP_OK;
|
||||
s_initialization_failed = error != ESP_OK;
|
||||
s_announced = error == ESP_OK;
|
||||
s_last_error = error;
|
||||
unlock_service();
|
||||
return error;
|
||||
}
|
||||
|
||||
void mdns_service_stop(void)
|
||||
{
|
||||
if (s_mutex == NULL) {
|
||||
return;
|
||||
}
|
||||
lock_service();
|
||||
s_announced = false;
|
||||
unlock_service();
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_reannounce(void)
|
||||
{
|
||||
if (s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
lock_service();
|
||||
if (!s_component_initialized) {
|
||||
esp_err_t error = s_initialization_failed ? s_last_error : ESP_ERR_INVALID_STATE;
|
||||
unlock_service();
|
||||
return error;
|
||||
}
|
||||
mdns_config_t config = s_config;
|
||||
unlock_service();
|
||||
|
||||
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U] = {0};
|
||||
make_hostname(&config, hostname, sizeof(hostname));
|
||||
esp_err_t error = mdns_hostname_set(hostname);
|
||||
|
||||
lock_service();
|
||||
s_last_error = error;
|
||||
unlock_service();
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* mDNS runtime service; its lifecycle is owned by wifi_manager. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "mdns_config.h"
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
bool announced;
|
||||
char suffix[MDNS_CONFIG_SUFFIX_MAX_LEN + 1U];
|
||||
char hostname[MDNS_CONFIG_SUFFIX_MAX_LEN + 5U];
|
||||
esp_err_t last_error;
|
||||
} mdns_service_snapshot_t;
|
||||
|
||||
esp_err_t mdns_service_init(const mdns_config_t *config);
|
||||
esp_err_t mdns_service_get_config(mdns_config_t *config);
|
||||
esp_err_t mdns_service_set_config(const mdns_config_t *config);
|
||||
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot);
|
||||
|
||||
/* Only wifi_manager may call these lifecycle operations. */
|
||||
esp_err_t mdns_service_start(void);
|
||||
void mdns_service_stop(void);
|
||||
esp_err_t mdns_service_reannounce(void);
|
||||
+108
-87
@@ -14,6 +14,7 @@
|
||||
#include "esp_err.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/task.h"
|
||||
#include "lwip/inet.h"
|
||||
#include "lwip/inet_chksum.h"
|
||||
@@ -163,109 +164,109 @@ static int resolve_ping_target(const char *host, ip_addr_t *target,
|
||||
return 0;
|
||||
}
|
||||
|
||||
typedef enum {
|
||||
PING_EVENT_LINE = 0,
|
||||
PING_EVENT_END,
|
||||
} ping_event_kind_t;
|
||||
|
||||
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;
|
||||
bool received_reply;
|
||||
bool summary_valid;
|
||||
} ping_event_t;
|
||||
|
||||
typedef struct {
|
||||
QueueHandle_t queue;
|
||||
} 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)
|
||||
{
|
||||
(void)arguments;
|
||||
|
||||
ping_wait_context_t *context = arguments;
|
||||
ping_event_t event = {.kind = PING_EVENT_LINE};
|
||||
uint16_t sequence = 0U;
|
||||
uint8_t ttl = 0U;
|
||||
uint32_t reply_size = 0U;
|
||||
uint32_t elapsed_ms = 0U;
|
||||
ip_addr_t reply_address;
|
||||
char numeric[NUMERIC_ADDRESS_CAPACITY];
|
||||
|
||||
if (esp_ping_get_profile(handle, ESP_PING_PROF_SEQNO,
|
||||
&sequence, sizeof(sequence)) != ESP_OK ||
|
||||
esp_ping_get_profile(handle, ESP_PING_PROF_SIZE,
|
||||
&reply_size, sizeof(reply_size)) != ESP_OK ||
|
||||
esp_ping_get_profile(handle, ESP_PING_PROF_TIMEGAP,
|
||||
&elapsed_ms, sizeof(elapsed_ms)) != ESP_OK ||
|
||||
esp_ping_get_profile(handle, ESP_PING_PROF_IPADDR,
|
||||
&reply_address, sizeof(reply_address)) != ESP_OK ||
|
||||
ipaddr_ntoa_r(&reply_address, numeric, (int)sizeof(numeric)) == NULL) {
|
||||
printf("ping: received a reply but could not read its profile\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (IP_IS_V4(&reply_address) &&
|
||||
esp_ping_get_profile(handle, ESP_PING_PROF_TTL,
|
||||
&ttl, sizeof(ttl)) == ESP_OK) {
|
||||
printf("%" PRIu32 " bytes from %s: icmp_seq=%" PRIu16
|
||||
" ttl=%u time=%" PRIu32 " ms\n",
|
||||
reply_size, numeric, sequence, (unsigned int)ttl, elapsed_ms);
|
||||
char numeric[NUMERIC_ADDRESS_CAPACITY] = "?";
|
||||
bool valid = esp_ping_get_profile(handle, ESP_PING_PROF_SEQNO,
|
||||
&sequence, sizeof(sequence)) == ESP_OK &&
|
||||
esp_ping_get_profile(handle, ESP_PING_PROF_SIZE,
|
||||
&reply_size, sizeof(reply_size)) == ESP_OK &&
|
||||
esp_ping_get_profile(handle, ESP_PING_PROF_TIMEGAP,
|
||||
&elapsed_ms, sizeof(elapsed_ms)) == ESP_OK &&
|
||||
esp_ping_get_profile(handle, ESP_PING_PROF_IPADDR,
|
||||
&reply_address, sizeof(reply_address)) == ESP_OK &&
|
||||
ipaddr_ntoa_r(&reply_address, numeric, (int)sizeof(numeric)) != NULL;
|
||||
if (!valid) {
|
||||
strlcpy(event.line, "ping: received a reply but could not read its profile",
|
||||
sizeof(event.line));
|
||||
} else if (IP_IS_V4(&reply_address) &&
|
||||
esp_ping_get_profile(handle, ESP_PING_PROF_TTL,
|
||||
&ttl, sizeof(ttl)) == ESP_OK) {
|
||||
snprintf(event.line, sizeof(event.line),
|
||||
"%" PRIu32 " bytes from %s: icmp_seq=%" PRIu16
|
||||
" ttl=%u time=%" PRIu32 " ms",
|
||||
reply_size, numeric, sequence, (unsigned int)ttl, elapsed_ms);
|
||||
} else {
|
||||
printf("%" PRIu32 " bytes from %s: icmp_seq=%" PRIu16
|
||||
" time=%" PRIu32 " ms\n",
|
||||
reply_size, numeric, sequence, elapsed_ms);
|
||||
snprintf(event.line, sizeof(event.line),
|
||||
"%" PRIu32 " bytes from %s: icmp_seq=%" PRIu16
|
||||
" time=%" PRIu32 " 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)
|
||||
{
|
||||
(void)arguments;
|
||||
|
||||
ping_wait_context_t *context = arguments;
|
||||
ping_event_t event = {.kind = PING_EVENT_LINE};
|
||||
uint16_t sequence = 0U;
|
||||
ip_addr_t target_address;
|
||||
char numeric[NUMERIC_ADDRESS_CAPACITY] = "?";
|
||||
|
||||
if (esp_ping_get_profile(handle, ESP_PING_PROF_SEQNO,
|
||||
&sequence, sizeof(sequence)) == ESP_OK &&
|
||||
esp_ping_get_profile(handle, ESP_PING_PROF_IPADDR,
|
||||
&target_address, sizeof(target_address)) == ESP_OK) {
|
||||
(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)
|
||||
{
|
||||
ping_wait_context_t *context = (ping_wait_context_t *)arguments;
|
||||
uint32_t transmitted = 0U;
|
||||
uint32_t received = 0U;
|
||||
uint32_t duration_ms = 0U;
|
||||
ping_wait_context_t *context = arguments;
|
||||
ping_event_t event = {.kind = PING_EVENT_END, .profile_error = ESP_OK};
|
||||
ip_addr_t target_address;
|
||||
char numeric[NUMERIC_ADDRESS_CAPACITY] = "?";
|
||||
|
||||
esp_err_t profile_error = esp_ping_get_profile(
|
||||
handle, ESP_PING_PROF_REQUEST, &transmitted, sizeof(transmitted));
|
||||
if (profile_error == ESP_OK) {
|
||||
profile_error = esp_ping_get_profile(
|
||||
handle, ESP_PING_PROF_REPLY, &received, sizeof(received));
|
||||
strlcpy(event.address, "?", sizeof(event.address));
|
||||
event.profile_error = esp_ping_get_profile(
|
||||
handle, ESP_PING_PROF_REQUEST, &event.transmitted, sizeof(event.transmitted));
|
||||
if (event.profile_error == ESP_OK) {
|
||||
event.profile_error = esp_ping_get_profile(
|
||||
handle, ESP_PING_PROF_REPLY, &event.received, sizeof(event.received));
|
||||
}
|
||||
if (profile_error == ESP_OK) {
|
||||
profile_error = esp_ping_get_profile(
|
||||
handle, ESP_PING_PROF_DURATION, &duration_ms, sizeof(duration_ms));
|
||||
if (event.profile_error == ESP_OK) {
|
||||
event.profile_error = esp_ping_get_profile(
|
||||
handle, ESP_PING_PROF_DURATION, &event.duration_ms, sizeof(event.duration_ms));
|
||||
}
|
||||
if (esp_ping_get_profile(handle, ESP_PING_PROF_IPADDR,
|
||||
&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));
|
||||
}
|
||||
|
||||
if (profile_error == ESP_OK) {
|
||||
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);
|
||||
event.delete_error = esp_ping_delete_session(handle);
|
||||
(void)xQueueSend(context->queue, &event, 0U);
|
||||
}
|
||||
|
||||
static int execute_ping(int argc, char **argv)
|
||||
@@ -288,12 +289,17 @@ static int execute_ping(int argc, char **argv)
|
||||
return 1;
|
||||
}
|
||||
|
||||
ping_wait_context_t context = {
|
||||
.waiting_task = xTaskGetCurrentTaskHandle(),
|
||||
.delete_error = ESP_FAIL,
|
||||
};
|
||||
/* Remove any unrelated notification before this command begins waiting. */
|
||||
(void)ulTaskNotifyTake(pdTRUE, 0U);
|
||||
if (s_ping_queue == NULL) {
|
||||
s_ping_queue = xQueueCreateStatic(PING_EVENT_QUEUE_LENGTH, sizeof(ping_event_t),
|
||||
s_ping_queue_bytes, &s_ping_queue_storage);
|
||||
} else {
|
||||
(void)xQueueReset(s_ping_queue);
|
||||
}
|
||||
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();
|
||||
config.count = count;
|
||||
@@ -321,21 +327,36 @@ static int execute_ping(int argc, char **argv)
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Finite count guarantees on_ping_end; blocking keeps console output ordered. */
|
||||
if (ulTaskNotifyTake(pdTRUE, portMAX_DELAY) == 0U) {
|
||||
printf("ping: wait for session completion failed\n");
|
||||
(void)esp_ping_stop(session);
|
||||
(void)esp_ping_delete_session(session);
|
||||
return 1;
|
||||
for (;;) {
|
||||
ping_event_t event;
|
||||
if (xQueueReceive(s_ping_queue, &event, portMAX_DELAY) != pdTRUE) {
|
||||
printf("ping: wait for session completion failed\n");
|
||||
return 1;
|
||||
}
|
||||
if (event.kind == PING_EVENT_LINE) {
|
||||
printf("%s\n", event.line);
|
||||
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",
|
||||
esp_err_to_name(event.delete_error));
|
||||
return 1;
|
||||
}
|
||||
return event.received > 0U ? 0 : 1;
|
||||
}
|
||||
|
||||
bool command_succeeded = context.summary_valid && context.received_reply;
|
||||
if (context.delete_error != ESP_OK) {
|
||||
printf("ping: could not delete session: %s\n",
|
||||
esp_err_to_name(context.delete_error));
|
||||
return 1;
|
||||
}
|
||||
return command_succeeded ? 0 : 1;
|
||||
}
|
||||
|
||||
static bool socket_addresses_equal(const struct addrinfo *left,
|
||||
|
||||
+11
-8
@@ -17,6 +17,7 @@
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/task.h"
|
||||
#include "local_ui_hw_test.h"
|
||||
#include "rs232_port_owner.h"
|
||||
#include "status_led.h"
|
||||
|
||||
@@ -1578,11 +1579,13 @@ DEFINE_OWNED_COMMAND(rts_flow_test)
|
||||
static void print_debug_usage(void)
|
||||
{
|
||||
printf("Usage:\n");
|
||||
printf(" debug status\n");
|
||||
printf(" debug transceiver <enable|disable>\n");
|
||||
printf(" debug drivers <TX 0|1> <DTR 0|1> <RTS 0|1>\n");
|
||||
printf(" debug loopback-a|loopback-b|valid-test\n");
|
||||
printf(" debug uart-loopback <baud> [format] [bytes]\n");
|
||||
printf(" debug uart-suite|cts-flow-test|rts-flow-test\n");
|
||||
local_ui_hw_test_print_usage();
|
||||
}
|
||||
|
||||
static int command_debug(int argc, char **argv)
|
||||
@@ -1593,6 +1596,9 @@ static int command_debug(int argc, char **argv)
|
||||
}
|
||||
|
||||
/* Existing handlers expect their own command name in argv[0]. */
|
||||
if (strcmp(argv[1], "status") == 0) {
|
||||
return command_status(argc - 1, argv + 1);
|
||||
}
|
||||
if (strcmp(argv[1], "transceiver") == 0) {
|
||||
return command_transceiver(argc - 1, argv + 1);
|
||||
}
|
||||
@@ -1620,6 +1626,10 @@ static int command_debug(int argc, char **argv)
|
||||
if (strcmp(argv[1], "rts-flow-test") == 0) {
|
||||
return command_rts_flow_test(argc - 1, argv + 1);
|
||||
}
|
||||
if (strcmp(argv[1], "display") == 0 ||
|
||||
strcmp(argv[1], "buttons") == 0) {
|
||||
return local_ui_hw_test_execute(argc - 1, argv + 1);
|
||||
}
|
||||
|
||||
printf("Unknown debug command '%s'.\n", argv[1]);
|
||||
print_debug_usage();
|
||||
@@ -1641,16 +1651,9 @@ esp_err_t rs232_hw_test_register_console_commands(void)
|
||||
}
|
||||
|
||||
const esp_console_cmd_t commands[] = {
|
||||
{
|
||||
.command = "status",
|
||||
.help = "Show MAX3243 driver, receiver, VLD, and shutdown states",
|
||||
.hint = NULL,
|
||||
.func = &command_status,
|
||||
.argtable = NULL,
|
||||
},
|
||||
{
|
||||
.command = "debug",
|
||||
.help = "Low-level RS-232 hardware diagnostics; run 'debug' for subcommands",
|
||||
.help = "Low-level RS-232 and local-UI hardware diagnostics; run 'debug' for subcommands",
|
||||
.hint = NULL,
|
||||
.func = &command_debug,
|
||||
.argtable = NULL,
|
||||
|
||||
+1
-1
@@ -5,5 +5,5 @@
|
||||
/* Configure all MAX3243 logic-side signals in their safe static-test state. */
|
||||
esp_err_t rs232_hw_test_init(void);
|
||||
|
||||
/* Register top-level status and the Phase 0 `debug` submenu. */
|
||||
/* Register top-level status and the shared low-level `debug` submenu. */
|
||||
esp_err_t rs232_hw_test_register_console_commands(void);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Pre-radio seeding and serialized access to the device-wide CTR_DRBG. */
|
||||
|
||||
#include "secure_random.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "bootloader_random.h"
|
||||
#include "esp_random.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "mbedtls/ctr_drbg.h"
|
||||
|
||||
static SemaphoreHandle_t s_random_mutex;
|
||||
static mbedtls_ctr_drbg_context s_drbg;
|
||||
static bool s_drbg_ready;
|
||||
static uint32_t s_generate_calls;
|
||||
|
||||
static int pre_radio_entropy(void *context, unsigned char *output, size_t length)
|
||||
{
|
||||
(void)context;
|
||||
|
||||
/* This callback is deliberately reachable only from secure_random_init(). */
|
||||
bootloader_random_enable();
|
||||
esp_fill_random(output, length);
|
||||
bootloader_random_disable();
|
||||
return 0;
|
||||
}
|
||||
|
||||
esp_err_t secure_random_init(void)
|
||||
{
|
||||
static const unsigned char personalization[] =
|
||||
"esp32-serial-swiss-army-knife";
|
||||
|
||||
if (s_drbg_ready) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (s_random_mutex == NULL) {
|
||||
s_random_mutex = xSemaphoreCreateMutex();
|
||||
if (s_random_mutex == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_random_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_OK;
|
||||
if (!s_drbg_ready) {
|
||||
mbedtls_ctr_drbg_init(&s_drbg);
|
||||
int result = mbedtls_ctr_drbg_seed(&s_drbg,
|
||||
pre_radio_entropy,
|
||||
NULL,
|
||||
personalization,
|
||||
sizeof(personalization) - 1U);
|
||||
if (result == 0) {
|
||||
/* Reseeding would re-enter a pre-radio-only entropy path after RF starts. */
|
||||
mbedtls_ctr_drbg_set_reseed_interval(&s_drbg, INT_MAX);
|
||||
s_generate_calls = 0U;
|
||||
s_drbg_ready = true;
|
||||
} else {
|
||||
mbedtls_ctr_drbg_free(&s_drbg);
|
||||
error = ESP_FAIL;
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(s_random_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t secure_random_fill(void *output, size_t length)
|
||||
{
|
||||
if (length == 0U) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (output == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (!s_drbg_ready || s_random_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
unsigned char *cursor = (unsigned char *)output;
|
||||
esp_err_t error = ESP_OK;
|
||||
|
||||
xSemaphoreTake(s_random_mutex, portMAX_DELAY);
|
||||
while (length > 0U) {
|
||||
/* CTR_DRBG limits each request even though the public API need not. */
|
||||
size_t chunk = length;
|
||||
if (chunk > MBEDTLS_CTR_DRBG_MAX_REQUEST) {
|
||||
chunk = MBEDTLS_CTR_DRBG_MAX_REQUEST;
|
||||
}
|
||||
/*
|
||||
* Mbed TLS stores its reseed counter in a signed int. Fail closed one
|
||||
* call before INT_MAX so it can neither overflow nor invoke the
|
||||
* pre-radio-only entropy callback during the device's lifetime.
|
||||
*/
|
||||
if (s_generate_calls >= (uint32_t)INT_MAX - 1U) {
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
break;
|
||||
}
|
||||
if (mbedtls_ctr_drbg_random(&s_drbg, cursor, chunk) != 0) {
|
||||
error = ESP_FAIL;
|
||||
break;
|
||||
}
|
||||
++s_generate_calls;
|
||||
cursor += chunk;
|
||||
length -= chunk;
|
||||
}
|
||||
xSemaphoreGive(s_random_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
int secure_random_mbedtls(void *context, unsigned char *output, size_t length)
|
||||
{
|
||||
(void)context;
|
||||
return secure_random_fill(output, length) == ESP_OK
|
||||
? 0
|
||||
: MBEDTLS_ERR_CTR_DRBG_ENTROPY_SOURCE_FAILED;
|
||||
}
|
||||
|
||||
void secure_wipe(void *data, size_t size)
|
||||
{
|
||||
volatile uint8_t *byte = (volatile uint8_t *)data;
|
||||
|
||||
if (byte == NULL) {
|
||||
return;
|
||||
}
|
||||
while (size-- > 0U) {
|
||||
*byte++ = 0U;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Device-wide cryptographic random generator shared by security subsystems. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/*
|
||||
* Seed the sole CTR_DRBG while bootloader entropy is safe to enable. Call this
|
||||
* before Wi-Fi, Bluetooth, or ADC startup; later calls are idempotent and do
|
||||
* not touch the hardware entropy source.
|
||||
*/
|
||||
esp_err_t secure_random_init(void);
|
||||
|
||||
/* Fill output from the already-seeded, mutex-protected device DRBG. */
|
||||
esp_err_t secure_random_fill(void *output, size_t length);
|
||||
|
||||
/* Mbed TLS-compatible adapter: zero means success, negative means failure. */
|
||||
int secure_random_mbedtls(void *context, unsigned char *output, size_t length);
|
||||
|
||||
/* Volatile stores keep cleanup of key material from being optimized away. */
|
||||
void secure_wipe(void *data, size_t size);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+35
-1
@@ -6,6 +6,7 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_command_gate.h"
|
||||
#include "esp_console.h"
|
||||
#include "esp_err.h"
|
||||
#include "rs232_port_owner.h"
|
||||
@@ -191,7 +192,7 @@ static int set_parameter(const char *parameter, const char *value)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_serial(int argc, char **argv)
|
||||
static int command_serial_impl(int argc, char **argv)
|
||||
{
|
||||
if (argc == 1 || (argc == 2 && strcmp(argv[1], "status") == 0)) {
|
||||
return show_status();
|
||||
@@ -290,6 +291,39 @@ static int command_serial(int argc, char **argv)
|
||||
return 1;
|
||||
}
|
||||
|
||||
static bool command_serial_requires_gate(int argc, char **argv)
|
||||
{
|
||||
if (argc == 4 && strcmp(argv[1], "set") == 0) {
|
||||
return true;
|
||||
}
|
||||
if (argc != 2) {
|
||||
return false;
|
||||
}
|
||||
return strcmp(argv[1], "start") == 0 ||
|
||||
strcmp(argv[1], "stop") == 0 ||
|
||||
strcmp(argv[1], "save") == 0 ||
|
||||
strcmp(argv[1], "load") == 0 ||
|
||||
strcmp(argv[1], "defaults") == 0 ||
|
||||
strcmp(argv[1], "reset") == 0;
|
||||
}
|
||||
|
||||
static int command_serial(int argc, char **argv)
|
||||
{
|
||||
if (!command_serial_requires_gate(argc, argv)) {
|
||||
return command_serial_impl(argc, argv);
|
||||
}
|
||||
|
||||
esp_err_t error = admin_command_gate_take();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not serialize serial administration: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
int result = command_serial_impl(argc, argv);
|
||||
admin_command_gate_give();
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t serial_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
|
||||
+31
-4
@@ -7,6 +7,7 @@
|
||||
#include "driver/gpio.h"
|
||||
#include "driver/uart.h"
|
||||
#include "esp_check.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
@@ -17,8 +18,10 @@
|
||||
|
||||
#define SERIAL_UART_RX_RING_SIZE 8192
|
||||
#define SERIAL_UART_EVENT_QUEUE_SIZE 64
|
||||
#define SERIAL_RX_STREAM_SIZE 16384
|
||||
#define SERIAL_TX_STREAM_SIZE 8192
|
||||
#define SERIAL_RX_STREAM_SIZE 16384U
|
||||
#define SERIAL_TX_STREAM_SIZE 8192U
|
||||
#define SERIAL_RX_STREAM_STORAGE_SIZE (SERIAL_RX_STREAM_SIZE + 1U)
|
||||
#define SERIAL_TX_STREAM_STORAGE_SIZE (SERIAL_TX_STREAM_SIZE + 1U)
|
||||
#define SERIAL_IO_CHUNK_SIZE 256
|
||||
#define SERIAL_TASK_STACK_SIZE 4096
|
||||
#define SERIAL_TASK_PRIORITY 10
|
||||
@@ -32,6 +35,10 @@ static SemaphoreHandle_t s_state_mutex;
|
||||
static SemaphoreHandle_t s_task_stopped;
|
||||
static StreamBufferHandle_t s_rx_stream;
|
||||
static StreamBufferHandle_t s_tx_stream;
|
||||
static StaticStreamBuffer_t s_rx_stream_control;
|
||||
static StaticStreamBuffer_t s_tx_stream_control;
|
||||
static uint8_t *s_rx_stream_storage;
|
||||
static uint8_t *s_tx_stream_storage;
|
||||
static QueueHandle_t s_uart_event_queue;
|
||||
static TaskHandle_t s_event_task;
|
||||
static portMUX_TYPE s_counter_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
@@ -472,8 +479,24 @@ esp_err_t serial_service_init(const serial_config_t *initial_config)
|
||||
|
||||
s_state_mutex = xSemaphoreCreateMutex();
|
||||
s_task_stopped = xSemaphoreCreateBinary();
|
||||
s_rx_stream = xStreamBufferCreate(SERIAL_RX_STREAM_SIZE, 1);
|
||||
s_tx_stream = xStreamBufferCreate(SERIAL_TX_STREAM_SIZE, 1);
|
||||
s_rx_stream_storage = heap_caps_calloc_prefer(
|
||||
1U, SERIAL_RX_STREAM_STORAGE_SIZE, 2,
|
||||
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,
|
||||
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||
s_tx_stream_storage = heap_caps_calloc_prefer(
|
||||
1U, SERIAL_TX_STREAM_STORAGE_SIZE, 2,
|
||||
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,
|
||||
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||
if (s_rx_stream_storage != NULL) {
|
||||
s_rx_stream = xStreamBufferCreateStatic(
|
||||
SERIAL_RX_STREAM_STORAGE_SIZE, 1U, s_rx_stream_storage,
|
||||
&s_rx_stream_control);
|
||||
}
|
||||
if (s_tx_stream_storage != NULL) {
|
||||
s_tx_stream = xStreamBufferCreateStatic(
|
||||
SERIAL_TX_STREAM_STORAGE_SIZE, 1U, s_tx_stream_storage,
|
||||
&s_tx_stream_control);
|
||||
}
|
||||
if (s_state_mutex == NULL || s_task_stopped == NULL ||
|
||||
s_rx_stream == NULL || s_tx_stream == NULL) {
|
||||
if (s_state_mutex != NULL) {
|
||||
@@ -488,10 +511,14 @@ esp_err_t serial_service_init(const serial_config_t *initial_config)
|
||||
if (s_tx_stream != NULL) {
|
||||
vStreamBufferDelete(s_tx_stream);
|
||||
}
|
||||
heap_caps_free(s_rx_stream_storage);
|
||||
heap_caps_free(s_tx_stream_storage);
|
||||
s_state_mutex = NULL;
|
||||
s_task_stopped = NULL;
|
||||
s_rx_stream = NULL;
|
||||
s_tx_stream = NULL;
|
||||
s_rx_stream_storage = NULL;
|
||||
s_tx_stream_storage = NULL;
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
|
||||
+132
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_heap_caps.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
@@ -10,12 +11,16 @@
|
||||
#include "serial_service.h"
|
||||
|
||||
#define SESSION_BROKER_RX_CHUNK_SIZE 256U
|
||||
#define SESSION_BROKER_ACTIVE_BURST_BYTES (8U * SESSION_BROKER_RX_CHUNK_SIZE)
|
||||
#define SESSION_BROKER_TASK_STACK_SIZE 4096U
|
||||
#define SESSION_BROKER_TASK_PRIORITY 9U
|
||||
#define SESSION_BROKER_IDLE_POLL_MS 5U
|
||||
#define SESSION_BROKER_SLOT_BITS 3U
|
||||
#define SESSION_BROKER_SLOT_MASK ((1U << SESSION_BROKER_SLOT_BITS) - 1U)
|
||||
#define SESSION_BROKER_MAX_GENERATION (UINT32_MAX >> SESSION_BROKER_SLOT_BITS)
|
||||
#define SESSION_BROKER_OUTPUT_STORAGE_SIZE (SESSION_BROKER_OUTPUT_SIZE + 1U)
|
||||
#define SESSION_BROKER_EVENT_STORAGE_SIZE \
|
||||
(SESSION_BROKER_EVENT_QUEUE_LENGTH * sizeof(session_broker_event_t))
|
||||
|
||||
_Static_assert(
|
||||
SESSION_BROKER_MAX_CLIENTS == (1U << SESSION_BROKER_SLOT_BITS),
|
||||
@@ -24,6 +29,10 @@ _Static_assert(
|
||||
typedef struct {
|
||||
StreamBufferHandle_t output;
|
||||
QueueHandle_t events;
|
||||
StaticStreamBuffer_t output_control;
|
||||
StaticQueue_t events_control;
|
||||
uint8_t *output_storage;
|
||||
uint8_t *event_storage;
|
||||
session_broker_client_id_t id;
|
||||
uint32_t generation;
|
||||
session_broker_client_type_t type;
|
||||
@@ -152,6 +161,7 @@ static void broker_task(void *context)
|
||||
{
|
||||
(void)context;
|
||||
uint8_t data[SESSION_BROKER_RX_CHUNK_SIZE];
|
||||
size_t active_burst_bytes = 0U;
|
||||
|
||||
for (;;) {
|
||||
/*
|
||||
@@ -167,7 +177,20 @@ static void broker_task(void *context)
|
||||
xSemaphoreGive(s_mutex);
|
||||
|
||||
if (received == 0U) {
|
||||
active_burst_bytes = 0U;
|
||||
vTaskDelay(milliseconds_to_ticks(SESSION_BROKER_IDLE_POLL_MS));
|
||||
} else {
|
||||
active_burst_bytes += received;
|
||||
if (active_burst_bytes >= SESSION_BROKER_ACTIVE_BURST_BYTES) {
|
||||
active_burst_bytes = 0U;
|
||||
/*
|
||||
* A continuously readable UART must not make this priority-9
|
||||
* task permanently runnable. One tick after each bounded burst
|
||||
* preserves 1 Mbaud headroom while allowing idle and transports
|
||||
* to run on a loaded dual-core system.
|
||||
*/
|
||||
vTaskDelay(1U);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,6 +206,10 @@ static void cleanup_allocations(void)
|
||||
vStreamBufferDelete(s_slots[i].output);
|
||||
s_slots[i].output = NULL;
|
||||
}
|
||||
heap_caps_free(s_slots[i].event_storage);
|
||||
s_slots[i].event_storage = NULL;
|
||||
heap_caps_free(s_slots[i].output_storage);
|
||||
s_slots[i].output_storage = NULL;
|
||||
}
|
||||
if (s_mutex != NULL) {
|
||||
vSemaphoreDelete(s_mutex);
|
||||
@@ -209,9 +236,25 @@ esp_err_t session_broker_init(void)
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) {
|
||||
s_slots[i].output = xStreamBufferCreate(SESSION_BROKER_OUTPUT_SIZE, 1U);
|
||||
s_slots[i].events = xQueueCreate(SESSION_BROKER_EVENT_QUEUE_LENGTH,
|
||||
sizeof(session_broker_event_t));
|
||||
s_slots[i].output_storage = heap_caps_calloc_prefer(
|
||||
1U, SESSION_BROKER_OUTPUT_STORAGE_SIZE, 2,
|
||||
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,
|
||||
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||
s_slots[i].event_storage = heap_caps_calloc_prefer(
|
||||
1U, SESSION_BROKER_EVENT_STORAGE_SIZE, 2,
|
||||
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT,
|
||||
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||
if (s_slots[i].output_storage != NULL) {
|
||||
s_slots[i].output = xStreamBufferCreateStatic(
|
||||
SESSION_BROKER_OUTPUT_STORAGE_SIZE, 1U,
|
||||
s_slots[i].output_storage, &s_slots[i].output_control);
|
||||
}
|
||||
if (s_slots[i].event_storage != NULL) {
|
||||
s_slots[i].events = xQueueCreateStatic(
|
||||
SESSION_BROKER_EVENT_QUEUE_LENGTH,
|
||||
sizeof(session_broker_event_t),
|
||||
s_slots[i].event_storage, &s_slots[i].events_control);
|
||||
}
|
||||
if (s_slots[i].output == NULL || s_slots[i].events == NULL) {
|
||||
cleanup_allocations();
|
||||
return ESP_ERR_NO_MEM;
|
||||
@@ -475,6 +518,92 @@ esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_compare_exchange_writer(
|
||||
session_broker_client_id_t expected_writer_id,
|
||||
session_broker_client_id_t target_client_id)
|
||||
{
|
||||
if (!s_initialized) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (target_client_id == SESSION_BROKER_NO_CLIENT) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
if (s_writer_id != expected_writer_id) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
session_broker_slot_t *new_writer = find_slot_locked(target_client_id);
|
||||
if (new_writer == NULL) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
if (expected_writer_id == target_client_id) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
session_broker_slot_t *old_writer =
|
||||
find_slot_locked(expected_writer_id);
|
||||
if (expected_writer_id != SESSION_BROKER_NO_CLIENT && old_writer == NULL) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
s_writer_id = target_client_id;
|
||||
if (old_writer != NULL) {
|
||||
++old_writer->counters.writer_revocations;
|
||||
++old_writer->counters.writer_changes;
|
||||
++s_counters.writer_revocations;
|
||||
}
|
||||
++new_writer->counters.writer_grants;
|
||||
++new_writer->counters.writer_changes;
|
||||
++s_counters.writer_grants;
|
||||
++s_counters.writer_changes;
|
||||
|
||||
if (old_writer != NULL) {
|
||||
broadcast_event_locked(SESSION_BROKER_EVENT_WRITER_REVOKED,
|
||||
expected_writer_id, s_writer_id);
|
||||
}
|
||||
broadcast_event_locked(SESSION_BROKER_EVENT_WRITER_GRANTED,
|
||||
target_client_id, s_writer_id);
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_force_release_writer(
|
||||
session_broker_client_id_t expected_writer_id)
|
||||
{
|
||||
if (!s_initialized) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (expected_writer_id == SESSION_BROKER_NO_CLIENT) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
if (s_writer_id != expected_writer_id) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
session_broker_slot_t *writer = find_slot_locked(expected_writer_id);
|
||||
if (writer == NULL) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
s_writer_id = SESSION_BROKER_NO_CLIENT;
|
||||
++writer->counters.writer_revocations;
|
||||
++writer->counters.writer_changes;
|
||||
++s_counters.writer_revocations;
|
||||
++s_counters.writer_changes;
|
||||
broadcast_event_locked(SESSION_BROKER_EVENT_WRITER_REVOKED,
|
||||
expected_writer_id, s_writer_id);
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
session_broker_client_id_t session_broker_get_writer_id(void)
|
||||
{
|
||||
if (!s_initialized) {
|
||||
|
||||
@@ -130,6 +130,16 @@ esp_err_t session_broker_disconnect(session_broker_client_id_t client_id);
|
||||
esp_err_t session_broker_request_writer(session_broker_client_id_t client_id);
|
||||
esp_err_t session_broker_release_writer(session_broker_client_id_t client_id);
|
||||
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id);
|
||||
/*
|
||||
* Atomically replace exactly the expected current writer with a connected target.
|
||||
* expected_writer_id may be zero; target_client_id must identify a live client.
|
||||
* A changed current writer returns ESP_ERR_INVALID_STATE without altering the lease.
|
||||
*/
|
||||
esp_err_t session_broker_compare_exchange_writer(
|
||||
session_broker_client_id_t expected_writer_id,
|
||||
session_broker_client_id_t target_client_id);
|
||||
/* Revoke only if the expected client still owns the writer lease. */
|
||||
esp_err_t session_broker_force_release_writer(session_broker_client_id_t expected_writer_id);
|
||||
session_broker_client_id_t session_broker_get_writer_id(void);
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* UART0 SSH lifecycle, sessions, counters, and host-key recovery commands. */
|
||||
|
||||
#include "ssh_console.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_console.h"
|
||||
#include "mbedtls/base64.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_security.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "user_database.h"
|
||||
|
||||
static void print_usage(void)
|
||||
{
|
||||
printf("Usage:\n");
|
||||
printf(" ssh status|start|stop|sessions\n");
|
||||
printf(" ssh disconnect <session-id>\n");
|
||||
printf(" ssh counters|clear-counters\n");
|
||||
printf(" ssh host-key info\n");
|
||||
printf(" ssh host-key rotate --force\n");
|
||||
printf(" ssh reset --force\n");
|
||||
}
|
||||
|
||||
static const char *state_name(ssh_transport_session_state_t state)
|
||||
{
|
||||
switch (state) {
|
||||
case SSH_TRANSPORT_SESSION_FREE:
|
||||
return "free";
|
||||
case SSH_TRANSPORT_SESSION_HANDSHAKE:
|
||||
return "handshake";
|
||||
case SSH_TRANSPORT_SESSION_ACTIVE:
|
||||
return "active";
|
||||
case SSH_TRANSPORT_SESSION_CLOSING:
|
||||
return "closing";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
static const char *auth_method_name(user_auth_method_t method)
|
||||
{
|
||||
return method == USER_AUTH_METHOD_PASSWORD
|
||||
? "password"
|
||||
: 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)
|
||||
{
|
||||
printf("SSH sessions: active=%" PRIu32 "/%u\n",
|
||||
snapshot->active_sessions, SSH_TRANSPORT_MAX_SESSIONS);
|
||||
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
const ssh_transport_session_snapshot_t *session = &snapshot->sessions[index];
|
||||
if (!session->active) {
|
||||
continue;
|
||||
}
|
||||
printf(" id=%" PRIu32 " slot=%u peer=%s state=%s auth=%s account=%s"
|
||||
" user-role=%s method=%s route=%s broker=%" PRIu32
|
||||
" 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,
|
||||
state_name(session->state), session->authenticated ? "yes" : "no",
|
||||
session->principal_valid ? session->username : "-",
|
||||
session->principal_valid ? 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->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE
|
||||
? "n/a"
|
||||
: (session->broker_client_id == SESSION_BROKER_NO_CLIENT
|
||||
? "unattached"
|
||||
: (session->writer ? "writer" : "observer")),
|
||||
session->admin_command_pending ? "running" : "idle",
|
||||
session->admin_output_pending,
|
||||
session->rx_pending ? "yes" : "no",
|
||||
session->tx_pending ? "yes" : "no",
|
||||
session->close_requested ? "yes" : "no");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int show_status(bool sessions_only)
|
||||
{
|
||||
ssh_transport_snapshot_t snapshot;
|
||||
esp_err_t error = ssh_transport_get_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
printf("SSH runtime unavailable: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
if (!sessions_only) {
|
||||
printf("SSH: initialized=%s running=%s transitioning=%s port=%u last-error=%s\n",
|
||||
snapshot.initialized ? "yes" : "no",
|
||||
snapshot.running ? "yes" : "no",
|
||||
snapshot.transitioning ? "yes" : "no",
|
||||
(unsigned int)snapshot.port,
|
||||
esp_err_to_name(snapshot.last_error));
|
||||
printf("Authentication: role-based password and SSH public key via user database\n");
|
||||
printf("Admission: shell/PTY only; exec, subsystem, forwarding, SCP, and SFTP disabled\n");
|
||||
printf("Owner task: core=%" PRId32 " stack=%" PRIu32
|
||||
" minimum-free=%" PRIu32 " bytes\n",
|
||||
snapshot.task_core_id, snapshot.task_stack_size,
|
||||
snapshot.task_stack_free_minimum);
|
||||
}
|
||||
return print_sessions(&snapshot);
|
||||
}
|
||||
|
||||
static int show_counters(void)
|
||||
{
|
||||
ssh_transport_snapshot_t snapshot;
|
||||
esp_err_t error = ssh_transport_get_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read SSH counters: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
const ssh_transport_counters_t *counter = &snapshot.counters;
|
||||
printf("Lifecycle: starts=%" PRIu64 " start-failures=%" PRIu64
|
||||
" stops=%" PRIu64 " tcp-connect=%" PRIu64
|
||||
" capacity-reject=%" PRIu64 "\n",
|
||||
counter->starts, counter->start_failures, counter->stops,
|
||||
counter->tcp_connections, counter->capacity_rejections);
|
||||
printf("Handshake: success=%" PRIu64 " failures=%" PRIu64
|
||||
" timeouts=%" PRIu64 " auth-attempts=%" PRIu64
|
||||
" auth-failures=%" PRIu64 " request-rejects=%" PRIu64 "\n",
|
||||
counter->handshake_successes, counter->handshake_failures,
|
||||
counter->handshake_timeouts, counter->authentication_attempts,
|
||||
counter->authentication_failures, counter->request_rejections);
|
||||
printf("Broker: connect=%" PRIu64 " failures=%" PRIu64
|
||||
" disconnect=%" PRIu64 " writer-requests=%" PRIu64
|
||||
" grants=%" PRIu64 " denials=%" PRIu64
|
||||
" revocations=%" PRIu64 "\n",
|
||||
counter->broker_connections, counter->broker_failures,
|
||||
counter->disconnections, counter->writer_requests,
|
||||
counter->writer_grants, counter->writer_denials,
|
||||
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
|
||||
" rejected=%" PRIu64 " tx=%" PRIu64
|
||||
" io-failures=%" PRIu64 " session-revocations=%" PRIu64 "\n",
|
||||
counter->rx_bytes, counter->rx_accepted_bytes,
|
||||
counter->rx_rejected_bytes, counter->tx_bytes,
|
||||
counter->io_failures, counter->session_revocations);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int show_host_key(void)
|
||||
{
|
||||
ssh_security_metadata_t metadata;
|
||||
esp_err_t error = ssh_security_get_metadata(&metadata);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read SSH host-key information: %s\n", esp_err_to_name(error));
|
||||
printf("Use 'ssh reset --force' to replace incompatible or corrupt material.\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
unsigned char encoded[48] = {0};
|
||||
size_t encoded_length = 0U;
|
||||
int result = mbedtls_base64_encode(encoded, sizeof(encoded), &encoded_length,
|
||||
metadata.sha256_fingerprint,
|
||||
sizeof(metadata.sha256_fingerprint));
|
||||
if (result != 0 || encoded_length >= sizeof(encoded)) {
|
||||
secure_wipe(encoded, sizeof(encoded));
|
||||
printf("Could not encode SSH host-key fingerprint.\n");
|
||||
return 1;
|
||||
}
|
||||
while (encoded_length > 0U && encoded[encoded_length - 1U] == '=') {
|
||||
--encoded_length;
|
||||
}
|
||||
encoded[encoded_length] = '\0';
|
||||
printf("SSH host key: generation=%" PRIu32 " type=%s curve=%s\n",
|
||||
metadata.generation, SSH_SECURITY_KEY_TYPE, SSH_SECURITY_CURVE_NAME);
|
||||
printf("OpenSSH SHA-256 fingerprint: SHA256:%s\n", encoded);
|
||||
secure_wipe(encoded, sizeof(encoded));
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool parse_session_id(const char *text, uint32_t *session_id)
|
||||
{
|
||||
if (text == NULL || text[0] == '\0' || session_id == NULL) {
|
||||
return false;
|
||||
}
|
||||
errno = 0;
|
||||
char *end = NULL;
|
||||
unsigned long value = strtoul(text, &end, 10);
|
||||
if (errno != 0 || end == text || *end != '\0' || value == 0UL ||
|
||||
value > UINT32_MAX) {
|
||||
return false;
|
||||
}
|
||||
*session_id = (uint32_t)value;
|
||||
return true;
|
||||
}
|
||||
|
||||
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};
|
||||
bool had_before = ssh_security_get_metadata(&before) == ESP_OK;
|
||||
esp_err_t error = ssh_transport_replace_host_key(reset);
|
||||
|
||||
ssh_security_metadata_t after = {0};
|
||||
bool have_after = ssh_security_get_metadata(&after) == ESP_OK;
|
||||
bool replaced = have_after && (!had_before || after.generation != before.generation);
|
||||
if (error != ESP_OK) {
|
||||
if (replaced) {
|
||||
printf("SSH host key was persisted, but the transport could not complete its restart: %s\n",
|
||||
esp_err_to_name(error));
|
||||
} else {
|
||||
printf("Could not %s SSH host key: %s\n",
|
||||
reset ? "reset" : "rotate", esp_err_to_name(error));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("SSH host key replaced and persisted; existing clients must verify the new fingerprint.\n");
|
||||
return show_host_key();
|
||||
}
|
||||
|
||||
static bool force_is_present(int argc, char **argv, int expected_argc)
|
||||
{
|
||||
return argc == expected_argc && strcmp(argv[expected_argc - 1], "--force") == 0;
|
||||
}
|
||||
|
||||
static int command_ssh(int argc, char **argv)
|
||||
{
|
||||
if (argc == 1 || (argc == 2 && strcmp(argv[1], "help") == 0)) {
|
||||
print_usage();
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "status") == 0) {
|
||||
return show_status(false);
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "sessions") == 0) {
|
||||
return show_status(true);
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "start") == 0) {
|
||||
esp_err_t error = ssh_transport_start();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not start SSH: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("SSH started on TCP port %u.\n", SSH_TRANSPORT_PORT);
|
||||
return 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();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not stop SSH: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("SSH stopped.\n");
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "counters") == 0) {
|
||||
return show_counters();
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "clear-counters") == 0) {
|
||||
esp_err_t error = ssh_transport_clear_counters();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not clear SSH counters: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("SSH counters cleared.\n");
|
||||
return 0;
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "disconnect") == 0) {
|
||||
uint32_t session_id = 0U;
|
||||
if (!parse_session_id(argv[2], &session_id)) {
|
||||
printf("Session ID must be a nonzero decimal integer.\n");
|
||||
return 1;
|
||||
}
|
||||
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_SSH_DISCONNECT, session_id);
|
||||
}
|
||||
} else {
|
||||
error = ssh_transport_disconnect(session_id);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not disconnect SSH session: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("SSH session %" PRIu32 " scheduled for disconnect.\n", session_id);
|
||||
return 0;
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "host-key") == 0 &&
|
||||
strcmp(argv[2], "info") == 0) {
|
||||
return show_host_key();
|
||||
}
|
||||
if (argc >= 3 && strcmp(argv[1], "host-key") == 0 &&
|
||||
strcmp(argv[2], "rotate") == 0) {
|
||||
if (!force_is_present(argc, argv, 4)) {
|
||||
printf("Host-key rotation requires: ssh host-key rotate --force\n");
|
||||
return 1;
|
||||
}
|
||||
return replace_host_key(false);
|
||||
}
|
||||
if (strcmp(argv[1], "reset") == 0) {
|
||||
if (!force_is_present(argc, argv, 3)) {
|
||||
printf("Host-key recovery requires: ssh reset --force\n");
|
||||
return 1;
|
||||
}
|
||||
return replace_host_key(true);
|
||||
}
|
||||
|
||||
print_usage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
esp_err_t ssh_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
.command = "ssh",
|
||||
.help = "Manage authenticated SSH serial transport and host identity",
|
||||
.hint = NULL,
|
||||
.func = &command_ssh,
|
||||
.argtable = NULL,
|
||||
};
|
||||
return esp_console_cmd_register(&command);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* UART0 administration commands for the SSH serial transport. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
esp_err_t ssh_console_register_commands(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,470 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Versioned NVS storage and validation for the SSH ECDSA P-256 host key. */
|
||||
|
||||
#include "ssh_security.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "mbedtls/ecp.h"
|
||||
#include "mbedtls/pk.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "nvs.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
#define SSH_SECURITY_SCHEMA_VERSION 1U
|
||||
#define SSH_SECURITY_BLOB_SIZE 312U
|
||||
#define SSH_PUBLIC_POINT_LENGTH 65U
|
||||
#define SSH_PUBLIC_BLOB_LENGTH \
|
||||
(4U + (sizeof(SSH_SECURITY_KEY_TYPE) - 1U) + \
|
||||
4U + (sizeof(SSH_SECURITY_CURVE_NAME) - 1U) + \
|
||||
4U + SSH_PUBLIC_POINT_LENGTH)
|
||||
|
||||
typedef struct {
|
||||
uint32_t schema_version;
|
||||
uint16_t blob_size;
|
||||
uint16_t private_key_length;
|
||||
uint32_t generation;
|
||||
uint8_t private_key_der[SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY];
|
||||
uint8_t sha256_fingerprint[SSH_SECURITY_SHA256_LENGTH];
|
||||
uint8_t reserved[12];
|
||||
} ssh_security_blob_t;
|
||||
|
||||
_Static_assert(offsetof(ssh_security_blob_t, private_key_der) == 12U,
|
||||
"SSH security schema offsets changed");
|
||||
_Static_assert(offsetof(ssh_security_blob_t, sha256_fingerprint) == 268U,
|
||||
"SSH fingerprint offset changed");
|
||||
_Static_assert(sizeof(ssh_security_blob_t) == SSH_SECURITY_BLOB_SIZE,
|
||||
"SSH security schema size changed");
|
||||
|
||||
static SemaphoreHandle_t s_security_mutex;
|
||||
static portMUX_TYPE s_mutex_init_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static bool s_mutex_creating;
|
||||
static ssh_security_blob_t s_material;
|
||||
static bool s_material_ready;
|
||||
static ssh_security_load_result_t s_load_result;
|
||||
|
||||
static bool bytes_are_zero(const uint8_t *data, size_t size)
|
||||
{
|
||||
for (size_t index = 0U; index < size; ++index) {
|
||||
if (data[index] != 0U) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool constant_time_equal(const uint8_t *left, const uint8_t *right,
|
||||
size_t size)
|
||||
{
|
||||
uint8_t difference = 0U;
|
||||
for (size_t index = 0U; index < size; ++index) {
|
||||
difference |= left[index] ^ right[index];
|
||||
}
|
||||
return difference == 0U;
|
||||
}
|
||||
|
||||
static esp_err_t ensure_mutex(void)
|
||||
{
|
||||
for (;;) {
|
||||
bool create = false;
|
||||
taskENTER_CRITICAL(&s_mutex_init_lock);
|
||||
if (s_security_mutex != NULL) {
|
||||
taskEXIT_CRITICAL(&s_mutex_init_lock);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (!s_mutex_creating) {
|
||||
s_mutex_creating = true;
|
||||
create = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_mutex_init_lock);
|
||||
|
||||
if (create) {
|
||||
SemaphoreHandle_t mutex = xSemaphoreCreateMutex();
|
||||
taskENTER_CRITICAL(&s_mutex_init_lock);
|
||||
s_security_mutex = mutex;
|
||||
s_mutex_creating = false;
|
||||
taskEXIT_CRITICAL(&s_mutex_init_lock);
|
||||
return mutex != NULL ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
}
|
||||
vTaskDelay(1U);
|
||||
}
|
||||
}
|
||||
|
||||
static void write_u32_be(uint8_t output[4], uint32_t value)
|
||||
{
|
||||
output[0] = (uint8_t)(value >> 24U);
|
||||
output[1] = (uint8_t)(value >> 16U);
|
||||
output[2] = (uint8_t)(value >> 8U);
|
||||
output[3] = (uint8_t)value;
|
||||
}
|
||||
|
||||
static size_t append_ssh_string(uint8_t *output, size_t offset,
|
||||
const uint8_t *value, size_t value_length)
|
||||
{
|
||||
write_u32_be(output + offset, (uint32_t)value_length);
|
||||
offset += 4U;
|
||||
memcpy(output + offset, value, value_length);
|
||||
return offset + value_length;
|
||||
}
|
||||
|
||||
static esp_err_t fingerprint_key(const mbedtls_pk_context *key,
|
||||
uint8_t fingerprint[SSH_SECURITY_SHA256_LENGTH])
|
||||
{
|
||||
const mbedtls_ecp_keypair *ec = mbedtls_pk_ec(*key);
|
||||
if (ec == NULL ||
|
||||
mbedtls_ecp_keypair_get_group_id(ec) != MBEDTLS_ECP_DP_SECP256R1) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
|
||||
uint8_t point[SSH_PUBLIC_POINT_LENGTH] = {0};
|
||||
size_t point_length = 0U;
|
||||
int result = mbedtls_ecp_point_write_binary(
|
||||
&ec->MBEDTLS_PRIVATE(grp), &ec->MBEDTLS_PRIVATE(Q),
|
||||
MBEDTLS_ECP_PF_UNCOMPRESSED,
|
||||
&point_length, point, sizeof(point));
|
||||
if (result != 0 || point_length != sizeof(point)) {
|
||||
secure_wipe(point, sizeof(point));
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
|
||||
uint8_t public_blob[SSH_PUBLIC_BLOB_LENGTH] = {0};
|
||||
size_t offset = append_ssh_string(
|
||||
public_blob, 0U, (const uint8_t *)SSH_SECURITY_KEY_TYPE,
|
||||
sizeof(SSH_SECURITY_KEY_TYPE) - 1U);
|
||||
offset = append_ssh_string(
|
||||
public_blob, offset, (const uint8_t *)SSH_SECURITY_CURVE_NAME,
|
||||
sizeof(SSH_SECURITY_CURVE_NAME) - 1U);
|
||||
offset = append_ssh_string(public_blob, offset, point, point_length);
|
||||
|
||||
result = offset == sizeof(public_blob)
|
||||
? mbedtls_sha256(public_blob, offset, fingerprint, 0)
|
||||
: -1;
|
||||
secure_wipe(public_blob, sizeof(public_blob));
|
||||
secure_wipe(point, sizeof(point));
|
||||
return result == 0 ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
|
||||
static esp_err_t parse_and_fingerprint(const ssh_security_blob_t *blob,
|
||||
uint8_t fingerprint[SSH_SECURITY_SHA256_LENGTH])
|
||||
{
|
||||
mbedtls_pk_context key;
|
||||
mbedtls_pk_init(&key);
|
||||
int result = mbedtls_pk_parse_key(&key,
|
||||
blob->private_key_der,
|
||||
blob->private_key_length,
|
||||
NULL, 0U,
|
||||
secure_random_mbedtls, NULL);
|
||||
esp_err_t error = ESP_ERR_INVALID_RESPONSE;
|
||||
if (result == 0 && mbedtls_pk_get_type(&key) == MBEDTLS_PK_ECKEY) {
|
||||
const mbedtls_ecp_keypair *ec = mbedtls_pk_ec(key);
|
||||
if (ec != NULL &&
|
||||
mbedtls_ecp_keypair_get_group_id(ec) == MBEDTLS_ECP_DP_SECP256R1 &&
|
||||
mbedtls_ecp_check_privkey(&ec->MBEDTLS_PRIVATE(grp),
|
||||
&ec->MBEDTLS_PRIVATE(d)) == 0 &&
|
||||
mbedtls_ecp_check_pubkey(&ec->MBEDTLS_PRIVATE(grp),
|
||||
&ec->MBEDTLS_PRIVATE(Q)) == 0 &&
|
||||
mbedtls_pk_check_pair(&key, &key,
|
||||
secure_random_mbedtls, NULL) == 0) {
|
||||
error = fingerprint_key(&key, fingerprint);
|
||||
}
|
||||
}
|
||||
mbedtls_pk_free(&key);
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t validate_blob(const ssh_security_blob_t *blob)
|
||||
{
|
||||
if (blob == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (blob->schema_version != SSH_SECURITY_SCHEMA_VERSION ||
|
||||
blob->blob_size != SSH_SECURITY_BLOB_SIZE) {
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
if (blob->generation == 0U || blob->private_key_length == 0U ||
|
||||
blob->private_key_length > sizeof(blob->private_key_der) ||
|
||||
!bytes_are_zero(blob->private_key_der + blob->private_key_length,
|
||||
sizeof(blob->private_key_der) - blob->private_key_length) ||
|
||||
!bytes_are_zero(blob->reserved, sizeof(blob->reserved))) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
|
||||
uint8_t fingerprint[SSH_SECURITY_SHA256_LENGTH] = {0};
|
||||
esp_err_t error = parse_and_fingerprint(blob, fingerprint);
|
||||
if (error == ESP_OK &&
|
||||
!constant_time_equal(fingerprint, blob->sha256_fingerprint,
|
||||
sizeof(fingerprint))) {
|
||||
error = ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
secure_wipe(fingerprint, sizeof(fingerprint));
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t generate_blob(ssh_security_blob_t *blob, uint32_t generation)
|
||||
{
|
||||
memset(blob, 0, sizeof(*blob));
|
||||
blob->schema_version = SSH_SECURITY_SCHEMA_VERSION;
|
||||
blob->blob_size = SSH_SECURITY_BLOB_SIZE;
|
||||
blob->generation = generation;
|
||||
|
||||
mbedtls_pk_context key;
|
||||
mbedtls_pk_init(&key);
|
||||
int result = mbedtls_pk_setup(&key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY));
|
||||
esp_err_t error = result == 0 ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
if (error == ESP_OK) {
|
||||
result = mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1,
|
||||
mbedtls_pk_ec(key),
|
||||
secure_random_mbedtls, NULL);
|
||||
error = result == 0 ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
result = mbedtls_pk_write_key_der(&key, blob->private_key_der,
|
||||
sizeof(blob->private_key_der));
|
||||
if (result <= 0 || (size_t)result > sizeof(blob->private_key_der)) {
|
||||
error = ESP_FAIL;
|
||||
} else {
|
||||
size_t length = (size_t)result;
|
||||
memmove(blob->private_key_der,
|
||||
blob->private_key_der + sizeof(blob->private_key_der) - length,
|
||||
length);
|
||||
memset(blob->private_key_der + length, 0,
|
||||
sizeof(blob->private_key_der) - length);
|
||||
blob->private_key_length = (uint16_t)length;
|
||||
}
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = fingerprint_key(&key, blob->sha256_fingerprint);
|
||||
}
|
||||
mbedtls_pk_free(&key);
|
||||
if (error == ESP_OK) {
|
||||
error = validate_blob(blob);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t save_blob(const ssh_security_blob_t *blob)
|
||||
{
|
||||
esp_err_t error = validate_blob(blob);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
error = nvs_open(SSH_SECURITY_NVS_NAMESPACE, NVS_READWRITE, &handle);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = nvs_set_blob(handle, SSH_SECURITY_NVS_BLOB_KEY, blob, sizeof(*blob));
|
||||
if (error == ESP_OK) {
|
||||
error = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t load_blob(ssh_security_blob_t *blob, bool *missing)
|
||||
{
|
||||
*missing = false;
|
||||
nvs_handle_t handle;
|
||||
esp_err_t error = nvs_open(SSH_SECURITY_NVS_NAMESPACE, NVS_READONLY, &handle);
|
||||
if (error == ESP_ERR_NVS_NOT_FOUND) {
|
||||
*missing = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
size_t size = 0U;
|
||||
error = nvs_get_blob(handle, SSH_SECURITY_NVS_BLOB_KEY, NULL, &size);
|
||||
if (error == ESP_ERR_NVS_NOT_FOUND) {
|
||||
*missing = true;
|
||||
nvs_close(handle);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (error == ESP_ERR_NVS_TYPE_MISMATCH) {
|
||||
nvs_close(handle);
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
nvs_close(handle);
|
||||
return error;
|
||||
}
|
||||
if (size != sizeof(*blob)) {
|
||||
nvs_close(handle);
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
|
||||
memset(blob, 0, sizeof(*blob));
|
||||
error = nvs_get_blob(handle, SSH_SECURITY_NVS_BLOB_KEY, blob, &size);
|
||||
nvs_close(handle);
|
||||
if (error == ESP_ERR_NVS_INVALID_LENGTH) {
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
return error == ESP_OK ? validate_blob(blob) : error;
|
||||
}
|
||||
|
||||
static void install_blob(const ssh_security_blob_t *candidate)
|
||||
{
|
||||
secure_wipe(&s_material, sizeof(s_material));
|
||||
s_material = *candidate;
|
||||
s_material_ready = true;
|
||||
s_load_result = SSH_SECURITY_LOAD_STORED;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_init(ssh_security_load_result_t *load_result)
|
||||
{
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = ensure_mutex();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
if (s_material_ready) {
|
||||
if (load_result != NULL) {
|
||||
*load_result = s_load_result;
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
ssh_security_blob_t candidate;
|
||||
bool missing = false;
|
||||
error = load_blob(&candidate, &missing);
|
||||
if (error == ESP_OK && missing) {
|
||||
error = generate_blob(&candidate, 1U);
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
s_material = candidate;
|
||||
s_material_ready = true;
|
||||
s_load_result = missing ? SSH_SECURITY_LOAD_GENERATED_MISSING
|
||||
: SSH_SECURITY_LOAD_STORED;
|
||||
if (load_result != NULL) {
|
||||
*load_result = s_load_result;
|
||||
}
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_copy_private_key(uint8_t *output, size_t capacity,
|
||||
size_t *output_length)
|
||||
{
|
||||
if (output_length == NULL || (output == NULL && capacity != 0U)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_security_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
if (s_material_ready) {
|
||||
*output_length = s_material.private_key_length;
|
||||
if (output == NULL) {
|
||||
error = capacity == 0U ? ESP_OK : ESP_ERR_INVALID_ARG;
|
||||
} else if (capacity < s_material.private_key_length) {
|
||||
error = ESP_ERR_INVALID_SIZE;
|
||||
} else {
|
||||
memcpy(output, s_material.private_key_der,
|
||||
s_material.private_key_length);
|
||||
error = ESP_OK;
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata)
|
||||
{
|
||||
if (metadata == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_security_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
if (s_material_ready) {
|
||||
memset(metadata, 0, sizeof(*metadata));
|
||||
metadata->generation = s_material.generation;
|
||||
memcpy(metadata->sha256_fingerprint, s_material.sha256_fingerprint,
|
||||
sizeof(metadata->sha256_fingerprint));
|
||||
error = ESP_OK;
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_rotate(void)
|
||||
{
|
||||
if (s_security_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
ssh_security_blob_t candidate;
|
||||
memset(&candidate, 0, sizeof(candidate));
|
||||
if (s_material_ready && s_material.generation != UINT32_MAX) {
|
||||
error = generate_blob(&candidate, s_material.generation + 1U);
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_blob(&candidate);
|
||||
}
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_reset(void)
|
||||
{
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = ensure_mutex();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
uint32_t generation = 1U;
|
||||
if (s_material_ready) {
|
||||
if (s_material.generation == UINT32_MAX) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
generation = s_material.generation + 1U;
|
||||
}
|
||||
|
||||
ssh_security_blob_t candidate;
|
||||
error = generate_blob(&candidate, generation);
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_blob(&candidate);
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Persistent SSH host identity, separate from the HTTPS certificate key. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define SSH_SECURITY_NVS_NAMESPACE "ssh_sec"
|
||||
#define SSH_SECURITY_NVS_BLOB_KEY "material"
|
||||
#define SSH_SECURITY_PRIVATE_KEY_DER_CAPACITY 256U
|
||||
#define SSH_SECURITY_SHA256_LENGTH 32U
|
||||
|
||||
#define SSH_SECURITY_KEY_TYPE "ecdsa-sha2-nistp256"
|
||||
#define SSH_SECURITY_CURVE_NAME "nistp256"
|
||||
|
||||
typedef enum {
|
||||
SSH_SECURITY_LOAD_STORED = 0,
|
||||
SSH_SECURITY_LOAD_GENERATED_MISSING = 1,
|
||||
} ssh_security_load_result_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t generation;
|
||||
uint8_t sha256_fingerprint[SSH_SECURITY_SHA256_LENGTH];
|
||||
} ssh_security_metadata_t;
|
||||
|
||||
/* NVS and secure_random must be ready. Existing malformed material is not replaced. */
|
||||
esp_err_t ssh_security_init(ssh_security_load_result_t *load_result);
|
||||
|
||||
/* Query with output NULL/capacity zero; the required length is always returned. */
|
||||
esp_err_t ssh_security_copy_private_key(uint8_t *output, size_t capacity,
|
||||
size_t *output_length);
|
||||
esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata);
|
||||
|
||||
/* Caller must stop SSH first. Rotation requires valid live material; reset replaces any stored state. */
|
||||
esp_err_t ssh_security_rotate(void);
|
||||
esp_err_t ssh_security_reset(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+1665
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Authenticated, bounded wolfSSH transport for the serial session broker. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "session_broker.h"
|
||||
#include "user_database.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define SSH_TRANSPORT_PORT 22U
|
||||
#define SSH_TRANSPORT_MAX_SESSIONS 2U
|
||||
#define SSH_TRANSPORT_IO_BUFFER_SIZE 512U
|
||||
#define SSH_TRANSPORT_HANDSHAKE_TIMEOUT_SECONDS 15U
|
||||
|
||||
typedef enum {
|
||||
SSH_TRANSPORT_SESSION_FREE = 0,
|
||||
SSH_TRANSPORT_SESSION_HANDSHAKE,
|
||||
SSH_TRANSPORT_SESSION_ACTIVE,
|
||||
SSH_TRANSPORT_SESSION_CLOSING,
|
||||
} 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 {
|
||||
uint64_t starts;
|
||||
uint64_t start_failures;
|
||||
uint64_t stops;
|
||||
uint64_t tcp_connections;
|
||||
uint64_t capacity_rejections;
|
||||
uint64_t handshake_successes;
|
||||
uint64_t handshake_failures;
|
||||
uint64_t handshake_timeouts;
|
||||
uint64_t authentication_attempts;
|
||||
uint64_t authentication_failures;
|
||||
uint64_t request_rejections;
|
||||
uint64_t broker_connections;
|
||||
uint64_t broker_failures;
|
||||
uint64_t disconnections;
|
||||
uint64_t writer_requests;
|
||||
uint64_t writer_grants;
|
||||
uint64_t writer_denials;
|
||||
uint64_t writer_revocations;
|
||||
uint64_t rx_bytes;
|
||||
uint64_t rx_accepted_bytes;
|
||||
uint64_t rx_rejected_bytes;
|
||||
uint64_t tx_bytes;
|
||||
uint64_t io_failures;
|
||||
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;
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
bool authenticated;
|
||||
bool principal_valid;
|
||||
bool writer;
|
||||
bool close_requested;
|
||||
bool rx_pending;
|
||||
bool tx_pending;
|
||||
bool admin_command_pending;
|
||||
uint32_t admin_output_pending;
|
||||
uint32_t session_id;
|
||||
uint32_t generation;
|
||||
int socket_fd;
|
||||
session_broker_client_id_t broker_client_id;
|
||||
ssh_transport_session_state_t state;
|
||||
ssh_transport_session_route_t route;
|
||||
user_role_t user_role;
|
||||
user_auth_method_t auth_method;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
||||
char peer[48];
|
||||
} ssh_transport_session_snapshot_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
bool running;
|
||||
bool transitioning;
|
||||
uint16_t port;
|
||||
esp_err_t last_error;
|
||||
uint32_t active_sessions;
|
||||
int32_t task_core_id;
|
||||
uint32_t task_stack_size;
|
||||
uint32_t task_stack_free_minimum;
|
||||
ssh_transport_session_snapshot_t sessions[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
ssh_transport_counters_t counters;
|
||||
} ssh_transport_snapshot_t;
|
||||
|
||||
/* Installs wolfCrypt RNG/PSRAM hooks and starts the sole wolfSSH owner task. */
|
||||
esp_err_t ssh_transport_init(void);
|
||||
esp_err_t ssh_transport_start(void);
|
||||
esp_err_t ssh_transport_stop(void);
|
||||
|
||||
/* Serialize stop, persistent host-key replacement, and conditional restart. */
|
||||
esp_err_t ssh_transport_replace_host_key(bool reset);
|
||||
|
||||
esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot);
|
||||
esp_err_t ssh_transport_clear_counters(void);
|
||||
|
||||
/* Close one session, one account's sessions, or every transport session. */
|
||||
esp_err_t ssh_transport_disconnect(uint32_t session_id);
|
||||
esp_err_t ssh_transport_revoke_user(const uint8_t *username,
|
||||
size_t username_length);
|
||||
esp_err_t ssh_transport_revoke_sessions(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+53
-2
@@ -3,13 +3,43 @@
|
||||
|
||||
#include "system_console.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_console.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_system.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
static void print_heap_region(const char *name, uint32_t capabilities)
|
||||
{
|
||||
printf("%s: free=%u minimum-free=%u largest-block=%u bytes\n",
|
||||
name,
|
||||
(unsigned int)heap_caps_get_free_size(capabilities),
|
||||
(unsigned int)heap_caps_get_minimum_free_size(capabilities),
|
||||
(unsigned int)heap_caps_get_largest_free_block(capabilities));
|
||||
}
|
||||
|
||||
static int command_memory(int argc, char **argv)
|
||||
{
|
||||
(void)argv;
|
||||
if (argc != 1) {
|
||||
printf("Usage: memory\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
print_heap_region("Internal 8-bit heap",
|
||||
MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||
print_heap_region("Internal DMA heap",
|
||||
MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA | MALLOC_CAP_8BIT);
|
||||
print_heap_region("External PSRAM",
|
||||
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
printf("Minimum-free is a conservative sum of each matching heap region's lifetime minimum.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int command_reboot(int argc, char **argv)
|
||||
{
|
||||
(void)argv;
|
||||
@@ -18,6 +48,15 @@ static int command_reboot(int argc, char **argv)
|
||||
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");
|
||||
fflush(stdout);
|
||||
/* Give the UART driver time to transmit the acknowledgement before reset. */
|
||||
@@ -28,12 +67,24 @@ static int command_reboot(int argc, char **argv)
|
||||
|
||||
esp_err_t system_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
const esp_console_cmd_t reboot_command = {
|
||||
.command = "reboot",
|
||||
.help = "Restart the ESP32; unsaved RAM-only configuration is lost",
|
||||
.hint = NULL,
|
||||
.func = &command_reboot,
|
||||
.argtable = NULL,
|
||||
};
|
||||
return esp_console_cmd_register(&command);
|
||||
esp_err_t error = esp_console_cmd_register(&reboot_command);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const esp_console_cmd_t memory_command = {
|
||||
.command = "memory",
|
||||
.help = "Show internal and PSRAM heap availability/low-water marks",
|
||||
.hint = NULL,
|
||||
.func = &command_memory,
|
||||
.argtable = NULL,
|
||||
};
|
||||
return esp_console_cmd_register(&memory_command);
|
||||
}
|
||||
|
||||
+34
-156
@@ -9,7 +9,6 @@
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/stream_buffer.h"
|
||||
#include "freertos/task.h"
|
||||
#include "serial_config.h"
|
||||
#include "serial_service.h"
|
||||
#include "tinyusb.h"
|
||||
#include "tinyusb_cdc_acm.h"
|
||||
@@ -47,18 +46,19 @@ static portMUX_TYPE s_state_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
static atomic_bool s_initialized;
|
||||
static atomic_bool s_initializing;
|
||||
static atomic_bool s_init_faulted;
|
||||
static atomic_uint s_usb_state;
|
||||
/* Changes on every effective CDC open/close boundary, even during one task poll. */
|
||||
static atomic_uint s_connection_generation;
|
||||
|
||||
static session_broker_client_id_t s_broker_client_id;
|
||||
static bool s_writer;
|
||||
/* Host-provided CDC line coding is diagnostic information only. */
|
||||
static usb_cdc_transport_line_coding_t s_line_coding;
|
||||
static bool s_line_coding_pending;
|
||||
static usb_cdc_transport_counters_t s_counters;
|
||||
|
||||
static const char s_language_descriptor[] = {0x09, 0x04};
|
||||
static const char s_manufacturer[] = "ESP32 Serial Tools";
|
||||
static const char s_manufacturer[] = "Commander1024";
|
||||
/* esp_tinyusb's default UTF-16 conversion accepts at most 31 characters. */
|
||||
static const char s_product[] = "ESP32 Serial Swiss Army Knife";
|
||||
static char s_serial_number[13];
|
||||
@@ -133,14 +133,11 @@ static void device_event_callback(tinyusb_event_t *event, void *arg)
|
||||
notify_transport_task();
|
||||
break;
|
||||
case TINYUSB_EVENT_DETACHED: {
|
||||
/* A new attachment must receive fresh control state and line coding. */
|
||||
/* A new attachment must receive fresh control state. */
|
||||
unsigned int old_state = atomic_exchange(&s_usb_state, 0U);
|
||||
if (usb_state_is_open(old_state)) {
|
||||
atomic_fetch_add(&s_connection_generation, 1U);
|
||||
}
|
||||
taskENTER_CRITICAL(&s_state_lock);
|
||||
s_line_coding_pending = false;
|
||||
taskEXIT_CRITICAL(&s_state_lock);
|
||||
notify_transport_task();
|
||||
break;
|
||||
}
|
||||
@@ -225,13 +222,6 @@ static void cdc_line_state_callback(int itf, cdcacm_event_t *event)
|
||||
}
|
||||
}
|
||||
|
||||
if (!event->line_state_changed_data.dtr) {
|
||||
/* Do not apply a closed host session's deferred line coding after reopen. */
|
||||
taskENTER_CRITICAL(&s_state_lock);
|
||||
s_line_coding_pending = false;
|
||||
taskEXIT_CRITICAL(&s_state_lock);
|
||||
}
|
||||
|
||||
notify_transport_task();
|
||||
}
|
||||
|
||||
@@ -251,137 +241,15 @@ static void cdc_line_coding_callback(int itf, cdcacm_event_t *event)
|
||||
sizeof(coding));
|
||||
|
||||
taskENTER_CRITICAL(&s_state_lock);
|
||||
if (s_line_coding_pending) {
|
||||
/* Preserve the latest complete setting and account for the superseded one. */
|
||||
++s_counters.callback_drops;
|
||||
}
|
||||
s_line_coding = (usb_cdc_transport_line_coding_t) {
|
||||
.baud_rate = coding.bit_rate,
|
||||
.stop_bits = coding.stop_bits,
|
||||
.parity = coding.parity,
|
||||
.data_bits = coding.data_bits,
|
||||
};
|
||||
s_line_coding_pending = true;
|
||||
taskEXIT_CRITICAL(&s_state_lock);
|
||||
|
||||
notify_transport_task();
|
||||
}
|
||||
|
||||
static bool take_pending_line_coding(usb_cdc_transport_line_coding_t *coding)
|
||||
{
|
||||
bool pending;
|
||||
|
||||
taskENTER_CRITICAL(&s_state_lock);
|
||||
pending = s_line_coding_pending;
|
||||
if (pending) {
|
||||
*coding = s_line_coding;
|
||||
s_line_coding_pending = false;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_state_lock);
|
||||
return pending;
|
||||
}
|
||||
|
||||
static bool serial_configs_equal(const serial_config_t *left,
|
||||
const serial_config_t *right)
|
||||
{
|
||||
return left->version == right->version &&
|
||||
left->baud_rate == right->baud_rate &&
|
||||
left->data_bits == right->data_bits &&
|
||||
left->parity == right->parity &&
|
||||
left->stop_bits == right->stop_bits &&
|
||||
left->flow_control == right->flow_control &&
|
||||
left->dtr_behavior == right->dtr_behavior &&
|
||||
left->rts_threshold == right->rts_threshold;
|
||||
}
|
||||
|
||||
static bool map_line_coding(const usb_cdc_transport_line_coding_t *coding,
|
||||
serial_config_t *config)
|
||||
{
|
||||
if (coding->baud_rate < SERIAL_CONFIG_MIN_BAUD_RATE ||
|
||||
coding->baud_rate > SERIAL_CONFIG_MAX_BAUD_RATE) {
|
||||
return false;
|
||||
}
|
||||
config->baud_rate = coding->baud_rate;
|
||||
|
||||
switch (coding->data_bits) {
|
||||
case 7U:
|
||||
config->data_bits = SERIAL_CONFIG_DATA_BITS_7;
|
||||
break;
|
||||
case 8U:
|
||||
config->data_bits = SERIAL_CONFIG_DATA_BITS_8;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (coding->parity) {
|
||||
case CDC_LINE_CODING_PARITY_NONE:
|
||||
config->parity = SERIAL_CONFIG_PARITY_NONE;
|
||||
break;
|
||||
case CDC_LINE_CODING_PARITY_ODD:
|
||||
config->parity = SERIAL_CONFIG_PARITY_ODD;
|
||||
break;
|
||||
case CDC_LINE_CODING_PARITY_EVEN:
|
||||
config->parity = SERIAL_CONFIG_PARITY_EVEN;
|
||||
break;
|
||||
default:
|
||||
/* Mark and space parity are intentionally not representable by UART policy. */
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (coding->stop_bits) {
|
||||
case CDC_LINE_CODING_STOP_BITS_1:
|
||||
config->stop_bits = SERIAL_CONFIG_STOP_BITS_1;
|
||||
break;
|
||||
case CDC_LINE_CODING_STOP_BITS_2:
|
||||
config->stop_bits = SERIAL_CONFIG_STOP_BITS_2;
|
||||
break;
|
||||
default:
|
||||
/* This also rejects USB's 1.5-stop-bit encoding. */
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static void apply_pending_line_coding(bool writer)
|
||||
{
|
||||
if (!writer || !serial_service_is_running()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* Restarting UART1 discards queued TX, so defer framing changes until idle. */
|
||||
if (serial_service_tx_pending() > 0U) {
|
||||
return;
|
||||
}
|
||||
|
||||
usb_cdc_transport_line_coding_t coding;
|
||||
if (!take_pending_line_coding(&coding)) {
|
||||
return;
|
||||
}
|
||||
|
||||
serial_config_t current;
|
||||
if (serial_service_get_config(¤t) != ESP_OK) {
|
||||
add_counter(&s_counters.line_coding_failed, 1U);
|
||||
return;
|
||||
}
|
||||
|
||||
serial_config_t desired = current;
|
||||
if (!map_line_coding(&coding, &desired)) {
|
||||
add_counter(&s_counters.line_coding_rejected, 1U);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Flow control, DTR policy, and RTS threshold remain from current RAM state. */
|
||||
if (serial_configs_equal(¤t, &desired)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (serial_service_apply_config(&desired) == ESP_OK) {
|
||||
add_counter(&s_counters.line_coding_applied, 1U);
|
||||
} else {
|
||||
add_counter(&s_counters.line_coding_failed, 1U);
|
||||
}
|
||||
/* CDC line coding must not reconfigure the independently configured UART1. */
|
||||
}
|
||||
|
||||
static bool writer_event_type(session_broker_event_type_t type)
|
||||
@@ -761,8 +629,6 @@ static void transport_task(void *context)
|
||||
continue;
|
||||
}
|
||||
|
||||
apply_pending_line_coding(writer);
|
||||
|
||||
if (atomic_load(&s_connection_generation) != observed_generation) {
|
||||
continue;
|
||||
}
|
||||
@@ -811,18 +677,23 @@ static void reset_uninitialized_state(void)
|
||||
.parity = USB_CDC_TRANSPORT_PARITY_NONE,
|
||||
.data_bits = 8U,
|
||||
};
|
||||
s_line_coding_pending = false;
|
||||
memset(&s_counters, 0, sizeof(s_counters));
|
||||
taskEXIT_CRITICAL(&s_state_lock);
|
||||
}
|
||||
|
||||
static void cleanup_init_allocations(bool cdc_initialized, bool driver_installed)
|
||||
static esp_err_t cleanup_init_allocations(bool cdc_initialized, bool driver_installed)
|
||||
{
|
||||
if (cdc_initialized) {
|
||||
(void)tinyusb_cdcacm_deinit(TINYUSB_CDC_ACM_0);
|
||||
esp_err_t error = tinyusb_cdcacm_deinit(TINYUSB_CDC_ACM_0);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
if (driver_installed) {
|
||||
(void)tinyusb_driver_uninstall();
|
||||
esp_err_t error = tinyusb_driver_uninstall();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
if (s_control_queue != NULL) {
|
||||
vQueueDelete(s_control_queue);
|
||||
@@ -833,12 +704,27 @@ static void cleanup_init_allocations(bool cdc_initialized, bool driver_installed
|
||||
s_host_rx_stream = NULL;
|
||||
}
|
||||
reset_uninitialized_state();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t fail_initialization(esp_err_t original_error,
|
||||
bool cdc_initialized,
|
||||
bool driver_installed)
|
||||
{
|
||||
esp_err_t cleanup_error = cleanup_init_allocations(cdc_initialized, driver_installed);
|
||||
if (cleanup_error != ESP_OK) {
|
||||
/* Retain callback-facing storage and reject retries after incomplete teardown. */
|
||||
atomic_store(&s_init_faulted, true);
|
||||
original_error = cleanup_error;
|
||||
}
|
||||
atomic_store(&s_initializing, false);
|
||||
return original_error;
|
||||
}
|
||||
|
||||
esp_err_t usb_cdc_transport_init(void)
|
||||
{
|
||||
bool expected = false;
|
||||
if (atomic_load(&s_initialized) ||
|
||||
if (atomic_load(&s_initialized) || atomic_load(&s_init_faulted) ||
|
||||
!atomic_compare_exchange_strong(&s_initializing, &expected, true)) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
@@ -870,9 +756,7 @@ esp_err_t usb_cdc_transport_init(void)
|
||||
s_control_queue = xQueueCreate(USB_CDC_CONTROL_QUEUE_LENGTH,
|
||||
sizeof(usb_cdc_control_t));
|
||||
if (s_control_queue == NULL) {
|
||||
cleanup_init_allocations(false, false);
|
||||
atomic_store(&s_initializing, false);
|
||||
return ESP_ERR_NO_MEM;
|
||||
return fail_initialization(ESP_ERR_NO_MEM, false, false);
|
||||
}
|
||||
|
||||
/* ESP32-S3's default full-speed internal PHY is fixed to GPIO19/20. */
|
||||
@@ -884,9 +768,7 @@ esp_err_t usb_cdc_transport_init(void)
|
||||
|
||||
result = tinyusb_driver_install(&usb_config);
|
||||
if (result != ESP_OK) {
|
||||
cleanup_init_allocations(false, false);
|
||||
atomic_store(&s_initializing, false);
|
||||
return result;
|
||||
return fail_initialization(result, false, false);
|
||||
}
|
||||
|
||||
const tinyusb_config_cdcacm_t cdc_config = {
|
||||
@@ -898,9 +780,7 @@ esp_err_t usb_cdc_transport_init(void)
|
||||
};
|
||||
result = tinyusb_cdcacm_init(&cdc_config);
|
||||
if (result != ESP_OK) {
|
||||
cleanup_init_allocations(false, true);
|
||||
atomic_store(&s_initializing, false);
|
||||
return result;
|
||||
return fail_initialization(result, false, true);
|
||||
}
|
||||
|
||||
TaskHandle_t task = NULL;
|
||||
@@ -910,9 +790,7 @@ esp_err_t usb_cdc_transport_init(void)
|
||||
NULL,
|
||||
USB_CDC_TASK_PRIORITY,
|
||||
&task) != pdPASS) {
|
||||
cleanup_init_allocations(true, true);
|
||||
atomic_store(&s_initializing, false);
|
||||
return ESP_ERR_NO_MEM;
|
||||
return fail_initialization(ESP_ERR_NO_MEM, true, true);
|
||||
}
|
||||
|
||||
atomic_store(&s_transport_task, (uintptr_t)task);
|
||||
|
||||
@@ -44,9 +44,6 @@ typedef struct {
|
||||
uint64_t writer_revocations;
|
||||
uint64_t writer_events;
|
||||
uint64_t service_start_failures;
|
||||
uint64_t line_coding_applied;
|
||||
uint64_t line_coding_rejected;
|
||||
uint64_t line_coding_failed;
|
||||
uint64_t callback_drops;
|
||||
uint64_t control_drops;
|
||||
} usb_cdc_transport_counters_t;
|
||||
|
||||
+2
-6
@@ -76,7 +76,7 @@ static int show_status(void)
|
||||
(unsigned int)snapshot.line_coding.data_bits,
|
||||
parity_name(snapshot.line_coding.parity),
|
||||
stop_bits_name(snapshot.line_coding.stop_bits));
|
||||
printf("USB line coding changes RAM only; use 'serial save' to persist it.\n");
|
||||
printf("Host line coding is reported only; UART1 uses the serial configuration.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -110,11 +110,7 @@ static int show_counters(void)
|
||||
counter->writer_denials,
|
||||
counter->writer_revocations,
|
||||
counter->writer_events);
|
||||
printf("Line coding: applied=%" PRIu64 " rejected=%" PRIu64
|
||||
" failed=%" PRIu64 " service-start-failures=%" PRIu64 "\n",
|
||||
counter->line_coding_applied,
|
||||
counter->line_coding_rejected,
|
||||
counter->line_coding_failed,
|
||||
printf("Service start failures=%" PRIu64 "\n",
|
||||
counter->service_start_failures);
|
||||
printf("Control/callback drops: control=%" PRIu64 " callback=%" PRIu64 "\n",
|
||||
counter->control_drops,
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Typed, serialized user-administration mutations with transport revocation. */
|
||||
|
||||
#include "user_admin_service.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_command_gate.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "web_server.h"
|
||||
|
||||
typedef enum {
|
||||
USER_ADMIN_OPERATION_CREATE = 0,
|
||||
USER_ADMIN_OPERATION_CREATE_GENERATED,
|
||||
USER_ADMIN_OPERATION_DELETE,
|
||||
USER_ADMIN_OPERATION_SET_ROLE,
|
||||
USER_ADMIN_OPERATION_SET_PASSWORD,
|
||||
USER_ADMIN_OPERATION_GENERATE_PASSWORD,
|
||||
USER_ADMIN_OPERATION_ADD_SSH_KEY,
|
||||
USER_ADMIN_OPERATION_REMOVE_SSH_KEY,
|
||||
USER_ADMIN_OPERATION_CLEAR_SSH_KEYS,
|
||||
} user_admin_operation_type_t;
|
||||
|
||||
typedef struct {
|
||||
user_admin_operation_type_t type;
|
||||
const uint8_t *username;
|
||||
size_t username_length;
|
||||
user_role_t role;
|
||||
const uint8_t *password;
|
||||
size_t password_length;
|
||||
const uint8_t *key_type;
|
||||
size_t key_type_length;
|
||||
const uint8_t *key_blob;
|
||||
size_t key_blob_length;
|
||||
uint8_t key_index;
|
||||
uint8_t *added_key_index;
|
||||
user_database_generated_password_t *generated_password;
|
||||
} user_admin_operation_t;
|
||||
|
||||
/* Every access is protected by admin_command_gate. Keep this large snapshot off task stacks. */
|
||||
static user_database_snapshot_t s_snapshot;
|
||||
|
||||
static const user_database_user_snapshot_t *find_snapshot_user(
|
||||
const user_admin_operation_t *operation)
|
||||
{
|
||||
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) {
|
||||
const user_database_user_snapshot_t *user = &s_snapshot.users[index];
|
||||
if (user->active && user->username_length == operation->username_length &&
|
||||
memcmp(user->username, operation->username, operation->username_length) == 0) {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static bool role_valid(user_role_t role)
|
||||
{
|
||||
return role == USER_ROLE_USER || role == USER_ROLE_ADMIN;
|
||||
}
|
||||
|
||||
static bool operation_arguments_valid(const user_admin_operation_t *operation)
|
||||
{
|
||||
if (operation == NULL ||
|
||||
!user_database_username_valid(operation->username, operation->username_length)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (operation->type) {
|
||||
case USER_ADMIN_OPERATION_CREATE:
|
||||
return role_valid(operation->role) &&
|
||||
user_database_password_valid(operation->password,
|
||||
operation->password_length);
|
||||
case USER_ADMIN_OPERATION_CREATE_GENERATED:
|
||||
return role_valid(operation->role) && operation->generated_password != NULL;
|
||||
case USER_ADMIN_OPERATION_DELETE:
|
||||
return true;
|
||||
case USER_ADMIN_OPERATION_SET_ROLE:
|
||||
return role_valid(operation->role);
|
||||
case USER_ADMIN_OPERATION_SET_PASSWORD:
|
||||
return user_database_password_valid(operation->password,
|
||||
operation->password_length);
|
||||
case USER_ADMIN_OPERATION_GENERATE_PASSWORD:
|
||||
return operation->generated_password != NULL;
|
||||
case USER_ADMIN_OPERATION_ADD_SSH_KEY:
|
||||
return operation->added_key_index != NULL;
|
||||
case USER_ADMIN_OPERATION_REMOVE_SSH_KEY:
|
||||
return operation->key_index < USER_DATABASE_MAX_SSH_KEYS_PER_USER;
|
||||
case USER_ADMIN_OPERATION_CLEAR_SSH_KEYS:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool operation_expected_to_commit(
|
||||
const user_admin_operation_t *operation,
|
||||
const user_database_user_snapshot_t *user)
|
||||
{
|
||||
if (operation->type == USER_ADMIN_OPERATION_SET_ROLE && user != NULL) {
|
||||
return user->role != operation->role;
|
||||
}
|
||||
if (operation->type == USER_ADMIN_OPERATION_CLEAR_SSH_KEYS && user != NULL) {
|
||||
return user->public_key_count != 0U;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static esp_err_t apply_database_operation(const user_admin_operation_t *operation)
|
||||
{
|
||||
switch (operation->type) {
|
||||
case USER_ADMIN_OPERATION_CREATE:
|
||||
return user_database_create(
|
||||
operation->username, operation->username_length, operation->role,
|
||||
operation->password, operation->password_length);
|
||||
case USER_ADMIN_OPERATION_CREATE_GENERATED:
|
||||
return user_database_create_generated(
|
||||
operation->username, operation->username_length, operation->role,
|
||||
operation->generated_password);
|
||||
case USER_ADMIN_OPERATION_DELETE:
|
||||
return user_database_delete(operation->username, operation->username_length);
|
||||
case USER_ADMIN_OPERATION_SET_ROLE:
|
||||
return user_database_set_role(operation->username, operation->username_length,
|
||||
operation->role);
|
||||
case USER_ADMIN_OPERATION_SET_PASSWORD:
|
||||
return user_database_set_password(
|
||||
operation->username, operation->username_length,
|
||||
operation->password, operation->password_length);
|
||||
case USER_ADMIN_OPERATION_GENERATE_PASSWORD:
|
||||
return user_database_generate_password(
|
||||
operation->username, operation->username_length,
|
||||
operation->generated_password);
|
||||
case USER_ADMIN_OPERATION_ADD_SSH_KEY:
|
||||
return user_database_add_ssh_key(
|
||||
operation->username, operation->username_length,
|
||||
operation->key_type, operation->key_type_length,
|
||||
operation->key_blob, operation->key_blob_length,
|
||||
operation->added_key_index);
|
||||
case USER_ADMIN_OPERATION_REMOVE_SSH_KEY:
|
||||
return user_database_remove_ssh_key(
|
||||
operation->username, operation->username_length, operation->key_index);
|
||||
case USER_ADMIN_OPERATION_CLEAR_SSH_KEYS:
|
||||
return user_database_clear_ssh_keys(
|
||||
operation->username, operation->username_length);
|
||||
default:
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
}
|
||||
|
||||
static void revoke_committed_user(const user_admin_operation_t *operation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
result->revocation.attempted = true;
|
||||
result->revocation.web_error = web_server_revoke_user(
|
||||
operation->username, operation->username_length);
|
||||
result->revocation.ssh_error = ssh_transport_revoke_user(
|
||||
operation->username, operation->username_length);
|
||||
}
|
||||
|
||||
static bool operation_is_create(const user_admin_operation_t *operation)
|
||||
{
|
||||
return operation->type == USER_ADMIN_OPERATION_CREATE ||
|
||||
operation->type == USER_ADMIN_OPERATION_CREATE_GENERATED;
|
||||
}
|
||||
|
||||
static bool operation_advances_auth_generation(
|
||||
const user_admin_operation_t *operation)
|
||||
{
|
||||
return operation->type == USER_ADMIN_OPERATION_SET_ROLE ||
|
||||
operation->type == USER_ADMIN_OPERATION_SET_PASSWORD ||
|
||||
operation->type == USER_ADMIN_OPERATION_GENERATE_PASSWORD ||
|
||||
operation->type == USER_ADMIN_OPERATION_ADD_SSH_KEY ||
|
||||
operation->type == USER_ADMIN_OPERATION_REMOVE_SSH_KEY ||
|
||||
operation->type == USER_ADMIN_OPERATION_CLEAR_SSH_KEYS;
|
||||
}
|
||||
|
||||
static user_admin_service_failure_t classify_failure(
|
||||
const user_admin_operation_t *operation,
|
||||
const user_database_user_snapshot_t *user, esp_err_t error)
|
||||
{
|
||||
if (error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_DUPLICATE_KEY;
|
||||
}
|
||||
if (error == ESP_ERR_NO_MEM) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_CAPACITY;
|
||||
}
|
||||
if (error != ESP_ERR_INVALID_STATE) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_NONE;
|
||||
}
|
||||
if (operation_is_create(operation) && user != NULL) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_DUPLICATE_USERNAME;
|
||||
}
|
||||
if (user != NULL && user->role == USER_ROLE_ADMIN &&
|
||||
s_snapshot.admin_count <= 1U &&
|
||||
(operation->type == USER_ADMIN_OPERATION_DELETE ||
|
||||
(operation->type == USER_ADMIN_OPERATION_SET_ROLE &&
|
||||
operation->role != USER_ROLE_ADMIN))) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_FINAL_ADMIN;
|
||||
}
|
||||
if (s_snapshot.generation == UINT32_MAX ||
|
||||
(user != NULL && operation_advances_auth_generation(operation) &&
|
||||
user->auth_generation == UINT32_MAX)) {
|
||||
return USER_ADMIN_SERVICE_FAILURE_GENERATION_EXHAUSTED;
|
||||
}
|
||||
return USER_ADMIN_SERVICE_FAILURE_STATE;
|
||||
}
|
||||
|
||||
static esp_err_t execute_operation(
|
||||
const user_admin_operation_t *operation,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
if (result == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
memset(result, 0, sizeof(*result));
|
||||
if (!operation_arguments_valid(operation) ||
|
||||
(operation_is_create(operation) && expectation != NULL &&
|
||||
expectation->user_id != 0U)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (operation->added_key_index != NULL) {
|
||||
*operation->added_key_index = 0U;
|
||||
}
|
||||
|
||||
esp_err_t error = admin_command_gate_take();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
error = user_database_get_snapshot(&s_snapshot);
|
||||
if (error != ESP_OK) {
|
||||
admin_command_gate_give();
|
||||
return error;
|
||||
}
|
||||
|
||||
const uint32_t before_generation = s_snapshot.generation;
|
||||
const user_database_user_snapshot_t *user = find_snapshot_user(operation);
|
||||
result->database_generation = before_generation;
|
||||
if (user != NULL) {
|
||||
result->user_id = user->user_id;
|
||||
}
|
||||
|
||||
if (expectation != NULL && expectation->database_generation != 0U &&
|
||||
expectation->database_generation != before_generation) {
|
||||
result->conflict = USER_ADMIN_SERVICE_CONFLICT_DATABASE_GENERATION;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
} else if (!operation_is_create(operation) && expectation != NULL &&
|
||||
expectation->user_id != 0U &&
|
||||
(user == NULL || expectation->user_id != user->user_id)) {
|
||||
result->conflict = USER_ADMIN_SERVICE_CONFLICT_USER_ID;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
} else {
|
||||
const bool expected_commit = operation_expected_to_commit(operation, user);
|
||||
error = apply_database_operation(operation);
|
||||
if (error != ESP_OK) {
|
||||
result->failure = classify_failure(operation, user, error);
|
||||
} else {
|
||||
esp_err_t snapshot_error = user_database_get_snapshot(&s_snapshot);
|
||||
if (snapshot_error == ESP_OK) {
|
||||
result->database_generation = s_snapshot.generation;
|
||||
result->mutation_committed =
|
||||
s_snapshot.generation != before_generation;
|
||||
const user_database_user_snapshot_t *updated_user =
|
||||
find_snapshot_user(operation);
|
||||
if (updated_user != NULL) {
|
||||
result->user_id = updated_user->user_id;
|
||||
}
|
||||
} else {
|
||||
result->mutation_committed = expected_commit;
|
||||
if (expected_commit) {
|
||||
result->database_generation = before_generation + 1U;
|
||||
}
|
||||
}
|
||||
if (result->mutation_committed) {
|
||||
revoke_committed_user(operation, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
admin_command_gate_give();
|
||||
if (error != ESP_OK && operation->generated_password != NULL) {
|
||||
secure_wipe(operation->generated_password,
|
||||
sizeof(*operation->generated_password));
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_create(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_CREATE,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.role = role,
|
||||
.password = password,
|
||||
.password_length = password_length,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_create_generated(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_database_generated_password_t *generated_password,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
if (generated_password != NULL) {
|
||||
secure_wipe(generated_password, sizeof(*generated_password));
|
||||
}
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_CREATE_GENERATED,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.role = role,
|
||||
.generated_password = generated_password,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_delete(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_DELETE,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_set_role(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_SET_ROLE,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.role = role,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_set_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_SET_PASSWORD,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.password = password,
|
||||
.password_length = password_length,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_generate_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_database_generated_password_t *generated_password,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
if (generated_password != NULL) {
|
||||
secure_wipe(generated_password, sizeof(*generated_password));
|
||||
}
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_GENERATE_PASSWORD,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.generated_password = generated_password,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_add_ssh_key(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *key_type, size_t key_type_length,
|
||||
const uint8_t *key_blob, size_t key_blob_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
uint8_t *key_index, user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_ADD_SSH_KEY,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.key_type = key_type,
|
||||
.key_type_length = key_type_length,
|
||||
.key_blob = key_blob,
|
||||
.key_blob_length = key_blob_length,
|
||||
.added_key_index = key_index,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_remove_ssh_key(
|
||||
const uint8_t *username, size_t username_length, uint8_t key_index,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_REMOVE_SSH_KEY,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
.key_index = key_index,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
|
||||
esp_err_t user_admin_service_clear_ssh_keys(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result)
|
||||
{
|
||||
const user_admin_operation_t operation = {
|
||||
.type = USER_ADMIN_OPERATION_CLEAR_SSH_KEYS,
|
||||
.username = username,
|
||||
.username_length = username_length,
|
||||
};
|
||||
return execute_operation(&operation, expectation, result);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Typed, serialized user-administration mutations with transport revocation. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "user_database.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
/* Zero disables the corresponding optimistic check. */
|
||||
uint32_t database_generation;
|
||||
uint32_t user_id;
|
||||
} user_admin_service_expectation_t;
|
||||
|
||||
typedef enum {
|
||||
USER_ADMIN_SERVICE_CONFLICT_NONE = 0,
|
||||
USER_ADMIN_SERVICE_CONFLICT_DATABASE_GENERATION,
|
||||
USER_ADMIN_SERVICE_CONFLICT_USER_ID,
|
||||
} user_admin_service_conflict_t;
|
||||
|
||||
typedef struct {
|
||||
bool attempted;
|
||||
esp_err_t web_error;
|
||||
esp_err_t ssh_error;
|
||||
} user_admin_service_revocation_t;
|
||||
|
||||
typedef enum {
|
||||
USER_ADMIN_SERVICE_FAILURE_NONE = 0,
|
||||
USER_ADMIN_SERVICE_FAILURE_DUPLICATE_USERNAME,
|
||||
USER_ADMIN_SERVICE_FAILURE_DUPLICATE_KEY,
|
||||
USER_ADMIN_SERVICE_FAILURE_CAPACITY,
|
||||
USER_ADMIN_SERVICE_FAILURE_FINAL_ADMIN,
|
||||
USER_ADMIN_SERVICE_FAILURE_GENERATION_EXHAUSTED,
|
||||
USER_ADMIN_SERVICE_FAILURE_STATE,
|
||||
} user_admin_service_failure_t;
|
||||
|
||||
typedef struct {
|
||||
/* True only when the database generation advanced for this operation. */
|
||||
bool mutation_committed;
|
||||
/* Current generation observed before, or immediately after, the operation. */
|
||||
uint32_t database_generation;
|
||||
/* Target account ID observed before the operation, or after a successful create. */
|
||||
uint32_t user_id;
|
||||
user_admin_service_conflict_t conflict;
|
||||
user_admin_service_failure_t failure;
|
||||
user_admin_service_revocation_t revocation;
|
||||
} user_admin_service_result_t;
|
||||
|
||||
/*
|
||||
* Every operation serializes its snapshot check and database mutation with
|
||||
* admin_command_gate. A generation or user-ID mismatch returns
|
||||
* ESP_ERR_INVALID_STATE and identifies the mismatch in result->conflict.
|
||||
*
|
||||
* A NULL expectation disables both optimistic checks. For existing-account
|
||||
* operations, zero fields also disable their individual checks. Create
|
||||
* operations require expectation->user_id to be zero because no prior account
|
||||
* identity can be targeted.
|
||||
*
|
||||
* After a committed mutation, both transport revocation hooks are attempted.
|
||||
* Their exact outcomes are returned separately and never replace ESP_OK from a
|
||||
* successful database mutation. No-op role and key-clear requests do not
|
||||
* advance the generation and do not trigger revocation.
|
||||
*/
|
||||
esp_err_t user_admin_service_create(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
/* On success, the caller owns generated_password and must securely wipe it. */
|
||||
esp_err_t user_admin_service_create_generated(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_database_generated_password_t *generated_password,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_delete(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_set_role(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_set_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
/* On success, the caller owns generated_password and must securely wipe it. */
|
||||
esp_err_t user_admin_service_generate_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_database_generated_password_t *generated_password,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_add_ssh_key(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *key_type, size_t key_type_length,
|
||||
const uint8_t *key_blob, size_t key_blob_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
uint8_t *key_index, user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_remove_ssh_key(
|
||||
const uint8_t *username, size_t username_length, uint8_t key_index,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
esp_err_t user_admin_service_clear_ssh_keys(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const user_admin_service_expectation_t *expectation,
|
||||
user_admin_service_result_t *result);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,560 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Physical UART0 role-based user administration. */
|
||||
|
||||
#include "user_console.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_command_gate.h"
|
||||
#include "admin_ssh_console.h"
|
||||
#include "console_input.h"
|
||||
#include "esp_console.h"
|
||||
#include "mbedtls/base64.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "user_admin_service.h"
|
||||
#include "user_database.h"
|
||||
#include "web_security.h"
|
||||
#include "web_server.h"
|
||||
|
||||
#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)
|
||||
{
|
||||
printf("Usage:\n");
|
||||
printf(" user status|list\n");
|
||||
printf(" user show <username>\n");
|
||||
printf(" user bootstrap [--generate]\n");
|
||||
printf(" user recover --force\n");
|
||||
printf(" user add <username> <user|admin> [--generate]\n");
|
||||
printf(" user delete <username> --force\n");
|
||||
printf(" user role <username> <user|admin> --force\n");
|
||||
printf(" user password <username> [--generate]\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 clear <username> --force\n");
|
||||
}
|
||||
|
||||
static void print_revocation_warnings(esp_err_t web_error, esp_err_t ssh_error)
|
||||
{
|
||||
if (web_error != ESP_OK && web_error != ESP_ERR_INVALID_STATE) {
|
||||
printf("Warning: Web session revocation failed: %s\n",
|
||||
esp_err_to_name(web_error));
|
||||
}
|
||||
if (ssh_error != ESP_OK && ssh_error != ESP_ERR_INVALID_STATE) {
|
||||
printf("Warning: SSH revocation failed: %s\n", esp_err_to_name(ssh_error));
|
||||
}
|
||||
}
|
||||
|
||||
static void print_service_revocation_warnings(
|
||||
const user_admin_service_result_t *result)
|
||||
{
|
||||
if (result->revocation.attempted) {
|
||||
print_revocation_warnings(result->revocation.web_error,
|
||||
result->revocation.ssh_error);
|
||||
}
|
||||
}
|
||||
|
||||
static void revoke_user_network_sessions(const char *username)
|
||||
{
|
||||
size_t username_length = strlen(username);
|
||||
esp_err_t web_error = web_server_revoke_user(
|
||||
(const uint8_t *)username, username_length);
|
||||
esp_err_t ssh_error = ssh_transport_revoke_user(
|
||||
(const uint8_t *)username, username_length);
|
||||
print_revocation_warnings(web_error, ssh_error);
|
||||
}
|
||||
|
||||
static void print_fingerprint(const uint8_t fingerprint[USER_DATABASE_SHA256_LENGTH])
|
||||
{
|
||||
uint8_t encoded[48] = {0};
|
||||
size_t length = 0U;
|
||||
if (mbedtls_base64_encode(encoded, sizeof(encoded), &length,
|
||||
fingerprint, USER_DATABASE_SHA256_LENGTH) != 0) {
|
||||
printf("unavailable");
|
||||
return;
|
||||
}
|
||||
while (length > 0U && encoded[length - 1U] == '=') {
|
||||
--length;
|
||||
}
|
||||
printf("SHA256:%.*s", (int)length, (const char *)encoded);
|
||||
secure_wipe(encoded, sizeof(encoded));
|
||||
}
|
||||
|
||||
static void print_user(const user_database_user_snapshot_t *user)
|
||||
{
|
||||
printf("%.*s role=%s id=%lu generation=%lu keys=%u\n",
|
||||
(int)user->username_length, user->username,
|
||||
user_role_to_string(user->role),
|
||||
(unsigned long)user->user_id,
|
||||
(unsigned long)user->auth_generation,
|
||||
(unsigned int)user->public_key_count);
|
||||
for (size_t index = 0U; index < USER_DATABASE_MAX_SSH_KEYS_PER_USER; ++index) {
|
||||
const user_database_key_snapshot_t *key = &user->public_keys[index];
|
||||
if (!key->active) {
|
||||
continue;
|
||||
}
|
||||
printf(" key %u %.*s ", (unsigned int)key->index,
|
||||
(int)key->key_type_length, key->key_type);
|
||||
print_fingerprint(key->sha256_fingerprint);
|
||||
putchar('\n');
|
||||
}
|
||||
}
|
||||
|
||||
static int show_users(const char *selected)
|
||||
{
|
||||
esp_err_t error = user_database_get_snapshot(&s_user_snapshot);
|
||||
if (error != ESP_OK) {
|
||||
printf("User database unavailable: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
if (selected == NULL) {
|
||||
printf("User database: generation=%lu users=%u/%u admins=%u bootstrapped=%s\n",
|
||||
(unsigned long)s_user_snapshot.generation,
|
||||
(unsigned int)s_user_snapshot.user_count,
|
||||
USER_DATABASE_MAX_USERS,
|
||||
(unsigned int)s_user_snapshot.admin_count,
|
||||
s_user_snapshot.admin_bootstrapped ? "yes" : "no");
|
||||
}
|
||||
bool found = false;
|
||||
for (size_t index = 0U; index < USER_DATABASE_MAX_USERS; ++index) {
|
||||
const user_database_user_snapshot_t *user = &s_user_snapshot.users[index];
|
||||
if (!user->active ||
|
||||
(selected != NULL &&
|
||||
(strlen(selected) != user->username_length ||
|
||||
memcmp(selected, user->username, user->username_length) != 0))) {
|
||||
continue;
|
||||
}
|
||||
print_user(user);
|
||||
found = true;
|
||||
}
|
||||
if (selected != NULL && !found) {
|
||||
printf("User '%s' not found.\n", selected);
|
||||
return 1;
|
||||
}
|
||||
if (!s_user_snapshot.admin_bootstrapped) {
|
||||
printf("Administrative network access is not bootstrapped; use 'user bootstrap'.\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static esp_err_t read_password(uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U],
|
||||
size_t *password_length)
|
||||
{
|
||||
uint8_t confirmation[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
|
||||
size_t confirmation_length = 0U;
|
||||
esp_err_t error = console_input_read_hidden(
|
||||
"Password (12..64 printable characters, Ctrl-C cancels): ",
|
||||
password, USER_DATABASE_PASSWORD_CAPACITY + 1U,
|
||||
USER_DATABASE_PASSWORD_MIN_LENGTH, USER_DATABASE_PASSWORD_CAPACITY,
|
||||
password_length);
|
||||
if (error == ESP_OK) {
|
||||
error = console_input_read_hidden(
|
||||
"Repeat password: ", confirmation, sizeof(confirmation),
|
||||
USER_DATABASE_PASSWORD_MIN_LENGTH, USER_DATABASE_PASSWORD_CAPACITY,
|
||||
&confirmation_length);
|
||||
}
|
||||
if (error == ESP_OK &&
|
||||
(*password_length != confirmation_length ||
|
||||
memcmp(password, confirmation, *password_length) != 0)) {
|
||||
printf("Passwords do not match.\n");
|
||||
error = ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
secure_wipe(confirmation, sizeof(confirmation));
|
||||
if (error != ESP_OK) {
|
||||
secure_wipe(password, USER_DATABASE_PASSWORD_CAPACITY + 1U);
|
||||
*password_length = 0U;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
static void show_generated_password(const char *username,
|
||||
user_database_generated_password_t *generated)
|
||||
{
|
||||
printf("Generated password for %s: %.*s\n",
|
||||
username, (int)generated->password_length, generated->password);
|
||||
printf("This password is shown once; store it securely.\n");
|
||||
secure_wipe(generated, sizeof(*generated));
|
||||
}
|
||||
|
||||
static int recover_database(void)
|
||||
{
|
||||
web_security_credentials_t credentials;
|
||||
memset(&credentials, 0, sizeof(credentials));
|
||||
esp_err_t error = web_security_show_credentials(&credentials);
|
||||
if (error == ESP_OK) {
|
||||
const user_database_legacy_credentials_t legacy = {
|
||||
.username = (const uint8_t *)credentials.username,
|
||||
.username_length = credentials.username_length,
|
||||
.password = (const uint8_t *)credentials.password,
|
||||
.password_length = credentials.password_length,
|
||||
};
|
||||
error = user_database_recover_from_legacy(&legacy);
|
||||
}
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not recover user database: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("User database replaced from the current legacy network credential.\n");
|
||||
printf("The imported account has role user; run 'user bootstrap' to establish an administrator.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int bootstrap(bool generated)
|
||||
{
|
||||
esp_err_t error;
|
||||
if (generated) {
|
||||
user_database_generated_password_t password;
|
||||
error = user_database_bootstrap_admin_generated(&password);
|
||||
if (error == ESP_OK) {
|
||||
show_generated_password("admin", &password);
|
||||
}
|
||||
} else {
|
||||
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
|
||||
size_t password_length = 0U;
|
||||
error = read_password(password, &password_length);
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_bootstrap_admin(password, password_length);
|
||||
}
|
||||
secure_wipe(password, sizeof(password));
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not bootstrap administrator: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions("admin");
|
||||
printf("Administrator account bootstrapped. Role-aware HTTPS and SSH authentication is active.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int add_user(const char *username, const char *role_text, bool generated)
|
||||
{
|
||||
user_role_t role;
|
||||
if (!user_database_username_valid((const uint8_t *)username, strlen(username)) ||
|
||||
!user_role_parse(role_text, &role)) {
|
||||
printf("Username must match [a-z][a-z0-9_-]{0,15}; role is user or admin.\n");
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error;
|
||||
user_admin_service_result_t result;
|
||||
if (generated) {
|
||||
user_database_generated_password_t password;
|
||||
error = user_admin_service_create_generated(
|
||||
(const uint8_t *)username, strlen(username), role, NULL,
|
||||
&password, &result);
|
||||
if (error == ESP_OK) {
|
||||
show_generated_password(username, &password);
|
||||
}
|
||||
} else {
|
||||
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
|
||||
size_t password_length = 0U;
|
||||
error = read_password(password, &password_length);
|
||||
if (error == ESP_OK) {
|
||||
error = user_admin_service_create(
|
||||
(const uint8_t *)username, strlen(username), role,
|
||||
password, password_length, NULL, &result);
|
||||
}
|
||||
secure_wipe(password, sizeof(password));
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not add user: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("User '%s' added with role %s.\n", username, user_role_to_string(role));
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int change_password(const char *username, bool generated)
|
||||
{
|
||||
esp_err_t error;
|
||||
user_admin_service_result_t result;
|
||||
if (generated) {
|
||||
user_database_generated_password_t password;
|
||||
error = user_admin_service_generate_password(
|
||||
(const uint8_t *)username, strlen(username), NULL, &password, &result);
|
||||
if (error == ESP_OK) {
|
||||
show_generated_password(username, &password);
|
||||
}
|
||||
} else {
|
||||
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U] = {0};
|
||||
size_t password_length = 0U;
|
||||
error = read_password(password, &password_length);
|
||||
if (error == ESP_OK) {
|
||||
error = user_admin_service_set_password(
|
||||
(const uint8_t *)username, strlen(username),
|
||||
password, password_length, NULL, &result);
|
||||
}
|
||||
secure_wipe(password, sizeof(password));
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not change password: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("Password changed; affected network sessions are now stale and will be revoked.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool parse_key_index(const char *text, uint8_t *index)
|
||||
{
|
||||
if (text == NULL || text[0] < '0' || text[0] > '9' || text[1] != '\0') {
|
||||
return false;
|
||||
}
|
||||
uint8_t parsed = (uint8_t)(text[0] - '0');
|
||||
if (parsed >= USER_DATABASE_MAX_SSH_KEYS_PER_USER) {
|
||||
return false;
|
||||
}
|
||||
*index = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool key_delimiter(uint8_t value)
|
||||
{
|
||||
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;
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_add_ssh_key(
|
||||
(const uint8_t *)username, strlen(username), type, type_length,
|
||||
blob, blob_length, NULL, &key_index, &result);
|
||||
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;
|
||||
}
|
||||
print_service_revocation_warnings(&result);
|
||||
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)
|
||||
{
|
||||
uint8_t line[USER_CONSOLE_KEY_LINE_CAPACITY] = {0};
|
||||
size_t line_length = 0U;
|
||||
esp_err_t error = console_input_read_line(
|
||||
"OpenSSH public key (type base64 [comment], Ctrl-C cancels): ",
|
||||
line, sizeof(line), &line_length);
|
||||
if (error != ESP_OK) {
|
||||
secure_wipe(line, sizeof(line));
|
||||
return 1;
|
||||
}
|
||||
|
||||
uint8_t *space = NULL;
|
||||
for (size_t index = 0U; index < line_length; ++index) {
|
||||
if (key_delimiter(line[index])) {
|
||||
space = &line[index];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (space == NULL) {
|
||||
printf("Public key must contain a key type and Base64 blob.\n");
|
||||
secure_wipe(line, sizeof(line));
|
||||
return 1;
|
||||
}
|
||||
size_t type_length = (size_t)(space - line);
|
||||
uint8_t *encoded = space + 1U;
|
||||
size_t remaining = line_length - type_length - 1U;
|
||||
while (remaining > 0U && key_delimiter(*encoded)) {
|
||||
++encoded;
|
||||
--remaining;
|
||||
}
|
||||
uint8_t *encoded_end = NULL;
|
||||
for (size_t index = 0U; index < remaining; ++index) {
|
||||
if (key_delimiter(encoded[index])) {
|
||||
encoded_end = &encoded[index];
|
||||
break;
|
||||
}
|
||||
}
|
||||
size_t encoded_length = encoded_end == NULL
|
||||
? remaining
|
||||
: (size_t)(encoded_end - encoded);
|
||||
int result = add_key_parts(username, line, type_length, encoded, encoded_length);
|
||||
secure_wipe(line, sizeof(line));
|
||||
return result;
|
||||
}
|
||||
|
||||
static int command_user_inner(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) ||
|
||||
(argc == 2 && strcmp(argv[1], "list") == 0)) {
|
||||
return show_users(NULL);
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "show") == 0) {
|
||||
return show_users(argv[2]);
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "recover") == 0 &&
|
||||
strcmp(argv[2], "--force") == 0) {
|
||||
if (remote) {
|
||||
printf("User database recovery is restricted to physical UART0.\n");
|
||||
return 1;
|
||||
}
|
||||
return recover_database();
|
||||
}
|
||||
if ((argc == 2 || argc == 3) && strcmp(argv[1], "bootstrap") == 0) {
|
||||
bool generated = argc == 3 && strcmp(argv[2], "--generate") == 0;
|
||||
if (argc == 3 && !generated) {
|
||||
print_usage();
|
||||
return 1;
|
||||
}
|
||||
if (remote) {
|
||||
printf("Administrator bootstrap is restricted to physical UART0.\n");
|
||||
return 1;
|
||||
}
|
||||
return bootstrap(generated);
|
||||
}
|
||||
if ((argc == 4 || argc == 5) && strcmp(argv[1], "add") == 0) {
|
||||
bool generated = argc == 5 && strcmp(argv[4], "--generate") == 0;
|
||||
if (argc == 5 && !generated) {
|
||||
print_usage();
|
||||
return 1;
|
||||
}
|
||||
return add_user(argv[2], argv[3], generated);
|
||||
}
|
||||
if (argc == 4 && strcmp(argv[1], "delete") == 0 &&
|
||||
strcmp(argv[3], "--force") == 0) {
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_delete(
|
||||
(const uint8_t *)argv[2], strlen(argv[2]), NULL, &result);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not delete user (the migrated or final admin is protected): %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("User '%s' deleted.\n", argv[2]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (argc == 5 && strcmp(argv[1], "role") == 0 &&
|
||||
strcmp(argv[4], "--force") == 0) {
|
||||
user_role_t role;
|
||||
if (!user_role_parse(argv[3], &role)) {
|
||||
printf("Role must be user or admin.\n");
|
||||
return 1;
|
||||
}
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_set_role(
|
||||
(const uint8_t *)argv[2], strlen(argv[2]), role, NULL, &result);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not change role (the final admin is protected): %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("User '%s' role changed to %s.\n", argv[2], user_role_to_string(role));
|
||||
return 0;
|
||||
}
|
||||
if ((argc == 3 || argc == 4) && strcmp(argv[1], "password") == 0) {
|
||||
bool generated = argc == 4 && strcmp(argv[3], "--generate") == 0;
|
||||
if (argc == 4 && !generated) {
|
||||
print_usage();
|
||||
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);
|
||||
}
|
||||
if (argc == 4 && strcmp(argv[1], "key") == 0 &&
|
||||
strcmp(argv[2], "add") == 0) {
|
||||
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 &&
|
||||
strcmp(argv[2], "delete") == 0 && strcmp(argv[5], "--force") == 0) {
|
||||
uint8_t index;
|
||||
if (!parse_key_index(argv[4], &index)) {
|
||||
printf("Key index must be 0..2.\n");
|
||||
return 1;
|
||||
}
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_remove_ssh_key(
|
||||
(const uint8_t *)argv[3], strlen(argv[3]), index, NULL, &result);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not delete SSH key: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("SSH key %u deleted for '%s'.\n", (unsigned int)index, argv[3]);
|
||||
return 0;
|
||||
}
|
||||
if (argc == 5 && strcmp(argv[1], "key") == 0 &&
|
||||
strcmp(argv[2], "clear") == 0 && strcmp(argv[4], "--force") == 0) {
|
||||
user_admin_service_result_t result;
|
||||
esp_err_t error = user_admin_service_clear_ssh_keys(
|
||||
(const uint8_t *)argv[3], strlen(argv[3]), NULL, &result);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not clear SSH keys: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
print_service_revocation_warnings(&result);
|
||||
printf("SSH keys cleared for '%s'.\n", argv[3]);
|
||||
return 0;
|
||||
}
|
||||
print_usage();
|
||||
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)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
.command = "user",
|
||||
.help = "Manage bounded role-based users, passwords, and SSH public keys",
|
||||
.hint = NULL,
|
||||
.func = &command_user,
|
||||
.argtable = NULL,
|
||||
};
|
||||
return esp_console_cmd_register(&command);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Physical UART0 role-based user administration. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
esp_err_t user_console_register_commands(void);
|
||||
+1291
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded persistent role-based user and SSH authorized-key database. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define USER_DATABASE_NVS_NAMESPACE "user_db"
|
||||
#define USER_DATABASE_NVS_BLOB_KEY "database"
|
||||
#define USER_DATABASE_MAX_USERS 8U
|
||||
#define USER_DATABASE_MAX_SSH_KEYS_PER_USER 3U
|
||||
#define USER_DATABASE_USERNAME_CAPACITY 16U
|
||||
#define USER_DATABASE_PASSWORD_CAPACITY 64U
|
||||
#define USER_DATABASE_PASSWORD_MIN_LENGTH 12U
|
||||
#define USER_DATABASE_GENERATED_PASSWORD_LENGTH 24U
|
||||
#define USER_DATABASE_SSH_KEY_TYPE_CAPACITY 32U
|
||||
#define USER_DATABASE_SSH_KEY_BLOB_CAPACITY 128U
|
||||
#define USER_DATABASE_SHA256_LENGTH 32U
|
||||
#define USER_DATABASE_ERR_DUPLICATE_SSH_KEY ESP_ERR_NOT_ALLOWED
|
||||
|
||||
typedef enum {
|
||||
USER_ROLE_USER = 1,
|
||||
USER_ROLE_ADMIN = 2,
|
||||
} user_role_t;
|
||||
|
||||
typedef enum {
|
||||
USER_AUTH_METHOD_PASSWORD = 1,
|
||||
USER_AUTH_METHOD_SSH_PUBLIC_KEY = 2,
|
||||
} user_auth_method_t;
|
||||
|
||||
typedef enum {
|
||||
USER_DATABASE_LOAD_STORED = 0,
|
||||
USER_DATABASE_LOAD_MIGRATED_LEGACY,
|
||||
USER_DATABASE_LOAD_EMPTY,
|
||||
} user_database_load_result_t;
|
||||
|
||||
typedef struct {
|
||||
const uint8_t *username;
|
||||
size_t username_length;
|
||||
const uint8_t *password;
|
||||
size_t password_length;
|
||||
} user_database_legacy_credentials_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t user_id;
|
||||
uint32_t auth_generation;
|
||||
user_role_t role;
|
||||
user_auth_method_t method;
|
||||
size_t username_length;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
||||
} user_principal_t;
|
||||
|
||||
typedef struct {
|
||||
size_t password_length;
|
||||
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U];
|
||||
} user_database_generated_password_t;
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
uint8_t index;
|
||||
size_t key_type_length;
|
||||
char key_type[USER_DATABASE_SSH_KEY_TYPE_CAPACITY + 1U];
|
||||
uint8_t sha256_fingerprint[USER_DATABASE_SHA256_LENGTH];
|
||||
} user_database_key_snapshot_t;
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
uint32_t user_id;
|
||||
uint32_t auth_generation;
|
||||
user_role_t role;
|
||||
size_t username_length;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
||||
uint8_t public_key_count;
|
||||
user_database_key_snapshot_t public_keys[USER_DATABASE_MAX_SSH_KEYS_PER_USER];
|
||||
} user_database_user_snapshot_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
bool admin_bootstrapped;
|
||||
uint32_t generation;
|
||||
uint8_t user_count;
|
||||
uint8_t admin_count;
|
||||
user_database_user_snapshot_t users[USER_DATABASE_MAX_USERS];
|
||||
} user_database_snapshot_t;
|
||||
|
||||
esp_err_t user_database_init(const user_database_legacy_credentials_t *legacy,
|
||||
user_database_load_result_t *load_result);
|
||||
/*
|
||||
* Before the first administrator is established, keep the migrated account in
|
||||
* sync with the legacy recovery credential. Once bootstrapped, that credential
|
||||
* remains independent and no longer authenticates Phase 8B network services.
|
||||
*/
|
||||
esp_err_t user_database_sync_legacy_credentials(
|
||||
const user_database_legacy_credentials_t *legacy, bool *synchronized);
|
||||
/* Explicit UART0 recovery: replace unavailable user storage with one legacy user. */
|
||||
esp_err_t user_database_recover_from_legacy(
|
||||
const user_database_legacy_credentials_t *legacy);
|
||||
esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot);
|
||||
|
||||
esp_err_t user_database_authenticate_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
user_principal_t *principal, bool *authenticated);
|
||||
esp_err_t user_database_authorize_ssh_public_key(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *key_type, size_t key_type_length,
|
||||
const uint8_t *key_blob, size_t key_blob_length,
|
||||
user_principal_t *principal, bool *authorized);
|
||||
esp_err_t user_database_principal_is_current(const user_principal_t *principal,
|
||||
bool *current);
|
||||
|
||||
esp_err_t user_database_bootstrap_admin(const uint8_t *password,
|
||||
size_t password_length);
|
||||
esp_err_t user_database_bootstrap_admin_generated(
|
||||
user_database_generated_password_t *generated_password);
|
||||
esp_err_t user_database_create(const uint8_t *username, size_t username_length,
|
||||
user_role_t role,
|
||||
const uint8_t *password, size_t password_length);
|
||||
esp_err_t user_database_create_generated(
|
||||
const uint8_t *username, size_t username_length, user_role_t role,
|
||||
user_database_generated_password_t *generated_password);
|
||||
esp_err_t user_database_delete(const uint8_t *username, size_t username_length);
|
||||
esp_err_t user_database_set_role(const uint8_t *username, size_t username_length,
|
||||
user_role_t role);
|
||||
esp_err_t user_database_set_password(const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length);
|
||||
esp_err_t user_database_generate_password(
|
||||
const uint8_t *username, size_t username_length,
|
||||
user_database_generated_password_t *generated_password);
|
||||
esp_err_t user_database_add_ssh_key(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *key_type, size_t key_type_length,
|
||||
const uint8_t *key_blob, size_t key_blob_length,
|
||||
uint8_t *key_index);
|
||||
esp_err_t user_database_remove_ssh_key(const uint8_t *username,
|
||||
size_t username_length,
|
||||
uint8_t key_index);
|
||||
esp_err_t user_database_clear_ssh_keys(const uint8_t *username,
|
||||
size_t username_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_key_valid(const uint8_t *key_type, size_t key_type_length,
|
||||
const uint8_t *key_blob, size_t key_blob_length);
|
||||
const char *user_role_to_string(user_role_t role);
|
||||
bool user_role_parse(const char *text, user_role_t *role);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Authenticated, bounded WebSocket frontend for the canonical admin console. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "user_database.h"
|
||||
#include "web_session.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define WEB_ADMIN_TRANSPORT_MAX_SESSIONS 1U
|
||||
#define WEB_ADMIN_TRANSPORT_MAX_TICKETS 2U
|
||||
#define WEB_ADMIN_TRANSPORT_TICKET_LENGTH 32U
|
||||
#define WEB_ADMIN_TRANSPORT_TICKET_CAPACITY \
|
||||
(WEB_ADMIN_TRANSPORT_TICKET_LENGTH + 1U)
|
||||
#define WEB_ADMIN_TRANSPORT_TICKET_LIFETIME_SECONDS 30U
|
||||
#define WEB_ADMIN_TRANSPORT_MAX_RX_PAYLOAD 1024U
|
||||
#define WEB_ADMIN_TRANSPORT_PENDING_INPUT_CAPACITY 1024U
|
||||
#define WEB_ADMIN_TRANSPORT_TX_PAYLOAD_SIZE 512U
|
||||
|
||||
#define WEB_ADMIN_TRANSPORT_TICKET_URI "/api/admin/ws-ticket"
|
||||
#define WEB_ADMIN_TRANSPORT_WS_URI "/ws/admin"
|
||||
#define WEB_ADMIN_TRANSPORT_TICKET_QUERY_KEY "ticket"
|
||||
|
||||
typedef struct {
|
||||
uint64_t tickets_issued;
|
||||
uint64_t tickets_consumed;
|
||||
uint64_t tickets_rejected;
|
||||
uint64_t tickets_expired;
|
||||
|
||||
uint64_t connections;
|
||||
uint64_t connection_failures;
|
||||
uint64_t console_admission_failures;
|
||||
uint64_t disconnections;
|
||||
uint64_t session_revocations;
|
||||
uint64_t currentness_failures;
|
||||
|
||||
uint64_t rx_ws_frames_accepted;
|
||||
uint64_t rx_ws_frames_rejected;
|
||||
uint64_t rx_ws_bytes_accepted;
|
||||
uint64_t rx_ws_bytes_rejected;
|
||||
uint64_t input_bytes_fed;
|
||||
uint64_t input_feed_retries;
|
||||
uint64_t input_overflow_closes;
|
||||
|
||||
uint64_t tx_binary_frames;
|
||||
uint64_t tx_binary_bytes;
|
||||
uint64_t send_failures;
|
||||
uint64_t queue_failures;
|
||||
uint64_t protocol_errors;
|
||||
uint64_t close_requests;
|
||||
} web_admin_transport_counters_t;
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
bool principal_valid;
|
||||
bool input_pending;
|
||||
bool tx_pending;
|
||||
bool close_requested;
|
||||
int socket_fd;
|
||||
uint32_t session_id;
|
||||
uint32_t generation;
|
||||
size_t pending_input_bytes;
|
||||
user_role_t user_role;
|
||||
user_auth_method_t auth_method;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
||||
} web_admin_transport_session_snapshot_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
bool server_attached;
|
||||
bool accepting_connections;
|
||||
uint32_t active_sessions;
|
||||
uint32_t active_tickets;
|
||||
web_admin_transport_session_snapshot_t
|
||||
sessions[WEB_ADMIN_TRANSPORT_MAX_SESSIONS];
|
||||
web_admin_transport_counters_t counters;
|
||||
} web_admin_transport_snapshot_t;
|
||||
|
||||
/*
|
||||
* Allocate no heap objects and start the permanent static transport task.
|
||||
* CONFIG_HTTPD_WS_SUPPORT must be enabled. CONFIG_HTTPD_QUEUE_WORK_BLOCKING must
|
||||
* be disabled because that IDF mode can wait forever inside httpd_queue_work().
|
||||
*/
|
||||
esp_err_t web_admin_transport_init(void);
|
||||
|
||||
/* Attach after HTTPD start; detach before stopping that exact server. */
|
||||
esp_err_t web_admin_transport_attach_server(httpd_handle_t server);
|
||||
esp_err_t web_admin_transport_detach_server(httpd_handle_t server);
|
||||
/*
|
||||
* Complete a timed-out detach only after httpd_ssl_stop() has successfully
|
||||
* destroyed that exact server, so discarded queued work can be retired safely.
|
||||
*/
|
||||
esp_err_t web_admin_transport_finalize_stopped_server(httpd_handle_t server);
|
||||
|
||||
|
||||
/*
|
||||
* Convenience POST response helper for /api/admin/ws-ticket. Authentication and
|
||||
* CSRF validation remain outside this module: pass the principal and exact session
|
||||
* reference produced by the authenticated request. Register it as HTTP_POST.
|
||||
*/
|
||||
esp_err_t web_admin_transport_handle_authenticated_ticket_request(
|
||||
httpd_req_t *request, const user_principal_t *principal,
|
||||
const web_session_ref_t *session_reference);
|
||||
|
||||
/*
|
||||
* Handler for /ws/admin. Register as HTTP_GET with is_websocket=true and
|
||||
* handle_ws_control_frames=false. Only complete binary terminal frames are valid.
|
||||
*/
|
||||
esp_err_t web_admin_transport_ws_handler(httpd_req_t *request);
|
||||
|
||||
/* Snapshot and counters contain no ticket, digest, browser-session reference, or data. */
|
||||
esp_err_t web_admin_transport_get_snapshot(
|
||||
web_admin_transport_snapshot_t *snapshot);
|
||||
esp_err_t web_admin_transport_clear_counters(void);
|
||||
|
||||
/* Invalidate tickets and request closure for one exact browser login session. */
|
||||
esp_err_t web_admin_transport_revoke_session(
|
||||
const web_session_ref_t *session_reference);
|
||||
|
||||
/* Invalidate tickets/sessions for one account, or all admin web sessions. */
|
||||
esp_err_t web_admin_transport_revoke_user(const uint8_t *username,
|
||||
size_t username_length);
|
||||
esp_err_t web_admin_transport_revoke_sessions(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Generated web-asset data declarations. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
extern const uint8_t web_asset_xterm_js_gz[];
|
||||
extern const size_t web_asset_xterm_js_gz_size;
|
||||
|
||||
extern const uint8_t web_asset_xterm_css_gz[];
|
||||
extern const size_t web_asset_xterm_css_gz_size;
|
||||
|
||||
extern const uint8_t web_asset_addon_fit_js_gz[];
|
||||
extern const size_t web_asset_addon_fit_js_gz_size;
|
||||
|
||||
extern const uint8_t web_asset_logo_png[];
|
||||
extern const size_t web_asset_logo_png_size;
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Shared HTTPS lifecycle, legacy recovery credential, and certificate commands. */
|
||||
|
||||
#include "web_console.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_console.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "user_database.h"
|
||||
#include "web_admin_transport.h"
|
||||
#include "web_security.h"
|
||||
#include "web_serial_transport.h"
|
||||
#include "web_session.h"
|
||||
#include "web_server.h"
|
||||
|
||||
static void print_usage(void)
|
||||
{
|
||||
printf("Usage:\n");
|
||||
printf(" web status|start|stop\n");
|
||||
printf(" web counters|clear-counters\n");
|
||||
printf(" web credentials show\n");
|
||||
printf(" web credentials rotate --force\n");
|
||||
printf(" web certificate info\n");
|
||||
printf(" web certificate rotate --force\n");
|
||||
printf(" web reset --force\n");
|
||||
}
|
||||
|
||||
static void print_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH])
|
||||
{
|
||||
for (size_t index = 0U; index < WEB_SECURITY_SHA256_LENGTH; ++index) {
|
||||
printf(index == 0U ? "%02X" : ":%02X", (unsigned int)fingerprint[index]);
|
||||
}
|
||||
}
|
||||
|
||||
static int show_status(void)
|
||||
{
|
||||
web_server_snapshot_t snapshot;
|
||||
esp_err_t error = web_server_get_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
printf("HTTPS runtime unavailable: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
user_database_snapshot_t users;
|
||||
esp_err_t users_error = user_database_get_snapshot(&users);
|
||||
printf("HTTPS: initialized=%s running=%s transitioning=%s port=%u last-error=%s\n",
|
||||
snapshot.initialized ? "yes" : "no",
|
||||
snapshot.running ? "yes" : "no",
|
||||
snapshot.transitioning ? "yes" : "no",
|
||||
(unsigned int)snapshot.port,
|
||||
esp_err_to_name(snapshot.last_error));
|
||||
if (users_error == ESP_OK) {
|
||||
printf("Authentication: HTTPS login sessions via user database, active=%" PRIu32 "/%u users=%u admins=%u\n",
|
||||
snapshot.active_sessions, WEB_SESSION_MAX_SESSIONS,
|
||||
(unsigned int)users.user_count, (unsigned int)users.admin_count);
|
||||
} else {
|
||||
printf("Authentication database unavailable: %s; use 'user recover --force'.\n",
|
||||
esp_err_to_name(users_error));
|
||||
}
|
||||
printf("Endpoints: login/logout/session/status, serial WSS, admin WSS, and typed admin Serial/Wi-Fi/broker APIs\n");
|
||||
|
||||
web_admin_transport_snapshot_t admin_transport;
|
||||
esp_err_t admin_transport_error =
|
||||
web_admin_transport_get_snapshot(&admin_transport);
|
||||
if (admin_transport_error == ESP_OK) {
|
||||
printf("WebSocket admin: attached=%s accepting=%s sessions=%" PRIu32
|
||||
"/%u tickets=%" PRIu32 "\n",
|
||||
admin_transport.server_attached ? "yes" : "no",
|
||||
admin_transport.accepting_connections ? "yes" : "no",
|
||||
admin_transport.active_sessions,
|
||||
WEB_ADMIN_TRANSPORT_MAX_SESSIONS,
|
||||
admin_transport.active_tickets);
|
||||
for (size_t index = 0U;
|
||||
index < WEB_ADMIN_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
const web_admin_transport_session_snapshot_t *session =
|
||||
&admin_transport.sessions[index];
|
||||
if (!session->active) {
|
||||
continue;
|
||||
}
|
||||
printf(" admin-web session=%" PRIu32 " generation=%" PRIu32
|
||||
" account=%s input-pending=%u tx-pending=%s closing=%s\n",
|
||||
session->session_id, session->generation,
|
||||
session->principal_valid ? session->username : "-",
|
||||
(unsigned int)session->pending_input_bytes,
|
||||
session->tx_pending ? "yes" : "no",
|
||||
session->close_requested ? "yes" : "no");
|
||||
}
|
||||
} else {
|
||||
printf("WebSocket admin transport unavailable: %s\n",
|
||||
esp_err_to_name(snapshot.admin_transport_error));
|
||||
}
|
||||
|
||||
web_serial_transport_snapshot_t transport;
|
||||
esp_err_t transport_error = web_serial_transport_get_snapshot(&transport);
|
||||
if (transport_error != ESP_OK) {
|
||||
printf("WebSocket serial transport unavailable: %s\n",
|
||||
esp_err_to_name(snapshot.serial_transport_error));
|
||||
return 0;
|
||||
}
|
||||
|
||||
printf("WebSocket serial: attached=%s sessions=%" PRIu32 "/%u tickets=%" PRIu32 "\n",
|
||||
transport.server_attached ? "yes" : "no",
|
||||
transport.active_sessions,
|
||||
WEB_SERIAL_TRANSPORT_MAX_SESSIONS,
|
||||
transport.active_tickets);
|
||||
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
const web_serial_transport_session_snapshot_t *session =
|
||||
&transport.sessions[index];
|
||||
if (!session->active) {
|
||||
continue;
|
||||
}
|
||||
printf(" slot=%u fd=%d generation=%" PRIu32 " account=%s user-role=%s"
|
||||
" method=%s broker=%" PRIu32
|
||||
" broker-role=%s tx-pending=%s closing=%s\n",
|
||||
(unsigned int)index,
|
||||
session->socket_fd,
|
||||
session->generation,
|
||||
session->principal_valid ? session->username : "-",
|
||||
session->principal_valid
|
||||
? user_role_to_string(session->user_role)
|
||||
: "-",
|
||||
session->principal_valid &&
|
||||
session->auth_method == USER_AUTH_METHOD_PASSWORD
|
||||
? "password"
|
||||
: "unknown",
|
||||
session->broker_client_id,
|
||||
session->writer ? "writer" : "observer",
|
||||
session->tx_pending ? "yes" : "no",
|
||||
session->close_requested ? "yes" : "no");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int show_counters(void)
|
||||
{
|
||||
web_server_snapshot_t snapshot;
|
||||
esp_err_t error = web_server_get_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read HTTPS counters: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const web_server_counters_t *counter = &snapshot.counters;
|
||||
printf("Lifecycle: starts=%" PRIu64 " start-failures=%" PRIu64
|
||||
" stops=%" PRIu64 "\n",
|
||||
counter->starts, counter->start_failures, counter->stops);
|
||||
printf("Requests: total=%" PRIu64 " authenticated=%" PRIu64
|
||||
" auth-failures=%" PRIu64 " root=%" PRIu64
|
||||
" status=%" PRIu64 " tickets=%" PRIu64 " assets=%" PRIu64
|
||||
" response-errors=%" PRIu64 "\n",
|
||||
counter->requests, counter->authenticated_requests,
|
||||
counter->authentication_failures, counter->root_requests,
|
||||
counter->status_requests, counter->ticket_requests,
|
||||
counter->asset_requests, counter->response_errors);
|
||||
printf("Login sessions: active=%" PRIu32 "/%u login=%" PRIu64
|
||||
" success=%" PRIu64 " failure=%" PRIu64 " throttled=%" PRIu64
|
||||
" logout=%" PRIu64 " session-info=%" PRIu64 "\n",
|
||||
snapshot.active_sessions, WEB_SESSION_MAX_SESSIONS,
|
||||
counter->login_requests, counter->login_successes,
|
||||
counter->login_failures, counter->login_throttled,
|
||||
counter->logout_requests, counter->session_requests);
|
||||
const web_session_counters_t *sessions = &snapshot.session_counters;
|
||||
printf("Session table: created=%" PRIu64 " create-failures=%" PRIu64
|
||||
" capacity=%" PRIu64 " authenticated=%" PRIu64
|
||||
" rejected=%" PRIu64 " expired=%" PRIu64 "\n",
|
||||
sessions->created, sessions->create_failures,
|
||||
sessions->capacity_failures, sessions->authenticated,
|
||||
sessions->rejected, sessions->expired);
|
||||
printf("Session lifecycle: stale-principal=%" PRIu64
|
||||
" destroyed=%" PRIu64 " revocations=%" PRIu64
|
||||
" csrf-accepted=%" PRIu64 " csrf-rejected=%" PRIu64 "\n",
|
||||
sessions->stale_principal, sessions->destroyed,
|
||||
sessions->revocations, sessions->csrf_accepted,
|
||||
sessions->csrf_rejected);
|
||||
printf("Request rejection: cookie=%" PRIu64 " origin=%" PRIu64
|
||||
" csrf=%" PRIu64 "\n",
|
||||
counter->cookie_rejections, counter->origin_rejections,
|
||||
counter->csrf_rejections);
|
||||
printf("Admin API: tickets=%" PRIu64 " auth-denied=%" PRIu64
|
||||
" requests=%" PRIu64 " rejected=%" PRIu64
|
||||
" operation-failures=%" PRIu64 "\n",
|
||||
counter->admin_ticket_requests,
|
||||
counter->admin_authorization_failures,
|
||||
counter->admin_api_requests,
|
||||
counter->admin_request_rejections,
|
||||
counter->admin_operation_failures);
|
||||
printf("Writer transfer: attempts=%" PRIu64 " success=%" PRIu64
|
||||
" conflicts=%" PRIu64 "\n",
|
||||
counter->writer_transfer_attempts,
|
||||
counter->writer_transfer_successes,
|
||||
counter->writer_transfer_conflicts);
|
||||
|
||||
web_admin_transport_snapshot_t admin_transport;
|
||||
esp_err_t admin_error =
|
||||
web_admin_transport_get_snapshot(&admin_transport);
|
||||
if (admin_error == ESP_OK) {
|
||||
const web_admin_transport_counters_t *admin =
|
||||
&admin_transport.counters;
|
||||
printf("Admin WebSocket tickets: issued=%" PRIu64
|
||||
" consumed=%" PRIu64 " rejected=%" PRIu64
|
||||
" expired=%" PRIu64 "\n",
|
||||
admin->tickets_issued, admin->tickets_consumed,
|
||||
admin->tickets_rejected, admin->tickets_expired);
|
||||
printf("Admin WebSocket sessions: connect=%" PRIu64
|
||||
" failures=%" PRIu64 " admission-failures=%" PRIu64
|
||||
" disconnect=%" PRIu64 " revocations=%" PRIu64
|
||||
" stale=%" PRIu64 "\n",
|
||||
admin->connections, admin->connection_failures,
|
||||
admin->console_admission_failures, admin->disconnections,
|
||||
admin->session_revocations, admin->currentness_failures);
|
||||
printf("Admin WebSocket I/O: rx-frames=%" PRIu64
|
||||
" rx-rejected=%" PRIu64 " rx-bytes=%" PRIu64
|
||||
" rx-bytes-rejected=%" PRIu64 " fed=%" PRIu64
|
||||
" retries=%" PRIu64 " overflow-close=%" PRIu64
|
||||
" tx-frames=%" PRIu64 " tx-bytes=%" PRIu64 "\n",
|
||||
admin->rx_ws_frames_accepted,
|
||||
admin->rx_ws_frames_rejected,
|
||||
admin->rx_ws_bytes_accepted,
|
||||
admin->rx_ws_bytes_rejected, admin->input_bytes_fed,
|
||||
admin->input_feed_retries, admin->input_overflow_closes,
|
||||
admin->tx_binary_frames, admin->tx_binary_bytes);
|
||||
printf("Admin WebSocket failures: send=%" PRIu64
|
||||
" queue=%" PRIu64 " protocol=%" PRIu64
|
||||
" closes=%" PRIu64 "\n",
|
||||
admin->send_failures, admin->queue_failures,
|
||||
admin->protocol_errors, admin->close_requests);
|
||||
} else {
|
||||
printf("Admin WebSocket counters unavailable: %s\n",
|
||||
esp_err_to_name(admin_error));
|
||||
}
|
||||
|
||||
web_serial_transport_snapshot_t transport;
|
||||
error = web_serial_transport_get_snapshot(&transport);
|
||||
if (error != ESP_OK) {
|
||||
printf("WebSocket serial counters unavailable: %s\n", esp_err_to_name(error));
|
||||
return 0;
|
||||
}
|
||||
const web_serial_transport_counters_t *websocket = &transport.counters;
|
||||
printf("Tickets: issued=%" PRIu64 " consumed=%" PRIu64
|
||||
" rejected=%" PRIu64 " expired=%" PRIu64 "\n",
|
||||
websocket->tickets_issued, websocket->tickets_consumed,
|
||||
websocket->tickets_rejected, websocket->tickets_expired);
|
||||
printf("WebSocket sessions: connect=%" PRIu64 " failures=%" PRIu64
|
||||
" disconnect=%" PRIu64 " service-start-failures=%" PRIu64
|
||||
" broker-failures=%" PRIu64 "\n",
|
||||
websocket->connections, websocket->connection_failures,
|
||||
websocket->disconnections, websocket->service_start_failures,
|
||||
websocket->broker_failures);
|
||||
printf("WebSocket RX: frames-ok=%" PRIu64 " frames-rejected=%" PRIu64
|
||||
" bytes-ok=%" PRIu64 " bytes-rejected=%" PRIu64 "\n",
|
||||
websocket->rx_ws_frames_accepted,
|
||||
websocket->rx_ws_frames_rejected,
|
||||
websocket->rx_ws_bytes_accepted,
|
||||
websocket->rx_ws_bytes_rejected);
|
||||
printf("WebSocket TX: binary-frames=%" PRIu64 " binary-bytes=%" PRIu64
|
||||
" control-frames=%" PRIu64 " control-bytes=%" PRIu64 "\n",
|
||||
websocket->tx_binary_frames, websocket->tx_binary_bytes,
|
||||
websocket->tx_control_frames, websocket->tx_control_bytes);
|
||||
printf("WebSocket control: writer-requests=%" PRIu64
|
||||
" grants=%" PRIu64 " denials=%" PRIu64
|
||||
" releases=%" PRIu64 " revocations=%" PRIu64 "\n",
|
||||
websocket->writer_requests, websocket->writer_grants,
|
||||
websocket->writer_denials, websocket->writer_releases,
|
||||
websocket->writer_revocations);
|
||||
printf("WebSocket failures: send=%" PRIu64 " queue=%" PRIu64
|
||||
" protocol=%" PRIu64 " closes=%" PRIu64 "\n",
|
||||
websocket->send_failures, websocket->queue_failures,
|
||||
websocket->protocol_errors, websocket->close_requests);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int show_credentials(void)
|
||||
{
|
||||
web_security_credentials_t credentials;
|
||||
esp_err_t error = web_security_show_credentials(&credentials);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read web credentials: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("Username: %.*s\n", (int)credentials.username_length,
|
||||
credentials.username);
|
||||
printf("Password: %.*s\n", (int)credentials.password_length,
|
||||
credentials.password);
|
||||
printf("Phase 8B uses the user database for HTTPS and SSH authentication.\n");
|
||||
printf("This legacy credential is retained only for migration and physical recovery.\n");
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int show_certificate(void)
|
||||
{
|
||||
web_security_certificate_metadata_t metadata;
|
||||
esp_err_t error = web_security_get_certificate_metadata(&metadata);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read certificate information: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("Security material generation=%" PRIu32 " type=ECDSA-P256 signature=SHA-256\n",
|
||||
metadata.material_generation);
|
||||
printf("Subject/issuer CN: %s\n", metadata.common_name);
|
||||
printf("Validity: %sZ to %sZ\n", metadata.not_before, metadata.not_after);
|
||||
printf("SAN: DNS:%s, IP:%u.%u.%u.%u\n",
|
||||
metadata.dns_name,
|
||||
(unsigned int)metadata.ipv4_address[0],
|
||||
(unsigned int)metadata.ipv4_address[1],
|
||||
(unsigned int)metadata.ipv4_address[2],
|
||||
(unsigned int)metadata.ipv4_address[3]);
|
||||
printf("SHA-256 fingerprint: ");
|
||||
print_fingerprint(metadata.sha256_fingerprint);
|
||||
putchar('\n');
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool force_is_present(int argc, char **argv, int expected_argc)
|
||||
{
|
||||
return argc == expected_argc && strcmp(argv[expected_argc - 1], "--force") == 0;
|
||||
}
|
||||
|
||||
static int refresh_tls_after_material_change(
|
||||
const web_server_snapshot_t *before, bool ensure_running)
|
||||
{
|
||||
bool start_if_unchanged = ensure_running || before->desired_running;
|
||||
if (admin_ssh_console_dispatch_frontend() ==
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_WEB) {
|
||||
admin_ssh_deferred_action_type_t action = start_if_unchanged
|
||||
? ADMIN_SSH_DEFER_WEB_TLS_REFRESH_RUNNING
|
||||
: ADMIN_SSH_DEFER_WEB_TLS_REFRESH_STOPPED;
|
||||
esp_err_t error = admin_ssh_console_dispatch_defer(
|
||||
action, before->lifecycle_generation);
|
||||
if (error == ESP_OK) {
|
||||
printf("HTTPS TLS refresh scheduled after administrative output drains.\n");
|
||||
return 0;
|
||||
}
|
||||
printf("Could not schedule deferred HTTPS TLS refresh: %s; applying it now.\n",
|
||||
esp_err_to_name(error));
|
||||
}
|
||||
|
||||
esp_err_t error = web_server_refresh_tls(
|
||||
before->lifecycle_generation, start_if_unchanged);
|
||||
if (error != ESP_OK) {
|
||||
printf("Security material changed, but HTTPS could not apply it: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void synchronize_migrated_user(
|
||||
const web_security_credentials_t *credentials)
|
||||
{
|
||||
const user_database_legacy_credentials_t legacy = {
|
||||
.username = (const uint8_t *)credentials->username,
|
||||
.username_length = credentials->username_length,
|
||||
.password = (const uint8_t *)credentials->password,
|
||||
.password_length = credentials->password_length,
|
||||
};
|
||||
bool synchronized = false;
|
||||
esp_err_t error = user_database_sync_legacy_credentials(&legacy, &synchronized);
|
||||
if (error != ESP_OK) {
|
||||
printf("Warning: migrated user synchronization failed: %s. Boot will retry a valid stored database; otherwise use 'user recover --force'.\n",
|
||||
esp_err_to_name(error));
|
||||
return;
|
||||
}
|
||||
if (synchronized) {
|
||||
(void)web_server_revoke_user(
|
||||
(const uint8_t *)credentials->username,
|
||||
credentials->username_length);
|
||||
(void)ssh_transport_revoke_user(
|
||||
(const uint8_t *)credentials->username,
|
||||
credentials->username_length);
|
||||
printf("The pre-bootstrap migrated user credential was synchronized.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
user_database_snapshot_t snapshot;
|
||||
if (user_database_get_snapshot(&snapshot) == ESP_OK &&
|
||||
snapshot.admin_bootstrapped) {
|
||||
printf("This legacy recovery credential is separate from role-based user passwords.\n");
|
||||
} else {
|
||||
printf("Warning: no matching pre-bootstrap migrated user was synchronized; establish an administrator with 'user bootstrap'.\n");
|
||||
}
|
||||
}
|
||||
|
||||
static int rotate_credentials(void)
|
||||
{
|
||||
web_security_credentials_t credentials;
|
||||
esp_err_t error = web_security_rotate_credentials(&credentials);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not rotate web credentials: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
synchronize_migrated_user(&credentials);
|
||||
printf("Legacy migration/recovery credential rotated and persisted.\n");
|
||||
printf("Username: %.*s\nPassword: %.*s\n",
|
||||
(int)credentials.username_length, credentials.username,
|
||||
(int)credentials.password_length, credentials.password);
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int rotate_certificate(void)
|
||||
{
|
||||
web_server_snapshot_t snapshot;
|
||||
esp_err_t error = web_server_get_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not inspect HTTPS runtime: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
error = web_security_rotate_certificate();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not rotate web certificate: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("Web certificate and private key rotated and persisted.\n");
|
||||
return refresh_tls_after_material_change(&snapshot, false);
|
||||
}
|
||||
|
||||
static int reset_material(void)
|
||||
{
|
||||
web_server_snapshot_t snapshot;
|
||||
esp_err_t error = web_server_get_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not inspect HTTPS runtime: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
web_security_credentials_t credentials;
|
||||
error = web_security_reset_all(&credentials);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not reset web security material: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
synchronize_migrated_user(&credentials);
|
||||
printf("Legacy recovery credential, HTTPS certificate, and HTTPS private key replaced and persisted.\n");
|
||||
printf("Username: %.*s\nPassword: %.*s\n",
|
||||
(int)credentials.username_length, credentials.username,
|
||||
(int)credentials.password_length, credentials.password);
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
return refresh_tls_after_material_change(&snapshot, true);
|
||||
}
|
||||
|
||||
static int command_web(int argc, char **argv)
|
||||
{
|
||||
if (argc == 1 || (argc == 2 && strcmp(argv[1], "help") == 0)) {
|
||||
print_usage();
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "status") == 0) {
|
||||
return show_status();
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "start") == 0) {
|
||||
esp_err_t error = web_server_start();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not start HTTPS: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS started on TCP port 443.\n");
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "stop") == 0) {
|
||||
if (admin_ssh_console_dispatch_frontend() ==
|
||||
ADMIN_SSH_CONSOLE_FRONTEND_WEB) {
|
||||
web_server_snapshot_t snapshot;
|
||||
esp_err_t error = web_server_get_snapshot(&snapshot);
|
||||
if (error == ESP_OK) {
|
||||
error = admin_ssh_console_dispatch_defer(
|
||||
ADMIN_SSH_DEFER_WEB_STOP,
|
||||
snapshot.lifecycle_generation);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not schedule HTTPS stop: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS stop scheduled after administrative output drains.\n");
|
||||
return 0;
|
||||
}
|
||||
esp_err_t error = web_server_stop();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not stop HTTPS: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS stopped.\n");
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "counters") == 0) {
|
||||
return show_counters();
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "clear-counters") == 0) {
|
||||
esp_err_t error = web_server_clear_counters();
|
||||
if (error == ESP_OK) {
|
||||
error = web_serial_transport_clear_counters();
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = web_admin_transport_clear_counters();
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not clear web counters: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS and WebSocket counters cleared.\n");
|
||||
return 0;
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "credentials") == 0 &&
|
||||
strcmp(argv[2], "show") == 0) {
|
||||
return show_credentials();
|
||||
}
|
||||
if (strcmp(argv[1], "credentials") == 0 && argc >= 3 &&
|
||||
strcmp(argv[2], "rotate") == 0) {
|
||||
if (!force_is_present(argc, argv, 4)) {
|
||||
printf("Credential rotation requires: web credentials rotate --force\n");
|
||||
return 1;
|
||||
}
|
||||
return rotate_credentials();
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "certificate") == 0 &&
|
||||
strcmp(argv[2], "info") == 0) {
|
||||
return show_certificate();
|
||||
}
|
||||
if (strcmp(argv[1], "certificate") == 0 && argc >= 3 &&
|
||||
strcmp(argv[2], "rotate") == 0) {
|
||||
if (!force_is_present(argc, argv, 4)) {
|
||||
printf("Certificate rotation requires: web certificate rotate --force\n");
|
||||
return 1;
|
||||
}
|
||||
return rotate_certificate();
|
||||
}
|
||||
if (strcmp(argv[1], "reset") == 0) {
|
||||
if (!force_is_present(argc, argv, 3)) {
|
||||
printf("Full material replacement requires: web reset --force\n");
|
||||
return 1;
|
||||
}
|
||||
return reset_material();
|
||||
}
|
||||
|
||||
print_usage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
esp_err_t web_console_register_commands(void)
|
||||
{
|
||||
const esp_console_cmd_t command = {
|
||||
.command = "web",
|
||||
.help = "Manage authenticated HTTPS and recover web credentials/certificate",
|
||||
.hint = NULL,
|
||||
.func = &command_web,
|
||||
.argtable = NULL,
|
||||
};
|
||||
return esp_console_cmd_register(&command);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Physical UART0 administration commands for HTTPS recovery. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
esp_err_t web_console_register_commands(void);
|
||||
@@ -0,0 +1,879 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Canonical NVS storage for HTTPS identity and legacy recovery credentials. */
|
||||
|
||||
#include "web_security.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_mac.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "mbedtls/asn1.h"
|
||||
#include "mbedtls/ecp.h"
|
||||
#include "mbedtls/oid.h"
|
||||
#include "mbedtls/pk.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "mbedtls/x509_crt.h"
|
||||
#include "nvs.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
#define WEB_SECURITY_SCHEMA_VERSION 1U
|
||||
#define WEB_SECURITY_BLOB_SIZE 1392U
|
||||
|
||||
static const uint8_t s_admin_username[] = "admin";
|
||||
static const char s_password_alphabet[] =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
static const uint8_t s_ap_ipv4_address[4] = {192U, 168U, 4U, 1U};
|
||||
|
||||
typedef struct {
|
||||
uint32_t schema_version;
|
||||
uint16_t blob_size;
|
||||
uint16_t reserved_header;
|
||||
uint32_t generation;
|
||||
uint8_t username_length;
|
||||
uint8_t password_length;
|
||||
uint16_t private_key_length;
|
||||
uint16_t certificate_length;
|
||||
uint16_t reserved_lengths;
|
||||
uint8_t username[WEB_SECURITY_USERNAME_CAPACITY];
|
||||
uint8_t password[WEB_SECURITY_PASSWORD_CAPACITY];
|
||||
uint8_t private_key_der[WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY];
|
||||
uint8_t certificate_der[WEB_SECURITY_CERTIFICATE_DER_CAPACITY];
|
||||
uint8_t certificate_fingerprint[WEB_SECURITY_SHA256_LENGTH];
|
||||
uint8_t reserved[12];
|
||||
} web_security_blob_t;
|
||||
|
||||
_Static_assert(offsetof(web_security_blob_t, username) == 20U,
|
||||
"web security schema offsets changed");
|
||||
_Static_assert(offsetof(web_security_blob_t, private_key_der) == 68U,
|
||||
"web security key offset changed");
|
||||
_Static_assert(offsetof(web_security_blob_t, certificate_der) == 324U,
|
||||
"web security certificate offset changed");
|
||||
_Static_assert(sizeof(web_security_blob_t) == WEB_SECURITY_BLOB_SIZE,
|
||||
"web security schema size changed");
|
||||
|
||||
static SemaphoreHandle_t s_security_mutex;
|
||||
static web_security_blob_t s_material;
|
||||
static bool s_material_ready;
|
||||
static web_security_load_result_t s_load_result;
|
||||
|
||||
static bool bytes_are_zero(const uint8_t *data, size_t size)
|
||||
{
|
||||
for (size_t i = 0U; i < size; ++i) {
|
||||
if (data[i] != 0U) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool unused_bytes_are_zero(const uint8_t *data, size_t used, size_t capacity)
|
||||
{
|
||||
return used <= capacity && bytes_are_zero(data + used, capacity - used);
|
||||
}
|
||||
|
||||
static bool constant_time_equal(const uint8_t *left, const uint8_t *right, size_t size)
|
||||
{
|
||||
uint8_t difference = 0U;
|
||||
for (size_t i = 0U; i < size; ++i) {
|
||||
difference |= left[i] ^ right[i];
|
||||
}
|
||||
return difference == 0U;
|
||||
}
|
||||
|
||||
static esp_err_t ensure_security_mutex(void)
|
||||
{
|
||||
if (s_security_mutex != NULL) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
s_security_mutex = xSemaphoreCreateMutex();
|
||||
return s_security_mutex != NULL ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
static esp_err_t build_device_names(char *common_name, size_t common_name_size,
|
||||
char *dns_name, size_t dns_name_size)
|
||||
{
|
||||
uint8_t mac[6] = {0};
|
||||
esp_err_t error = esp_read_mac(mac, ESP_MAC_WIFI_SOFTAP);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
int common_length = snprintf(common_name, common_name_size,
|
||||
"ESP32 SAK %02X%02X%02X",
|
||||
mac[3], mac[4], mac[5]);
|
||||
int dns_length = snprintf(dns_name, dns_name_size,
|
||||
"esp32-sak-%02x%02x%02x.local",
|
||||
mac[3], mac[4], mac[5]);
|
||||
if (common_length < 0 || (size_t)common_length >= common_name_size ||
|
||||
dns_length < 0 || (size_t)dns_length >= dns_name_size) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t generate_credentials(web_security_blob_t *blob)
|
||||
{
|
||||
uint8_t random_bytes[WEB_SECURITY_PASSWORD_LENGTH] = {0};
|
||||
|
||||
memset(blob->username, 0, sizeof(blob->username));
|
||||
memset(blob->password, 0, sizeof(blob->password));
|
||||
memcpy(blob->username, s_admin_username, sizeof(s_admin_username) - 1U);
|
||||
blob->username_length = sizeof(s_admin_username) - 1U;
|
||||
blob->password_length = WEB_SECURITY_PASSWORD_LENGTH;
|
||||
|
||||
esp_err_t error = secure_random_fill(random_bytes, sizeof(random_bytes));
|
||||
if (error == ESP_OK) {
|
||||
/* Sixty-four symbols consume six random bits exactly, without modulo bias. */
|
||||
for (size_t i = 0U; i < sizeof(random_bytes); ++i) {
|
||||
blob->password[i] =
|
||||
(uint8_t)s_password_alphabet[random_bytes[i] & 0x3fU];
|
||||
}
|
||||
}
|
||||
secure_wipe(random_bytes, sizeof(random_bytes));
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t normalize_der(unsigned char *buffer, size_t capacity,
|
||||
int written, uint16_t *output_length)
|
||||
{
|
||||
if (written <= 0 || (size_t)written > capacity || written > UINT16_MAX) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Mbed TLS DER writers grow backward from the end of the output buffer. */
|
||||
memmove(buffer, buffer + capacity - (size_t)written, (size_t)written);
|
||||
memset(buffer + (size_t)written, 0, capacity - (size_t)written);
|
||||
*output_length = (uint16_t)written;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t generate_certificate(web_security_blob_t *blob)
|
||||
{
|
||||
char common_name[WEB_SECURITY_COMMON_NAME_CAPACITY] = {0};
|
||||
char dns_name[WEB_SECURITY_DNS_NAME_CAPACITY] = {0};
|
||||
char distinguished_name[WEB_SECURITY_COMMON_NAME_CAPACITY + 3U] = {0};
|
||||
unsigned char serial[16] = {0};
|
||||
mbedtls_pk_context key;
|
||||
mbedtls_x509write_cert writer;
|
||||
esp_err_t error = ESP_FAIL;
|
||||
int result;
|
||||
|
||||
mbedtls_pk_init(&key);
|
||||
mbedtls_x509write_crt_init(&writer);
|
||||
memset(blob->private_key_der, 0, sizeof(blob->private_key_der));
|
||||
memset(blob->certificate_der, 0, sizeof(blob->certificate_der));
|
||||
memset(blob->certificate_fingerprint, 0, sizeof(blob->certificate_fingerprint));
|
||||
blob->private_key_length = 0U;
|
||||
blob->certificate_length = 0U;
|
||||
|
||||
error = build_device_names(common_name, sizeof(common_name),
|
||||
dns_name, sizeof(dns_name));
|
||||
if (error != ESP_OK) {
|
||||
goto cleanup;
|
||||
}
|
||||
result = snprintf(distinguished_name, sizeof(distinguished_name),
|
||||
"CN=%s", common_name);
|
||||
if (result < 0 || (size_t)result >= sizeof(distinguished_name)) {
|
||||
error = ESP_ERR_INVALID_SIZE;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
result = mbedtls_pk_setup(&key, mbedtls_pk_info_from_type(MBEDTLS_PK_ECKEY));
|
||||
if (result != 0) {
|
||||
error = ESP_ERR_NO_MEM;
|
||||
goto cleanup;
|
||||
}
|
||||
result = mbedtls_ecp_gen_key(MBEDTLS_ECP_DP_SECP256R1,
|
||||
mbedtls_pk_ec(key),
|
||||
secure_random_mbedtls, NULL);
|
||||
if (result != 0) {
|
||||
error = ESP_FAIL;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
result = mbedtls_pk_write_key_der(&key, blob->private_key_der,
|
||||
sizeof(blob->private_key_der));
|
||||
error = normalize_der(blob->private_key_der, sizeof(blob->private_key_der),
|
||||
result, &blob->private_key_length);
|
||||
if (error != ESP_OK) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
error = secure_random_fill(serial, sizeof(serial));
|
||||
if (error != ESP_OK) {
|
||||
goto cleanup;
|
||||
}
|
||||
/* RFC 5280 serials are positive and nonzero; retain 127 random bits. */
|
||||
serial[0] &= 0x7fU;
|
||||
serial[0] |= 0x01U;
|
||||
|
||||
mbedtls_x509_san_list dns_san = {0};
|
||||
mbedtls_x509_san_list ip_san = {0};
|
||||
dns_san.node.type = MBEDTLS_X509_SAN_DNS_NAME;
|
||||
dns_san.node.san.unstructured_name.p = (unsigned char *)dns_name;
|
||||
dns_san.node.san.unstructured_name.len = strlen(dns_name);
|
||||
dns_san.next = &ip_san;
|
||||
ip_san.node.type = MBEDTLS_X509_SAN_IP_ADDRESS;
|
||||
ip_san.node.san.unstructured_name.p = (unsigned char *)s_ap_ipv4_address;
|
||||
ip_san.node.san.unstructured_name.len = sizeof(s_ap_ipv4_address);
|
||||
|
||||
mbedtls_asn1_sequence server_auth = {0};
|
||||
server_auth.buf.tag = MBEDTLS_ASN1_OID;
|
||||
server_auth.buf.p = (unsigned char *)MBEDTLS_OID_SERVER_AUTH;
|
||||
server_auth.buf.len = MBEDTLS_OID_SIZE(MBEDTLS_OID_SERVER_AUTH);
|
||||
|
||||
mbedtls_x509write_crt_set_version(&writer, MBEDTLS_X509_CRT_VERSION_3);
|
||||
mbedtls_x509write_crt_set_md_alg(&writer, MBEDTLS_MD_SHA256);
|
||||
mbedtls_x509write_crt_set_subject_key(&writer, &key);
|
||||
mbedtls_x509write_crt_set_issuer_key(&writer, &key);
|
||||
|
||||
result = mbedtls_x509write_crt_set_serial_raw(&writer, serial, sizeof(serial));
|
||||
if (result == 0) {
|
||||
result = mbedtls_x509write_crt_set_validity(
|
||||
&writer, WEB_SECURITY_CERT_NOT_BEFORE, WEB_SECURITY_CERT_NOT_AFTER);
|
||||
}
|
||||
if (result == 0) {
|
||||
result = mbedtls_x509write_crt_set_subject_name(&writer, distinguished_name);
|
||||
}
|
||||
if (result == 0) {
|
||||
result = mbedtls_x509write_crt_set_issuer_name(&writer, distinguished_name);
|
||||
}
|
||||
if (result == 0) {
|
||||
result = mbedtls_x509write_crt_set_basic_constraints(&writer, 0, -1);
|
||||
}
|
||||
if (result == 0) {
|
||||
result = mbedtls_x509write_crt_set_key_usage(
|
||||
&writer, MBEDTLS_X509_KU_DIGITAL_SIGNATURE);
|
||||
}
|
||||
if (result == 0) {
|
||||
result = mbedtls_x509write_crt_set_ext_key_usage(&writer, &server_auth);
|
||||
}
|
||||
if (result == 0) {
|
||||
result = mbedtls_x509write_crt_set_subject_alternative_name(&writer,
|
||||
&dns_san);
|
||||
}
|
||||
if (result != 0) {
|
||||
error = ESP_FAIL;
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
result = mbedtls_x509write_crt_der(&writer, blob->certificate_der,
|
||||
sizeof(blob->certificate_der),
|
||||
secure_random_mbedtls, NULL);
|
||||
error = normalize_der(blob->certificate_der, sizeof(blob->certificate_der),
|
||||
result, &blob->certificate_length);
|
||||
if (error != ESP_OK) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (mbedtls_sha256(blob->certificate_der, blob->certificate_length,
|
||||
blob->certificate_fingerprint, 0) != 0) {
|
||||
error = ESP_FAIL;
|
||||
goto cleanup;
|
||||
}
|
||||
error = ESP_OK;
|
||||
|
||||
cleanup:
|
||||
secure_wipe(serial, sizeof(serial));
|
||||
mbedtls_x509write_crt_free(&writer);
|
||||
mbedtls_pk_free(&key);
|
||||
return error;
|
||||
}
|
||||
|
||||
static bool x509_time_equals(const mbedtls_x509_time *time,
|
||||
int year, int month, int day,
|
||||
int hour, int minute, int second)
|
||||
{
|
||||
return time->year == year && time->mon == month && time->day == day &&
|
||||
time->hour == hour && time->min == minute && time->sec == second;
|
||||
}
|
||||
|
||||
static bool name_is_single_common_name(const mbedtls_x509_name *name,
|
||||
const char *expected)
|
||||
{
|
||||
size_t expected_length = strlen(expected);
|
||||
return name != NULL && name->next == NULL &&
|
||||
name->oid.len == MBEDTLS_OID_SIZE(MBEDTLS_OID_AT_CN) &&
|
||||
memcmp(name->oid.p, MBEDTLS_OID_AT_CN, name->oid.len) == 0 &&
|
||||
name->val.len == expected_length &&
|
||||
memcmp(name->val.p, expected, expected_length) == 0;
|
||||
}
|
||||
|
||||
static bool extended_key_usage_is_server_auth_only(const mbedtls_x509_crt *certificate)
|
||||
{
|
||||
const mbedtls_x509_sequence *usage = &certificate->ext_key_usage;
|
||||
return usage->next == NULL &&
|
||||
usage->buf.p != NULL &&
|
||||
usage->buf.len == MBEDTLS_OID_SIZE(MBEDTLS_OID_SERVER_AUTH) &&
|
||||
memcmp(usage->buf.p, MBEDTLS_OID_SERVER_AUTH, usage->buf.len) == 0;
|
||||
}
|
||||
|
||||
static bool subject_alt_names_are_expected(const mbedtls_x509_crt *certificate,
|
||||
const char *dns_name)
|
||||
{
|
||||
bool found_dns = false;
|
||||
bool found_ip = false;
|
||||
|
||||
for (const mbedtls_x509_sequence *item = &certificate->subject_alt_names;
|
||||
item != NULL && item->buf.p != NULL; item = item->next) {
|
||||
if (item->buf.tag == (MBEDTLS_ASN1_CONTEXT_SPECIFIC |
|
||||
MBEDTLS_X509_SAN_DNS_NAME) &&
|
||||
item->buf.len == strlen(dns_name) &&
|
||||
memcmp(item->buf.p, dns_name, item->buf.len) == 0 && !found_dns) {
|
||||
found_dns = true;
|
||||
} else if (item->buf.tag == (MBEDTLS_ASN1_CONTEXT_SPECIFIC |
|
||||
MBEDTLS_X509_SAN_IP_ADDRESS) &&
|
||||
item->buf.len == sizeof(s_ap_ipv4_address) &&
|
||||
memcmp(item->buf.p, s_ap_ipv4_address, item->buf.len) == 0 &&
|
||||
!found_ip) {
|
||||
found_ip = true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return found_dns && found_ip;
|
||||
}
|
||||
|
||||
static bool certificate_self_signature_is_valid(mbedtls_x509_crt *certificate)
|
||||
{
|
||||
unsigned char *cursor = certificate->raw.p;
|
||||
const unsigned char *end = certificate->raw.p + certificate->raw.len;
|
||||
const unsigned char *outer_end;
|
||||
const unsigned char *signature;
|
||||
size_t outer_length = 0U;
|
||||
size_t field_length = 0U;
|
||||
size_t signature_length = 0U;
|
||||
uint8_t digest[WEB_SECURITY_SHA256_LENGTH] = {0};
|
||||
bool valid = false;
|
||||
|
||||
/*
|
||||
* Mbed TLS exposes the TBS bytes but not the signature bytes. Parse only
|
||||
* the certificate's three outer fields instead of relying on private ABI.
|
||||
*/
|
||||
if (mbedtls_asn1_get_tag(&cursor, end, &outer_length,
|
||||
MBEDTLS_ASN1_CONSTRUCTED |
|
||||
MBEDTLS_ASN1_SEQUENCE) != 0 ||
|
||||
outer_length != (size_t)(end - cursor)) {
|
||||
goto cleanup;
|
||||
}
|
||||
outer_end = cursor + outer_length;
|
||||
if (mbedtls_asn1_get_tag(&cursor, outer_end, &field_length,
|
||||
MBEDTLS_ASN1_CONSTRUCTED |
|
||||
MBEDTLS_ASN1_SEQUENCE) != 0 ||
|
||||
field_length > (size_t)(outer_end - cursor)) {
|
||||
goto cleanup;
|
||||
}
|
||||
cursor += field_length;
|
||||
if (mbedtls_asn1_get_tag(&cursor, outer_end, &field_length,
|
||||
MBEDTLS_ASN1_CONSTRUCTED |
|
||||
MBEDTLS_ASN1_SEQUENCE) != 0 ||
|
||||
field_length > (size_t)(outer_end - cursor)) {
|
||||
goto cleanup;
|
||||
}
|
||||
cursor += field_length;
|
||||
if (mbedtls_asn1_get_bitstring_null(&cursor, outer_end,
|
||||
&signature_length) != 0 ||
|
||||
signature_length == 0U ||
|
||||
signature_length != (size_t)(outer_end - cursor)) {
|
||||
goto cleanup;
|
||||
}
|
||||
signature = cursor;
|
||||
|
||||
if (mbedtls_sha256(certificate->tbs.p, certificate->tbs.len,
|
||||
digest, 0) == 0 &&
|
||||
mbedtls_pk_verify(&certificate->pk, MBEDTLS_MD_SHA256,
|
||||
digest, sizeof(digest),
|
||||
signature, signature_length) == 0) {
|
||||
valid = true;
|
||||
}
|
||||
|
||||
cleanup:
|
||||
secure_wipe(digest, sizeof(digest));
|
||||
return valid;
|
||||
}
|
||||
|
||||
static esp_err_t validate_certificate_and_key(const web_security_blob_t *blob)
|
||||
{
|
||||
char common_name[WEB_SECURITY_COMMON_NAME_CAPACITY] = {0};
|
||||
char dns_name[WEB_SECURITY_DNS_NAME_CAPACITY] = {0};
|
||||
uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH] = {0};
|
||||
mbedtls_pk_context private_key;
|
||||
mbedtls_x509_crt certificate;
|
||||
esp_err_t error = ESP_ERR_INVALID_RESPONSE;
|
||||
|
||||
mbedtls_pk_init(&private_key);
|
||||
mbedtls_x509_crt_init(&certificate);
|
||||
|
||||
if (build_device_names(common_name, sizeof(common_name),
|
||||
dns_name, sizeof(dns_name)) != ESP_OK) {
|
||||
error = ESP_FAIL;
|
||||
goto cleanup;
|
||||
}
|
||||
if (mbedtls_sha256(blob->certificate_der, blob->certificate_length,
|
||||
fingerprint, 0) != 0 ||
|
||||
!constant_time_equal(fingerprint, blob->certificate_fingerprint,
|
||||
sizeof(fingerprint))) {
|
||||
goto cleanup;
|
||||
}
|
||||
if (mbedtls_pk_parse_key(&private_key,
|
||||
blob->private_key_der, blob->private_key_length,
|
||||
NULL, 0U, secure_random_mbedtls, NULL) != 0 ||
|
||||
mbedtls_x509_crt_parse_der(&certificate,
|
||||
blob->certificate_der,
|
||||
blob->certificate_length) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
mbedtls_ecp_keypair *private_ec = mbedtls_pk_ec(private_key);
|
||||
mbedtls_ecp_keypair *public_ec = mbedtls_pk_ec(certificate.pk);
|
||||
if (private_ec == NULL || public_ec == NULL ||
|
||||
mbedtls_ecp_keypair_get_group_id(private_ec) != MBEDTLS_ECP_DP_SECP256R1 ||
|
||||
mbedtls_ecp_keypair_get_group_id(public_ec) != MBEDTLS_ECP_DP_SECP256R1 ||
|
||||
mbedtls_pk_check_pair(&certificate.pk, &private_key,
|
||||
secure_random_mbedtls, NULL) != 0) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (certificate.version != MBEDTLS_X509_CRT_VERSION_3 + 1 ||
|
||||
certificate.sig_oid.len != MBEDTLS_OID_SIZE(MBEDTLS_OID_ECDSA_SHA256) ||
|
||||
memcmp(certificate.sig_oid.p, MBEDTLS_OID_ECDSA_SHA256,
|
||||
certificate.sig_oid.len) != 0 ||
|
||||
certificate.issuer_raw.len != certificate.subject_raw.len ||
|
||||
memcmp(certificate.issuer_raw.p, certificate.subject_raw.p,
|
||||
certificate.subject_raw.len) != 0 ||
|
||||
!name_is_single_common_name(&certificate.subject, common_name) ||
|
||||
!name_is_single_common_name(&certificate.issuer, common_name) ||
|
||||
!x509_time_equals(&certificate.valid_from, 2025, 1, 1, 0, 0, 0) ||
|
||||
!x509_time_equals(&certificate.valid_to, 2049, 12, 31, 23, 59, 59)) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (!mbedtls_x509_crt_has_ext_type(&certificate,
|
||||
MBEDTLS_X509_EXT_BASIC_CONSTRAINTS) ||
|
||||
mbedtls_x509_crt_get_ca_istrue(&certificate) != 0 ||
|
||||
!mbedtls_x509_crt_has_ext_type(&certificate, MBEDTLS_X509_EXT_KEY_USAGE) ||
|
||||
mbedtls_x509_crt_check_key_usage(
|
||||
&certificate, MBEDTLS_X509_KU_DIGITAL_SIGNATURE) != 0 ||
|
||||
!mbedtls_x509_crt_has_ext_type(
|
||||
&certificate, MBEDTLS_X509_EXT_EXTENDED_KEY_USAGE) ||
|
||||
!extended_key_usage_is_server_auth_only(&certificate) ||
|
||||
mbedtls_x509_crt_check_extended_key_usage(
|
||||
&certificate, MBEDTLS_OID_SERVER_AUTH,
|
||||
MBEDTLS_OID_SIZE(MBEDTLS_OID_SERVER_AUTH)) != 0 ||
|
||||
!mbedtls_x509_crt_has_ext_type(&certificate,
|
||||
MBEDTLS_X509_EXT_SUBJECT_ALT_NAME) ||
|
||||
!subject_alt_names_are_expected(&certificate, dns_name)) {
|
||||
goto cleanup;
|
||||
}
|
||||
|
||||
if (!certificate_self_signature_is_valid(&certificate)) {
|
||||
goto cleanup;
|
||||
}
|
||||
error = ESP_OK;
|
||||
|
||||
cleanup:
|
||||
secure_wipe(fingerprint, sizeof(fingerprint));
|
||||
mbedtls_x509_crt_free(&certificate);
|
||||
mbedtls_pk_free(&private_key);
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t validate_blob(const web_security_blob_t *blob)
|
||||
{
|
||||
if (blob == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (blob->schema_version != WEB_SECURITY_SCHEMA_VERSION ||
|
||||
blob->blob_size != WEB_SECURITY_BLOB_SIZE) {
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
if (blob->generation == 0U || blob->reserved_header != 0U ||
|
||||
blob->reserved_lengths != 0U ||
|
||||
!bytes_are_zero(blob->reserved, sizeof(blob->reserved)) ||
|
||||
blob->username_length != sizeof(s_admin_username) - 1U ||
|
||||
memcmp(blob->username, s_admin_username,
|
||||
sizeof(s_admin_username) - 1U) != 0 ||
|
||||
!unused_bytes_are_zero(blob->username, blob->username_length,
|
||||
sizeof(blob->username)) ||
|
||||
blob->password_length != WEB_SECURITY_PASSWORD_LENGTH ||
|
||||
!unused_bytes_are_zero(blob->password, blob->password_length,
|
||||
sizeof(blob->password)) ||
|
||||
blob->private_key_length == 0U ||
|
||||
blob->private_key_length > sizeof(blob->private_key_der) ||
|
||||
!unused_bytes_are_zero(blob->private_key_der, blob->private_key_length,
|
||||
sizeof(blob->private_key_der)) ||
|
||||
blob->certificate_length == 0U ||
|
||||
blob->certificate_length > sizeof(blob->certificate_der) ||
|
||||
!unused_bytes_are_zero(blob->certificate_der, blob->certificate_length,
|
||||
sizeof(blob->certificate_der))) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
|
||||
for (size_t i = 0U; i < blob->password_length; ++i) {
|
||||
const uint8_t value = blob->password[i];
|
||||
bool valid = (value >= 'A' && value <= 'Z') ||
|
||||
(value >= 'a' && value <= 'z') ||
|
||||
(value >= '0' && value <= '9') ||
|
||||
value == '-' || value == '_';
|
||||
if (!valid) {
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
}
|
||||
return validate_certificate_and_key(blob);
|
||||
}
|
||||
|
||||
static esp_err_t generate_all(web_security_blob_t *blob, uint32_t generation)
|
||||
{
|
||||
memset(blob, 0, sizeof(*blob));
|
||||
blob->schema_version = WEB_SECURITY_SCHEMA_VERSION;
|
||||
blob->blob_size = WEB_SECURITY_BLOB_SIZE;
|
||||
blob->generation = generation;
|
||||
|
||||
esp_err_t error = generate_credentials(blob);
|
||||
if (error == ESP_OK) {
|
||||
error = generate_certificate(blob);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = validate_blob(blob);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t save_blob(const web_security_blob_t *blob)
|
||||
{
|
||||
esp_err_t error = validate_blob(blob);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
nvs_handle_t handle;
|
||||
error = nvs_open(WEB_SECURITY_NVS_NAMESPACE, NVS_READWRITE, &handle);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
/* NVS append semantics retain the committed predecessor until commit succeeds. */
|
||||
error = nvs_set_blob(handle, WEB_SECURITY_NVS_BLOB_KEY,
|
||||
blob, sizeof(*blob));
|
||||
if (error == ESP_OK) {
|
||||
error = nvs_commit(handle);
|
||||
}
|
||||
nvs_close(handle);
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t load_stored_blob(web_security_blob_t *blob, bool *missing)
|
||||
{
|
||||
*missing = false;
|
||||
nvs_handle_t handle;
|
||||
esp_err_t error = nvs_open(WEB_SECURITY_NVS_NAMESPACE, NVS_READONLY, &handle);
|
||||
if (error == ESP_ERR_NVS_NOT_FOUND) {
|
||||
*missing = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
size_t size = 0U;
|
||||
error = nvs_get_blob(handle, WEB_SECURITY_NVS_BLOB_KEY, NULL, &size);
|
||||
if (error == ESP_ERR_NVS_NOT_FOUND) {
|
||||
*missing = true;
|
||||
nvs_close(handle);
|
||||
return ESP_OK;
|
||||
}
|
||||
if (error == ESP_ERR_NVS_TYPE_MISMATCH) {
|
||||
nvs_close(handle);
|
||||
return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
nvs_close(handle);
|
||||
return error;
|
||||
}
|
||||
if (size != sizeof(*blob)) {
|
||||
nvs_close(handle);
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
|
||||
memset(blob, 0, sizeof(*blob));
|
||||
error = nvs_get_blob(handle, WEB_SECURITY_NVS_BLOB_KEY, blob, &size);
|
||||
nvs_close(handle);
|
||||
if (error == ESP_ERR_NVS_INVALID_LENGTH) {
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
return validate_blob(blob);
|
||||
}
|
||||
|
||||
esp_err_t web_security_init(web_security_load_result_t *load_result)
|
||||
{
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = ensure_security_mutex();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
if (s_material_ready) {
|
||||
if (load_result != NULL) {
|
||||
*load_result = s_load_result;
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
web_security_blob_t candidate;
|
||||
bool missing = false;
|
||||
error = load_stored_blob(&candidate, &missing);
|
||||
if (error == ESP_OK && missing) {
|
||||
error = generate_all(&candidate, 1U);
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
s_material = candidate;
|
||||
s_material_ready = true;
|
||||
s_load_result = missing ? WEB_SECURITY_LOAD_GENERATED_MISSING
|
||||
: WEB_SECURITY_LOAD_STORED;
|
||||
if (load_result != NULL) {
|
||||
*load_result = s_load_result;
|
||||
}
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_security_copy_tls_material(
|
||||
uint8_t *certificate, size_t certificate_capacity,
|
||||
size_t *certificate_length,
|
||||
uint8_t *private_key, size_t private_key_capacity,
|
||||
size_t *private_key_length)
|
||||
{
|
||||
if (certificate_length == NULL || private_key_length == NULL ||
|
||||
(certificate == NULL && certificate_capacity != 0U) ||
|
||||
(private_key == NULL && private_key_capacity != 0U)) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_security_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
if (s_material_ready) {
|
||||
*certificate_length = s_material.certificate_length;
|
||||
*private_key_length = s_material.private_key_length;
|
||||
if ((certificate == NULL && certificate_capacity != 0U) ||
|
||||
(private_key == NULL && private_key_capacity != 0U)) {
|
||||
error = ESP_ERR_INVALID_ARG;
|
||||
} else if ((certificate != NULL &&
|
||||
certificate_capacity < s_material.certificate_length) ||
|
||||
(private_key != NULL &&
|
||||
private_key_capacity < s_material.private_key_length)) {
|
||||
/* Check both capacities before copying either half of the pair. */
|
||||
error = ESP_ERR_INVALID_SIZE;
|
||||
} else {
|
||||
if (certificate != NULL) {
|
||||
memcpy(certificate, s_material.certificate_der,
|
||||
s_material.certificate_length);
|
||||
}
|
||||
if (private_key != NULL) {
|
||||
memcpy(private_key, s_material.private_key_der,
|
||||
s_material.private_key_length);
|
||||
}
|
||||
error = ESP_OK;
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
|
||||
static void copy_credentials_locked(web_security_credentials_t *credentials,
|
||||
const web_security_blob_t *blob)
|
||||
{
|
||||
memset(credentials, 0, sizeof(*credentials));
|
||||
credentials->username_length = blob->username_length;
|
||||
credentials->password_length = blob->password_length;
|
||||
memcpy(credentials->username, blob->username, blob->username_length);
|
||||
memcpy(credentials->password, blob->password, blob->password_length);
|
||||
}
|
||||
|
||||
esp_err_t web_security_show_credentials(web_security_credentials_t *credentials)
|
||||
{
|
||||
if (credentials == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_security_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
if (s_material_ready) {
|
||||
copy_credentials_locked(credentials, &s_material);
|
||||
error = ESP_OK;
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_security_get_certificate_metadata(
|
||||
web_security_certificate_metadata_t *metadata)
|
||||
{
|
||||
if (metadata == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_security_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
if (s_material_ready) {
|
||||
memset(metadata, 0, sizeof(*metadata));
|
||||
error = build_device_names(metadata->common_name,
|
||||
sizeof(metadata->common_name),
|
||||
metadata->dns_name,
|
||||
sizeof(metadata->dns_name));
|
||||
if (error == ESP_OK) {
|
||||
metadata->material_generation = s_material.generation;
|
||||
memcpy(metadata->sha256_fingerprint,
|
||||
s_material.certificate_fingerprint,
|
||||
sizeof(metadata->sha256_fingerprint));
|
||||
memcpy(metadata->ipv4_address, s_ap_ipv4_address,
|
||||
sizeof(metadata->ipv4_address));
|
||||
memcpy(metadata->not_before, WEB_SECURITY_CERT_NOT_BEFORE,
|
||||
sizeof(WEB_SECURITY_CERT_NOT_BEFORE));
|
||||
memcpy(metadata->not_after, WEB_SECURITY_CERT_NOT_AFTER,
|
||||
sizeof(WEB_SECURITY_CERT_NOT_AFTER));
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t increment_generation(web_security_blob_t *blob)
|
||||
{
|
||||
if (blob->generation == UINT32_MAX) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
++blob->generation;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void install_committed_blob(const web_security_blob_t *candidate)
|
||||
{
|
||||
/* Wipe the superseded private key before replacing the live snapshot. */
|
||||
secure_wipe(&s_material, sizeof(s_material));
|
||||
s_material = *candidate;
|
||||
s_material_ready = true;
|
||||
s_load_result = WEB_SECURITY_LOAD_STORED;
|
||||
}
|
||||
|
||||
esp_err_t web_security_rotate_credentials(web_security_credentials_t *new_credentials)
|
||||
{
|
||||
if (s_security_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
web_security_blob_t candidate;
|
||||
memset(&candidate, 0, sizeof(candidate));
|
||||
if (s_material_ready) {
|
||||
candidate = s_material;
|
||||
error = increment_generation(&candidate);
|
||||
if (error == ESP_OK) {
|
||||
error = generate_credentials(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_committed_blob(&candidate);
|
||||
if (new_credentials != NULL) {
|
||||
copy_credentials_locked(new_credentials, &s_material);
|
||||
}
|
||||
}
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_security_rotate_certificate(void)
|
||||
{
|
||||
if (s_security_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_ERR_INVALID_STATE;
|
||||
web_security_blob_t candidate;
|
||||
memset(&candidate, 0, sizeof(candidate));
|
||||
if (s_material_ready) {
|
||||
candidate = s_material;
|
||||
error = increment_generation(&candidate);
|
||||
if (error == ESP_OK) {
|
||||
error = generate_certificate(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_committed_blob(&candidate);
|
||||
}
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_security_reset_all(web_security_credentials_t *new_credentials)
|
||||
{
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = ensure_security_mutex();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
web_security_blob_t candidate;
|
||||
uint32_t generation = 1U;
|
||||
if (s_material_ready) {
|
||||
if (s_material.generation == UINT32_MAX) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
generation = s_material.generation + 1U;
|
||||
}
|
||||
|
||||
error = generate_all(&candidate, generation);
|
||||
if (error == ESP_OK) {
|
||||
error = save_blob(&candidate);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
install_committed_blob(&candidate);
|
||||
if (new_credentials != NULL) {
|
||||
copy_credentials_locked(new_credentials, &s_material);
|
||||
}
|
||||
}
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Persistent HTTPS identity and legacy migration/recovery credentials. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define WEB_SECURITY_NVS_NAMESPACE "web_sec"
|
||||
#define WEB_SECURITY_NVS_BLOB_KEY "material"
|
||||
|
||||
#define WEB_SECURITY_USERNAME_CAPACITY 16U
|
||||
#define WEB_SECURITY_PASSWORD_CAPACITY 32U
|
||||
#define WEB_SECURITY_PASSWORD_LENGTH 24U
|
||||
#define WEB_SECURITY_PRIVATE_KEY_DER_CAPACITY 256U
|
||||
#define WEB_SECURITY_CERTIFICATE_DER_CAPACITY 1024U
|
||||
#define WEB_SECURITY_SHA256_LENGTH 32U
|
||||
#define WEB_SECURITY_COMMON_NAME_CAPACITY 32U
|
||||
#define WEB_SECURITY_DNS_NAME_CAPACITY 40U
|
||||
|
||||
#define WEB_SECURITY_CERT_NOT_BEFORE "20250101000000"
|
||||
#define WEB_SECURITY_CERT_NOT_AFTER "20491231235959"
|
||||
|
||||
typedef enum {
|
||||
WEB_SECURITY_LOAD_STORED = 0,
|
||||
WEB_SECURITY_LOAD_GENERATED_MISSING = 1,
|
||||
} web_security_load_result_t;
|
||||
|
||||
/*
|
||||
* This intentionally contains a displayable secret. UART callers should call
|
||||
* secure_wipe() on it immediately after rendering the length-delimited fields.
|
||||
*/
|
||||
typedef struct {
|
||||
size_t username_length;
|
||||
size_t password_length;
|
||||
char username[WEB_SECURITY_USERNAME_CAPACITY + 1U];
|
||||
char password[WEB_SECURITY_PASSWORD_CAPACITY + 1U];
|
||||
} web_security_credentials_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t material_generation;
|
||||
uint8_t sha256_fingerprint[WEB_SECURITY_SHA256_LENGTH];
|
||||
char common_name[WEB_SECURITY_COMMON_NAME_CAPACITY];
|
||||
char dns_name[WEB_SECURITY_DNS_NAME_CAPACITY];
|
||||
uint8_t ipv4_address[4];
|
||||
char not_before[sizeof(WEB_SECURITY_CERT_NOT_BEFORE)];
|
||||
char not_after[sizeof(WEB_SECURITY_CERT_NOT_AFTER)];
|
||||
} web_security_certificate_metadata_t;
|
||||
|
||||
/*
|
||||
* NVS must already be initialized. Missing material is generated and saved;
|
||||
* an existing wrong-version blob returns ESP_ERR_INVALID_VERSION, while any
|
||||
* malformed or cryptographically inconsistent blob returns
|
||||
* ESP_ERR_INVALID_RESPONSE and is never overwritten. Call before radio startup
|
||||
* so secure_random_init() can seed from the pre-radio entropy source.
|
||||
*/
|
||||
esp_err_t web_security_init(web_security_load_result_t *load_result);
|
||||
|
||||
/*
|
||||
* Query with both outputs NULL/capacities zero. Certificate and key are copied
|
||||
* under one lock so a concurrent rotation can never produce a mismatched pair.
|
||||
*/
|
||||
esp_err_t web_security_copy_tls_material(
|
||||
uint8_t *certificate, size_t certificate_capacity,
|
||||
size_t *certificate_length,
|
||||
uint8_t *private_key, size_t private_key_capacity,
|
||||
size_t *private_key_length);
|
||||
|
||||
/* Explicit secret-bearing API intended for a physically attached UART CLI. */
|
||||
esp_err_t web_security_show_credentials(web_security_credentials_t *credentials);
|
||||
esp_err_t web_security_get_certificate_metadata(
|
||||
web_security_certificate_metadata_t *metadata);
|
||||
|
||||
/* Mutations become visible only after a complete blob has committed to NVS. */
|
||||
esp_err_t web_security_rotate_credentials(web_security_credentials_t *new_credentials);
|
||||
esp_err_t web_security_rotate_certificate(void);
|
||||
|
||||
/* Explicitly replaces missing, valid, or incompatible stored material. */
|
||||
esp_err_t web_security_reset_all(web_security_credentials_t *new_credentials);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Authenticated, bounded WebSocket transport for the serial session broker. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "session_broker.h"
|
||||
#include "user_database.h"
|
||||
#include "web_session.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define WEB_SERIAL_TRANSPORT_MAX_SESSIONS 2U
|
||||
#define WEB_SERIAL_TRANSPORT_MAX_TICKETS 4U
|
||||
#define WEB_SERIAL_TRANSPORT_TICKET_LENGTH 32U
|
||||
#define WEB_SERIAL_TRANSPORT_TICKET_CAPACITY \
|
||||
(WEB_SERIAL_TRANSPORT_TICKET_LENGTH + 1U)
|
||||
#define WEB_SERIAL_TRANSPORT_TICKET_LIFETIME_SECONDS 30U
|
||||
#define WEB_SERIAL_TRANSPORT_MAX_RX_PAYLOAD 1024U
|
||||
#define WEB_SERIAL_TRANSPORT_TX_PAYLOAD_SIZE 512U
|
||||
|
||||
#define WEB_SERIAL_TRANSPORT_TICKET_URI "/api/ws-ticket"
|
||||
#define WEB_SERIAL_TRANSPORT_WS_URI "/ws/serial"
|
||||
#define WEB_SERIAL_TRANSPORT_TICKET_QUERY_KEY "ticket"
|
||||
|
||||
typedef struct {
|
||||
uint64_t tickets_issued;
|
||||
uint64_t tickets_consumed;
|
||||
uint64_t tickets_rejected;
|
||||
uint64_t tickets_expired;
|
||||
|
||||
uint64_t connections;
|
||||
uint64_t connection_failures;
|
||||
uint64_t disconnections;
|
||||
uint64_t service_start_failures;
|
||||
uint64_t broker_failures;
|
||||
|
||||
uint64_t writer_requests;
|
||||
uint64_t writer_grants;
|
||||
uint64_t writer_denials;
|
||||
uint64_t writer_releases;
|
||||
uint64_t writer_revocations;
|
||||
|
||||
uint64_t rx_ws_frames_accepted;
|
||||
uint64_t rx_ws_frames_rejected;
|
||||
uint64_t rx_ws_bytes_accepted;
|
||||
uint64_t rx_ws_bytes_rejected;
|
||||
|
||||
uint64_t tx_binary_frames;
|
||||
uint64_t tx_binary_bytes;
|
||||
uint64_t tx_control_frames;
|
||||
uint64_t tx_control_bytes;
|
||||
uint64_t send_failures;
|
||||
uint64_t queue_failures;
|
||||
uint64_t protocol_errors;
|
||||
uint64_t close_requests;
|
||||
} web_serial_transport_counters_t;
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
bool principal_valid;
|
||||
bool writer;
|
||||
bool tx_pending;
|
||||
bool close_requested;
|
||||
int socket_fd;
|
||||
uint32_t generation;
|
||||
session_broker_client_id_t broker_client_id;
|
||||
user_role_t user_role;
|
||||
user_auth_method_t auth_method;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
||||
} web_serial_transport_session_snapshot_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
bool server_attached;
|
||||
uint32_t active_sessions;
|
||||
uint32_t active_tickets;
|
||||
web_serial_transport_session_snapshot_t
|
||||
sessions[WEB_SERIAL_TRANSPORT_MAX_SESSIONS];
|
||||
web_serial_transport_counters_t counters;
|
||||
} web_serial_transport_snapshot_t;
|
||||
|
||||
/*
|
||||
* Allocate no per-session heap objects and start the permanent transport task.
|
||||
* CONFIG_HTTPD_WS_SUPPORT must be enabled. CONFIG_HTTPD_QUEUE_WORK_BLOCKING must
|
||||
* be disabled because that IDF mode can wait forever inside httpd_queue_work().
|
||||
*/
|
||||
esp_err_t web_serial_transport_init(void);
|
||||
|
||||
/* Attach after httpd start; detach as part of stopping that same server. */
|
||||
esp_err_t web_serial_transport_attach_server(httpd_handle_t server);
|
||||
esp_err_t web_serial_transport_detach_server(httpd_handle_t server);
|
||||
|
||||
/*
|
||||
* Mint a one-time bearer ticket bound to one exact current browser login session.
|
||||
* The principal and session reference are copied; the output is exactly 32
|
||||
* Base64URL characters plus a terminator and expires after 30 monotonic seconds.
|
||||
* Never log or persist the ticket.
|
||||
*/
|
||||
esp_err_t web_serial_transport_mint_ticket(
|
||||
const user_principal_t *principal, const web_session_ref_t *session_reference,
|
||||
char *ticket, size_t capacity);
|
||||
|
||||
/*
|
||||
* Convenience POST response helper for /api/ws-ticket. Authentication is
|
||||
* intentionally outside this module: pass the principal and exact session
|
||||
* reference returned by successful cookie-session authentication. Register it as
|
||||
* HTTP_POST, not as a public handler.
|
||||
*/
|
||||
esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
||||
httpd_req_t *request, const user_principal_t *principal,
|
||||
const web_session_ref_t *session_reference);
|
||||
|
||||
|
||||
/*
|
||||
* Handler for /ws/serial. Register as HTTP_GET with is_websocket=true and
|
||||
* handle_ws_control_frames=false. The initial upgraded GET authenticates the
|
||||
* ticket; later invocations process one complete data frame.
|
||||
*/
|
||||
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request);
|
||||
|
||||
esp_err_t web_serial_transport_get_snapshot(
|
||||
web_serial_transport_snapshot_t *snapshot);
|
||||
|
||||
/* Clearing counters does not alter tickets, sessions, ownership, or queued data. */
|
||||
esp_err_t web_serial_transport_clear_counters(void);
|
||||
|
||||
/* Invalidate tickets and request closure for one exact browser login session. */
|
||||
esp_err_t web_serial_transport_revoke_session(
|
||||
const web_session_ref_t *session_reference);
|
||||
|
||||
/* Invalidate tickets/sessions for one account, or all authenticated sessions. */
|
||||
esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
|
||||
size_t username_length);
|
||||
esp_err_t web_serial_transport_revoke_sessions(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+4076
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Session-authenticated HTTPS administration foundation. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "web_session.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
uint64_t starts;
|
||||
uint64_t start_failures;
|
||||
uint64_t stops;
|
||||
uint64_t requests;
|
||||
uint64_t authenticated_requests;
|
||||
uint64_t authentication_failures;
|
||||
uint64_t root_requests;
|
||||
uint64_t status_requests;
|
||||
uint64_t ticket_requests;
|
||||
uint64_t admin_ticket_requests;
|
||||
uint64_t admin_authorization_failures;
|
||||
uint64_t admin_api_requests;
|
||||
uint64_t admin_request_rejections;
|
||||
uint64_t admin_operation_failures;
|
||||
uint64_t writer_transfer_attempts;
|
||||
uint64_t writer_transfer_successes;
|
||||
uint64_t writer_transfer_conflicts;
|
||||
uint64_t asset_requests;
|
||||
uint64_t response_errors;
|
||||
uint64_t login_requests;
|
||||
uint64_t login_successes;
|
||||
uint64_t login_failures;
|
||||
uint64_t login_throttled;
|
||||
uint64_t logout_requests;
|
||||
uint64_t session_requests;
|
||||
uint64_t cookie_rejections;
|
||||
uint64_t origin_rejections;
|
||||
uint64_t csrf_rejections;
|
||||
} web_server_counters_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
bool running;
|
||||
bool transitioning;
|
||||
bool desired_running;
|
||||
uint32_t lifecycle_generation;
|
||||
uint16_t port;
|
||||
esp_err_t last_error;
|
||||
esp_err_t serial_transport_error;
|
||||
esp_err_t admin_transport_error;
|
||||
uint32_t active_sessions;
|
||||
web_session_counters_t session_counters;
|
||||
web_server_counters_t counters;
|
||||
} web_server_snapshot_t;
|
||||
|
||||
/* Initialize runtime state without requiring valid certificate material. */
|
||||
esp_err_t web_server_init(void);
|
||||
|
||||
/* Start one TLS-only server on all active network interfaces. */
|
||||
esp_err_t web_server_start(void);
|
||||
esp_err_t web_server_stop(void);
|
||||
/*
|
||||
* Stop only while expected_lifecycle_generation still names the latest
|
||||
* explicit HTTPS intent. A newer start/stop/refresh wins and returns
|
||||
* ESP_ERR_INVALID_STATE without changing server state.
|
||||
*/
|
||||
esp_err_t web_server_stop_if_generation(uint32_t expected_lifecycle_generation);
|
||||
/*
|
||||
* Apply current TLS material to a live server. If it is stopped, start only
|
||||
* when requested and no newer explicit lifecycle intent superseded the caller.
|
||||
*/
|
||||
esp_err_t web_server_refresh_tls(uint32_t expected_lifecycle_generation,
|
||||
bool start_if_unchanged);
|
||||
|
||||
esp_err_t web_server_get_snapshot(web_server_snapshot_t *snapshot);
|
||||
esp_err_t web_server_clear_counters(void);
|
||||
|
||||
/* Revoke one account's browser sessions and both WebSocket transports. */
|
||||
esp_err_t web_server_revoke_user(const uint8_t *username, size_t username_length);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+1097
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded opaque browser sessions with principal and CSRF validation. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "user_database.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define WEB_SESSION_MAX_SESSIONS 8U
|
||||
#define WEB_SESSION_MAX_SESSIONS_PER_ACCOUNT 2U
|
||||
#define WEB_SESSION_BASE64URL_SOURCE_LENGTH 24U
|
||||
#define WEB_SESSION_TOKEN_RANDOM_LENGTH WEB_SESSION_BASE64URL_SOURCE_LENGTH
|
||||
#define WEB_SESSION_TOKEN_LENGTH 32U
|
||||
#define WEB_SESSION_TOKEN_CAPACITY (WEB_SESSION_TOKEN_LENGTH + 1U)
|
||||
#define WEB_SESSION_TOKEN_DIGEST_LENGTH 32U
|
||||
#define WEB_SESSION_CSRF_KEY_LENGTH 32U
|
||||
#define WEB_SESSION_HMAC_BLOCK_LENGTH 64U
|
||||
#define WEB_SESSION_HMAC_DIGEST_LENGTH 32U
|
||||
#define WEB_SESSION_CSRF_SOURCE_LENGTH WEB_SESSION_BASE64URL_SOURCE_LENGTH
|
||||
#define WEB_SESSION_CSRF_TOKEN_LENGTH 32U
|
||||
#define WEB_SESSION_CSRF_TOKEN_CAPACITY (WEB_SESSION_CSRF_TOKEN_LENGTH + 1U)
|
||||
#define WEB_SESSION_LIFETIME_SECONDS (8U * 60U * 60U)
|
||||
#define WEB_SESSION_LIFETIME_US \
|
||||
((int64_t)WEB_SESSION_LIFETIME_SECONDS * 1000000LL)
|
||||
#define WEB_SESSION_ERR_CAPACITY ESP_ERR_NO_MEM
|
||||
|
||||
typedef struct {
|
||||
uint8_t slot_index;
|
||||
uint32_t generation;
|
||||
} web_session_ref_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t created;
|
||||
uint64_t create_failures;
|
||||
uint64_t capacity_failures;
|
||||
uint64_t authenticated;
|
||||
uint64_t rejected;
|
||||
uint64_t expired;
|
||||
uint64_t stale_principal;
|
||||
uint64_t destroyed;
|
||||
uint64_t revocations;
|
||||
uint64_t csrf_accepted;
|
||||
uint64_t csrf_rejected;
|
||||
} web_session_counters_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
uint32_t active_sessions;
|
||||
web_session_counters_t counters;
|
||||
} web_session_snapshot_t;
|
||||
|
||||
/*
|
||||
* Generate the boot-local CSRF key and initialize the fixed session table.
|
||||
* secure_random_init() and user_database_init() must already have succeeded.
|
||||
* Repeated calls after successful initialization return ESP_OK without changing
|
||||
* sessions, the boot-local key, or counters.
|
||||
*/
|
||||
esp_err_t web_session_init(void);
|
||||
|
||||
/*
|
||||
* Authenticate bounded username/password input and create a fixed eight-hour
|
||||
* session. On success, session_token contains exactly 32 Base64URL characters
|
||||
* plus a terminator and principal is a copied secret-free value. The caller must send
|
||||
* the session token only through a suitably protected host-only cookie and must
|
||||
* never log either token. Invalid credentials return ESP_OK with created=false;
|
||||
* a full table of current, unexpired sessions returns WEB_SESSION_ERR_CAPACITY.
|
||||
* At most two sessions are retained per account; a later login replaces that
|
||||
* account's oldest session. All secret-bearing outputs are cleared on failure.
|
||||
*/
|
||||
esp_err_t web_session_create(
|
||||
const uint8_t *username, size_t username_length,
|
||||
const uint8_t *password, size_t password_length,
|
||||
char *session_token, size_t session_token_capacity,
|
||||
user_principal_t *principal, bool *created);
|
||||
|
||||
/*
|
||||
* Authenticate an exact length-delimited session token. Missing, malformed,
|
||||
* expired, destroyed, or unknown tokens return ESP_OK with authenticated=false.
|
||||
* Database/currentness failures fail closed and are returned to the caller.
|
||||
*/
|
||||
esp_err_t web_session_authenticate(const char *session_token,
|
||||
size_t session_token_length,
|
||||
user_principal_t *principal,
|
||||
bool *authenticated);
|
||||
|
||||
|
||||
/* A syntactically valid reference names a bounded slot and nonzero generation. */
|
||||
bool web_session_ref_valid(const web_session_ref_t *reference);
|
||||
|
||||
/*
|
||||
* Authenticate exactly as web_session_authenticate() and additionally return the
|
||||
* reference of that exact live browser login session. reference is cleared unless
|
||||
* authenticated is true. No raw session token is retained.
|
||||
*/
|
||||
esp_err_t web_session_get_reference(
|
||||
const char *session_token, size_t session_token_length,
|
||||
user_principal_t *principal, web_session_ref_t *reference,
|
||||
bool *authenticated);
|
||||
|
||||
/*
|
||||
* Check that reference still names the same live session carrying principal and
|
||||
* that the copied principal remains current in the user database. The database
|
||||
* check is performed without holding the browser-session mutex, followed by
|
||||
* locked reference revalidation. Database failures fail closed.
|
||||
*/
|
||||
esp_err_t web_session_ref_is_current(const web_session_ref_t *reference,
|
||||
const user_principal_t *principal,
|
||||
bool *current);
|
||||
|
||||
/*
|
||||
* Authenticate a session and reproduce its deterministic boot-local CSRF token.
|
||||
* This supports rendering a fresh page without storing separate per-session CSRF
|
||||
* material. csrf_token is cleared unless authenticated is true.
|
||||
*/
|
||||
esp_err_t web_session_copy_csrf_token(
|
||||
const char *session_token, size_t session_token_length,
|
||||
char *csrf_token, size_t csrf_token_capacity,
|
||||
user_principal_t *principal, bool *authenticated);
|
||||
|
||||
/*
|
||||
* Authenticate the session and compare an exact length-delimited CSRF token in
|
||||
* constant time. accepted is true only when both the current session and its
|
||||
* session-bound CSRF token are valid. The copied principal is cleared otherwise.
|
||||
*/
|
||||
esp_err_t web_session_authenticate_csrf(
|
||||
const char *session_token, size_t session_token_length,
|
||||
const char *csrf_token, size_t csrf_token_length,
|
||||
user_principal_t *principal, bool *accepted);
|
||||
|
||||
/* Explicitly destroy every entry matching one opaque token digest. */
|
||||
esp_err_t web_session_destroy(const char *session_token,
|
||||
size_t session_token_length,
|
||||
bool *destroyed);
|
||||
|
||||
/* Revoke sessions for one bounded username, or every browser session. */
|
||||
esp_err_t web_session_revoke_username(const uint8_t *username,
|
||||
size_t username_length);
|
||||
esp_err_t web_session_revoke_all(void);
|
||||
|
||||
/* The snapshot contains no token, digest, CSRF key, expiry, or principal data. */
|
||||
esp_err_t web_session_get_snapshot(web_session_snapshot_t *snapshot);
|
||||
|
||||
/* Clearing counters never changes live sessions or the boot-local CSRF key. */
|
||||
esp_err_t web_session_clear_counters(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+1669
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Offline browser UI response helpers for public and session-authenticated HTTPS routes. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
WEB_UI_RESOURCE_INDEX = 0,
|
||||
WEB_UI_RESOURCE_LOGIN,
|
||||
WEB_UI_RESOURCE_LOGIN_ERROR,
|
||||
WEB_UI_RESOURCE_XTERM_JS,
|
||||
WEB_UI_RESOURCE_XTERM_CSS,
|
||||
WEB_UI_RESOURCE_ADDON_FIT_JS,
|
||||
WEB_UI_RESOURCE_APP_JS,
|
||||
WEB_UI_RESOURCE_LOGO_PNG,
|
||||
} web_ui_resource_t;
|
||||
|
||||
/*
|
||||
* Send one UI resource after the caller has applied the route's public or
|
||||
* session-authenticated access policy. This module deliberately performs no
|
||||
* authentication, authorization, or URI dispatch.
|
||||
*/
|
||||
esp_err_t web_ui_send_response(httpd_req_t *request,
|
||||
web_ui_resource_t resource);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+4
-88
@@ -3,18 +3,13 @@
|
||||
|
||||
#include "wifi_config.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "bootloader_random.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_random.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "mbedtls/ctr_drbg.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
_Static_assert(sizeof(wifi_config_sta_profile_t) == WIFI_CONFIG_STA_PROFILE_BLOB_SIZE,
|
||||
"Wi-Fi STA profile schema size changed");
|
||||
@@ -23,67 +18,7 @@ _Static_assert(offsetof(wifi_app_config_t, profiles) == 112U,
|
||||
_Static_assert(sizeof(wifi_app_config_t) == WIFI_CONFIG_BLOB_SIZE,
|
||||
"Wi-Fi config schema size changed");
|
||||
|
||||
static SemaphoreHandle_t s_drbg_mutex;
|
||||
static mbedtls_ctr_drbg_context s_drbg;
|
||||
static bool s_drbg_ready;
|
||||
|
||||
static int early_entropy_source(void *context, unsigned char *output, size_t length)
|
||||
{
|
||||
(void)context;
|
||||
/* This callback is used only during the explicit pre-radio initialization. */
|
||||
bootloader_random_enable();
|
||||
esp_fill_random(output, length);
|
||||
bootloader_random_disable();
|
||||
return 0;
|
||||
}
|
||||
|
||||
esp_err_t wifi_config_entropy_init(void)
|
||||
{
|
||||
static const unsigned char personalization[] = "esp32-serial-sak-wifi";
|
||||
|
||||
if (s_drbg_ready) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (s_drbg_mutex == NULL) {
|
||||
s_drbg_mutex = xSemaphoreCreateMutex();
|
||||
if (s_drbg_mutex == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_drbg_mutex, portMAX_DELAY);
|
||||
esp_err_t error = ESP_OK;
|
||||
if (!s_drbg_ready) {
|
||||
mbedtls_ctr_drbg_init(&s_drbg);
|
||||
int result = mbedtls_ctr_drbg_seed(&s_drbg,
|
||||
early_entropy_source,
|
||||
NULL,
|
||||
personalization,
|
||||
sizeof(personalization) - 1U);
|
||||
if (result == 0) {
|
||||
/* Credential generation is rare; never invoke early entropy after RF starts. */
|
||||
mbedtls_ctr_drbg_set_reseed_interval(&s_drbg, INT_MAX);
|
||||
s_drbg_ready = true;
|
||||
} else {
|
||||
mbedtls_ctr_drbg_free(&s_drbg);
|
||||
error = ESP_FAIL;
|
||||
}
|
||||
}
|
||||
xSemaphoreGive(s_drbg_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t fill_credential_random(uint8_t *output, size_t length)
|
||||
{
|
||||
if (!s_drbg_ready || output == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_drbg_mutex, portMAX_DELAY);
|
||||
int result = mbedtls_ctr_drbg_random(&s_drbg, output, length);
|
||||
xSemaphoreGive(s_drbg_mutex);
|
||||
return result == 0 ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
|
||||
static bool bytes_are_zero(const uint8_t *data, size_t size)
|
||||
{
|
||||
@@ -130,14 +65,8 @@ static bool security_is_valid(wifi_config_security_t security)
|
||||
|
||||
void wifi_config_secure_wipe(void *data, size_t size)
|
||||
{
|
||||
volatile uint8_t *byte = (volatile uint8_t *)data;
|
||||
|
||||
if (byte == NULL) {
|
||||
return;
|
||||
}
|
||||
while (size-- > 0U) {
|
||||
*byte++ = 0U;
|
||||
}
|
||||
/* Keep the existing Wi-Fi API while using the device-wide wipe primitive. */
|
||||
secure_wipe(data, size);
|
||||
}
|
||||
|
||||
esp_err_t wifi_config_defaults(wifi_app_config_t *config)
|
||||
@@ -179,7 +108,7 @@ esp_err_t wifi_config_defaults(wifi_app_config_t *config)
|
||||
* A 64-character alphabet maps six random bits without modulo bias. The
|
||||
* password is intentionally independent of the public MAC-derived suffix.
|
||||
*/
|
||||
err = fill_credential_random(random_bytes, sizeof(random_bytes));
|
||||
err = secure_random_fill(random_bytes, sizeof(random_bytes));
|
||||
if (err != ESP_OK) {
|
||||
wifi_config_secure_wipe(random_bytes, sizeof(random_bytes));
|
||||
return err;
|
||||
@@ -423,17 +352,4 @@ esp_err_t wifi_config_save(const wifi_app_config_t *config)
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t wifi_config_reset_storage(const wifi_app_config_t *defaults)
|
||||
{
|
||||
if (defaults != NULL) {
|
||||
return wifi_config_save(defaults);
|
||||
}
|
||||
|
||||
wifi_app_config_t generated_defaults;
|
||||
esp_err_t err = wifi_config_defaults(&generated_defaults);
|
||||
if (err == ESP_OK) {
|
||||
err = wifi_config_save(&generated_defaults);
|
||||
}
|
||||
wifi_config_secure_wipe(&generated_defaults, sizeof(generated_defaults));
|
||||
return err;
|
||||
}
|
||||
|
||||
+1
-5
@@ -84,8 +84,6 @@ typedef struct {
|
||||
wifi_config_sta_profile_t profiles[WIFI_CONFIG_STA_PROFILE_COUNT];
|
||||
} wifi_app_config_t;
|
||||
|
||||
/* Seed the credential DRBG before Wi-Fi, Bluetooth, or ADC use. */
|
||||
esp_err_t wifi_config_entropy_init(void);
|
||||
|
||||
/* Generates a unique AP SSID and a fresh random AP password. */
|
||||
esp_err_t wifi_config_defaults(wifi_app_config_t *config);
|
||||
@@ -106,8 +104,6 @@ esp_err_t wifi_config_load(wifi_app_config_t *config,
|
||||
wifi_config_load_source_t *source);
|
||||
esp_err_t wifi_config_save(const wifi_app_config_t *config);
|
||||
|
||||
/* Pass NULL to generate fresh defaults, or supply validated defaults to save. */
|
||||
esp_err_t wifi_config_reset_storage(const wifi_app_config_t *defaults);
|
||||
|
||||
/* Volatile writes prevent ordinary optimization from eliding secret cleanup. */
|
||||
/* Compatibility wrapper around the shared secure_wipe() implementation. */
|
||||
void wifi_config_secure_wipe(void *data, size_t size);
|
||||
|
||||
+102
-106
@@ -9,25 +9,23 @@
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "driver/uart.h"
|
||||
#include "console_input.h"
|
||||
#include "esp_console.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_netif_ip_addr.h"
|
||||
#include "esp_wifi_types.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include "network_console.h"
|
||||
#include "wifi_config.h"
|
||||
#include "wifi_manager.h"
|
||||
|
||||
#define WIFI_CONSOLE_UART UART_NUM_0
|
||||
#define WIFI_CONSOLE_SECRET_CAPACITY WIFI_CONFIG_PSK_MAX_LEN
|
||||
|
||||
static void print_usage(void)
|
||||
{
|
||||
printf("Usage:\n");
|
||||
printf(" wifi status|profiles|counters|clear-counters\n");
|
||||
printf(" wifi start|stop|reconnect\n");
|
||||
printf(" wifi start|stop|reconnect|next-profile\n");
|
||||
printf(" wifi profile set <slot> <priority> <mixed|wpa3> <ssid>\n");
|
||||
printf(" wifi profile secret <slot>\n");
|
||||
printf(" wifi profile enable|disable|delete <slot>\n");
|
||||
@@ -122,13 +120,16 @@ static void print_ipv4(uint32_t address)
|
||||
|
||||
static int show_status(void)
|
||||
{
|
||||
wifi_manager_snapshot_t snapshot;
|
||||
wifi_app_config_t config;
|
||||
wifi_manager_snapshot_t snapshot = {0};
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t config_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_snapshot(&snapshot);
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_get_working_config(&config);
|
||||
error = wifi_manager_get_working_config_versioned(
|
||||
&config, &config_generation);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Wi-Fi manager unavailable: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -138,7 +139,7 @@ static int show_status(void)
|
||||
config.enabled_at_boot ? "yes" : "no",
|
||||
snapshot.started ? "yes" : "no",
|
||||
wifi_manager_state_to_string(snapshot.state),
|
||||
snapshot.config_generation);
|
||||
config_generation);
|
||||
|
||||
if (snapshot.active_profile >= 0) {
|
||||
printf("STA: profile=%d SSID=", snapshot.active_profile);
|
||||
@@ -179,7 +180,7 @@ static int show_status(void)
|
||||
|
||||
static int show_profiles(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
wifi_app_config_t config = {0};
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read Wi-Fi profiles: %s\n", esp_err_to_name(error));
|
||||
@@ -233,77 +234,31 @@ static int show_counters(void)
|
||||
return 0;
|
||||
}
|
||||
|
||||
static esp_err_t apply_candidate(wifi_app_config_t *candidate)
|
||||
static esp_err_t apply_candidate(wifi_app_config_t *candidate,
|
||||
uint32_t expected_generation,
|
||||
uint32_t *resulting_generation)
|
||||
{
|
||||
esp_err_t error = wifi_manager_apply_working_config(candidate);
|
||||
esp_err_t error = wifi_manager_compare_exchange_working_config(
|
||||
candidate, expected_generation, resulting_generation);
|
||||
wifi_config_secure_wipe(candidate, sizeof(*candidate));
|
||||
return error;
|
||||
}
|
||||
|
||||
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;
|
||||
memset(buffer, 0, sizeof(buffer));
|
||||
|
||||
/*
|
||||
* esp_console may execute on CR while the terminal's trailing LF is still
|
||||
* arriving. Let that line ending settle, then discard only pre-prompt RX so
|
||||
* 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;
|
||||
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,
|
||||
WIFI_CONFIG_PSK_MAX_LEN, &length);
|
||||
if (error == ESP_OK) {
|
||||
memset(secret, 0, WIFI_CONFIG_PSK_MAX_LEN);
|
||||
memcpy(secret, buffer, length);
|
||||
*secret_len = (uint8_t)length;
|
||||
}
|
||||
|
||||
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);
|
||||
memcpy(secret, buffer, length);
|
||||
*secret_len = (uint8_t)length;
|
||||
wifi_config_secure_wipe(buffer, sizeof(buffer));
|
||||
return ESP_OK;
|
||||
return error;
|
||||
}
|
||||
|
||||
static int set_profile(char **argv)
|
||||
@@ -321,9 +276,12 @@ static int set_profile(char **argv)
|
||||
return 1;
|
||||
}
|
||||
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -334,7 +292,7 @@ static int set_profile(char **argv)
|
||||
profile->ssid_len = (uint8_t)ssid_len;
|
||||
profile->priority = (uint8_t)priority;
|
||||
profile->security = security;
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not apply profile: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
@@ -353,9 +311,12 @@ static int set_profile_secret(const char *slot_text)
|
||||
return 1;
|
||||
}
|
||||
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -368,7 +329,7 @@ static int set_profile_secret(const char *slot_text)
|
||||
error = read_secret_no_echo(config.profiles[slot].psk,
|
||||
&config.profiles[slot].psk_len);
|
||||
if (error == ESP_OK) {
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
} else {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
}
|
||||
@@ -392,9 +353,12 @@ static int change_profile_state(const char *operation, const char *slot_text)
|
||||
return 1;
|
||||
}
|
||||
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -406,7 +370,7 @@ static int change_profile_state(const char *operation, const char *slot_text)
|
||||
profile->enabled = strcmp(operation, "enable") == 0 ? 1U : 0U;
|
||||
}
|
||||
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not %s profile %u: %s\n", operation,
|
||||
(unsigned int)slot, esp_err_to_name(error));
|
||||
@@ -419,9 +383,12 @@ static int change_profile_state(const char *operation, const char *slot_text)
|
||||
|
||||
static int set_ap_parameter(const char *parameter, const char *value)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
@@ -454,7 +421,7 @@ static int set_ap_parameter(const char *parameter, const char *value)
|
||||
config.ap_channel = (uint8_t)channel;
|
||||
}
|
||||
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not apply AP configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
@@ -465,16 +432,19 @@ static int set_ap_parameter(const char *parameter, const char *value)
|
||||
|
||||
static int set_ap_secret(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
if (error != ESP_OK) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
printf("Could not read working configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
|
||||
error = read_secret_no_echo(config.ap_psk, &config.ap_psk_len);
|
||||
if (error == ESP_OK) {
|
||||
error = apply_candidate(&config);
|
||||
error = apply_candidate(&config, expected_generation, NULL);
|
||||
} else {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
}
|
||||
@@ -490,7 +460,7 @@ static int set_ap_secret(void)
|
||||
|
||||
static int show_ap_secret(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
wifi_app_config_t config = {0};
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not read AP secret: %s\n", esp_err_to_name(error));
|
||||
@@ -507,12 +477,15 @@ static int show_ap_secret(void)
|
||||
|
||||
static int save_config(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
esp_err_t error = wifi_manager_get_working_config(&config);
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_config_save(&config);
|
||||
}
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&config, &expected_generation);
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_save_working_config_if_generation(
|
||||
expected_generation);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not save Wi-Fi configuration: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
@@ -523,11 +496,19 @@ static int save_config(void)
|
||||
|
||||
static int load_config(void)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
wifi_config_load_source_t source;
|
||||
esp_err_t error = wifi_config_load(&config, &source);
|
||||
wifi_app_config_t current = {0};
|
||||
wifi_app_config_t config = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
wifi_config_load_source_t source = WIFI_CONFIG_LOAD_GENERATED_MISSING;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
¤t, &expected_generation);
|
||||
wifi_config_secure_wipe(¤t, sizeof(current));
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_apply_working_config(&config);
|
||||
error = wifi_config_load(&config, &source);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_compare_exchange_working_config(
|
||||
&config, expected_generation, NULL);
|
||||
}
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
if (error != ESP_OK) {
|
||||
@@ -545,20 +526,32 @@ static int load_config(void)
|
||||
|
||||
static int apply_defaults(bool persist)
|
||||
{
|
||||
wifi_app_config_t previous;
|
||||
wifi_app_config_t defaults;
|
||||
esp_err_t error = wifi_manager_get_working_config(&previous);
|
||||
wifi_app_config_t previous = {0};
|
||||
wifi_app_config_t defaults = {0};
|
||||
uint32_t expected_generation = 0U;
|
||||
uint32_t defaults_generation = 0U;
|
||||
esp_err_t error = wifi_manager_get_working_config_versioned(
|
||||
&previous, &expected_generation);
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_config_defaults(&defaults);
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = wifi_manager_apply_working_config(&defaults);
|
||||
error = wifi_manager_compare_exchange_working_config(
|
||||
&defaults, expected_generation, &defaults_generation);
|
||||
}
|
||||
if (error == ESP_OK && persist) {
|
||||
error = wifi_config_reset_storage(&defaults);
|
||||
if (error != ESP_OK) {
|
||||
/* Restore RAM behavior if persistence failed. */
|
||||
(void)wifi_manager_apply_working_config(&previous);
|
||||
error = wifi_manager_save_working_config_if_generation(
|
||||
defaults_generation);
|
||||
if (error != ESP_OK && error != WIFI_MANAGER_ERR_CONFIG_CONFLICT) {
|
||||
/* Restore RAM behavior only if no newer writer has won. */
|
||||
esp_err_t rollback_error =
|
||||
wifi_manager_compare_exchange_working_config(
|
||||
&previous, defaults_generation, NULL);
|
||||
if (rollback_error != ESP_OK &&
|
||||
rollback_error != WIFI_MANAGER_ERR_CONFIG_CONFLICT) {
|
||||
printf("Warning: could not restore the prior RAM configuration: %s\n",
|
||||
esp_err_to_name(rollback_error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,8 +573,10 @@ static int queue_lifecycle(const char *operation)
|
||||
error = wifi_manager_start();
|
||||
} else if (strcmp(operation, "stop") == 0) {
|
||||
error = wifi_manager_stop();
|
||||
} else {
|
||||
} else if (strcmp(operation, "reconnect") == 0) {
|
||||
error = wifi_manager_reconnect();
|
||||
} else {
|
||||
error = wifi_manager_next_profile();
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not queue Wi-Fi %s: %s\n", operation, esp_err_to_name(error));
|
||||
@@ -617,7 +612,8 @@ static int command_wifi(int argc, char **argv)
|
||||
}
|
||||
if (argc == 2 && (strcmp(argv[1], "start") == 0 ||
|
||||
strcmp(argv[1], "stop") == 0 ||
|
||||
strcmp(argv[1], "reconnect") == 0)) {
|
||||
strcmp(argv[1], "reconnect") == 0 ||
|
||||
strcmp(argv[1], "next-profile") == 0)) {
|
||||
return queue_lifecycle(argv[1]);
|
||||
}
|
||||
if (argc == 7 && strcmp(argv[1], "profile") == 0 &&
|
||||
|
||||
+294
-30
@@ -17,6 +17,7 @@
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "mdns_service.h"
|
||||
|
||||
#define WIFI_MANAGER_QUEUE_LENGTH 16U
|
||||
#define WIFI_MANAGER_TASK_STACK_SIZE 6144U
|
||||
@@ -34,6 +35,8 @@ typedef enum {
|
||||
MESSAGE_COMMAND_STOP,
|
||||
MESSAGE_COMMAND_APPLY,
|
||||
MESSAGE_COMMAND_RECONNECT,
|
||||
MESSAGE_COMMAND_NEXT_PROFILE,
|
||||
MESSAGE_COMMAND_MDNS_REANNOUNCE,
|
||||
MESSAGE_STA_CONNECTED,
|
||||
MESSAGE_STA_DISCONNECTED,
|
||||
MESSAGE_STA_GOT_IP,
|
||||
@@ -91,6 +94,8 @@ typedef struct {
|
||||
} manager_runtime_t;
|
||||
|
||||
static SemaphoreHandle_t s_mutex;
|
||||
/* Serializes every mutation of s_shared.config, including persistence. */
|
||||
static SemaphoreHandle_t s_config_writer_mutex;
|
||||
static QueueHandle_t s_queue;
|
||||
static TaskHandle_t s_task;
|
||||
static esp_netif_t *s_sta_netif;
|
||||
@@ -108,6 +113,15 @@ static void manager_task(void *context);
|
||||
static void start_profile_cycle(manager_runtime_t *runtime);
|
||||
static void start_next_profile(manager_runtime_t *runtime);
|
||||
|
||||
static void start_mdns_announcement(void)
|
||||
{
|
||||
esp_err_t error = mdns_service_start();
|
||||
if (error != ESP_OK) {
|
||||
/* Name discovery is optional; never make network or serial recovery depend on it. */
|
||||
ESP_LOGW(TAG, "mDNS announcement unavailable: %s", esp_err_to_name(error));
|
||||
}
|
||||
}
|
||||
|
||||
static void lock_shared(void)
|
||||
{
|
||||
(void)xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
@@ -118,6 +132,16 @@ static void unlock_shared(void)
|
||||
(void)xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
static void lock_config_writer(void)
|
||||
{
|
||||
(void)xSemaphoreTake(s_config_writer_mutex, portMAX_DELAY);
|
||||
}
|
||||
|
||||
static void unlock_config_writer(void)
|
||||
{
|
||||
(void)xSemaphoreGive(s_config_writer_mutex);
|
||||
}
|
||||
|
||||
static bool manager_is_started(void)
|
||||
{
|
||||
bool started;
|
||||
@@ -195,6 +219,48 @@ static void copy_working_config(wifi_app_config_t *config)
|
||||
unlock_shared();
|
||||
}
|
||||
|
||||
static bool profiles_equal(const wifi_config_sta_profile_t *left,
|
||||
const wifi_config_sta_profile_t *right)
|
||||
{
|
||||
return left->enabled == right->enabled &&
|
||||
left->priority == right->priority &&
|
||||
left->security == right->security &&
|
||||
left->ssid_len == right->ssid_len &&
|
||||
left->psk_len == right->psk_len &&
|
||||
memcmp(left->ssid, right->ssid, left->ssid_len) == 0 &&
|
||||
memcmp(left->psk, right->psk, left->psk_len) == 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Disabled profiles are staged configuration, not current radio policy. Their
|
||||
* credentials and priority may be prepared without interrupting a live link.
|
||||
*/
|
||||
static bool config_requires_radio_restart(const wifi_app_config_t *current,
|
||||
const wifi_app_config_t *candidate)
|
||||
{
|
||||
if (current->ap_policy != candidate->ap_policy ||
|
||||
current->ap_ssid_len != candidate->ap_ssid_len ||
|
||||
current->ap_psk_len != candidate->ap_psk_len ||
|
||||
current->ap_channel != candidate->ap_channel ||
|
||||
memcmp(current->ap_ssid, candidate->ap_ssid, current->ap_ssid_len) != 0 ||
|
||||
memcmp(current->ap_psk, candidate->ap_psk, current->ap_psk_len) != 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (size_t slot = 0U; slot < WIFI_CONFIG_STA_PROFILE_COUNT; ++slot) {
|
||||
const wifi_config_sta_profile_t *old_profile = ¤t->profiles[slot];
|
||||
const wifi_config_sta_profile_t *new_profile = &candidate->profiles[slot];
|
||||
bool old_enabled = old_profile->enabled != 0U;
|
||||
bool new_enabled = new_profile->enabled != 0U;
|
||||
|
||||
if (old_enabled != new_enabled ||
|
||||
(old_enabled && !profiles_equal(old_profile, new_profile))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool connected_event_matches_active_profile(const manager_message_t *message)
|
||||
{
|
||||
bool matches = false;
|
||||
@@ -233,12 +299,17 @@ static uint8_t build_profile_order(uint8_t *order, const wifi_app_config_t *conf
|
||||
return count;
|
||||
}
|
||||
|
||||
static void note_ap_running(bool running, uint8_t channel)
|
||||
static void note_ap_running(bool running, const wifi_app_config_t *config)
|
||||
{
|
||||
lock_shared();
|
||||
bool changed = s_shared.snapshot.ap_running != running;
|
||||
s_shared.snapshot.ap_running = running;
|
||||
s_shared.snapshot.ap_channel = channel;
|
||||
s_shared.snapshot.ap_channel = config->ap_channel;
|
||||
if (running) {
|
||||
s_shared.snapshot.ap_ssid_len = config->ap_ssid_len;
|
||||
memset(s_shared.snapshot.ap_ssid, 0, sizeof(s_shared.snapshot.ap_ssid));
|
||||
memcpy(s_shared.snapshot.ap_ssid, config->ap_ssid, config->ap_ssid_len);
|
||||
}
|
||||
if (!running) {
|
||||
s_shared.snapshot.ap_client_count = 0U;
|
||||
}
|
||||
@@ -325,7 +396,7 @@ static esp_err_t set_runtime_ap_enabled(manager_runtime_t *runtime, bool enabled
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
runtime->ap_enabled = true;
|
||||
note_ap_running(true, config.ap_channel);
|
||||
note_ap_running(true, &config);
|
||||
} else {
|
||||
/* Do not leave an untracked default/stale AP active after failure. */
|
||||
esp_err_t rollback_error = esp_wifi_set_mode(WIFI_MODE_STA);
|
||||
@@ -338,7 +409,7 @@ static esp_err_t set_runtime_ap_enabled(manager_runtime_t *runtime, bool enabled
|
||||
error = esp_wifi_set_mode(WIFI_MODE_STA);
|
||||
if (error == ESP_OK) {
|
||||
runtime->ap_enabled = false;
|
||||
note_ap_running(false, config.ap_channel);
|
||||
note_ap_running(false, &config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +433,7 @@ static void mark_intentional_disconnect(manager_runtime_t *runtime)
|
||||
|
||||
static void stop_radio(manager_runtime_t *runtime)
|
||||
{
|
||||
mdns_service_stop();
|
||||
if (!runtime->radio_started) {
|
||||
return;
|
||||
}
|
||||
@@ -379,7 +451,7 @@ static void stop_radio(manager_runtime_t *runtime)
|
||||
wifi_app_config_t config;
|
||||
copy_working_config(&config);
|
||||
if (runtime->ap_enabled) {
|
||||
note_ap_running(false, config.ap_channel);
|
||||
note_ap_running(false, &config);
|
||||
}
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
|
||||
@@ -483,6 +555,58 @@ static void start_next_profile(manager_runtime_t *runtime)
|
||||
schedule_cycle_retry(runtime);
|
||||
}
|
||||
|
||||
static void start_next_profile_after_current(manager_runtime_t *runtime)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
copy_working_config(&config);
|
||||
uint8_t order[WIFI_CONFIG_STA_PROFILE_COUNT];
|
||||
uint8_t count = build_profile_order(order, &config);
|
||||
int8_t active_profile;
|
||||
lock_shared();
|
||||
active_profile = s_shared.snapshot.active_profile;
|
||||
unlock_shared();
|
||||
|
||||
if (count == 0U) {
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
set_last_error(ESP_ERR_NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
|
||||
uint8_t active_index = 0U;
|
||||
bool found_active = false;
|
||||
for (uint8_t index = 0U; index < count; ++index) {
|
||||
if (active_profile >= 0 && order[index] == (uint8_t)active_profile) {
|
||||
active_index = index;
|
||||
found_active = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (uint8_t index = 0U; index < count; ++index) {
|
||||
uint8_t source = found_active ? (uint8_t)((active_index + 1U + index) % count)
|
||||
: index;
|
||||
runtime->profile_order[index] = order[source];
|
||||
}
|
||||
runtime->profile_count = count;
|
||||
runtime->next_profile = 0U;
|
||||
runtime->attempt_deadline = 0;
|
||||
runtime->disconnect_deadline = 0;
|
||||
runtime->backoff_deadline = 0;
|
||||
runtime->stable_deadline = 0;
|
||||
runtime->advance_after_disconnect = true;
|
||||
|
||||
uint32_t intentional_disconnects = runtime->intentional_disconnects;
|
||||
mark_intentional_disconnect(runtime);
|
||||
if (runtime->intentional_disconnects == intentional_disconnects) {
|
||||
runtime->advance_after_disconnect = false;
|
||||
start_next_profile(runtime);
|
||||
} else {
|
||||
/* Recover if the bounded queue drops the matching disconnect event. */
|
||||
runtime->disconnect_deadline = esp_timer_get_time() +
|
||||
WIFI_MANAGER_DISCONNECT_SETTLE_US;
|
||||
}
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
}
|
||||
|
||||
static void start_profile_cycle(manager_runtime_t *runtime)
|
||||
{
|
||||
wifi_app_config_t config;
|
||||
@@ -551,7 +675,7 @@ static void start_radio_and_policy(manager_runtime_t *runtime)
|
||||
runtime->radio_started = true;
|
||||
runtime->ap_enabled = want_ap;
|
||||
set_last_error(ESP_OK);
|
||||
note_ap_running(want_ap, config.ap_channel);
|
||||
note_ap_running(want_ap, &config);
|
||||
|
||||
if (runtime->profile_count == 0U) {
|
||||
set_active_profile(-1, NULL);
|
||||
@@ -644,6 +768,7 @@ static void handle_got_ip(manager_runtime_t *runtime,
|
||||
unlock_shared();
|
||||
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
start_mdns_announcement();
|
||||
}
|
||||
|
||||
static void handle_sta_disconnected(manager_runtime_t *runtime,
|
||||
@@ -664,6 +789,7 @@ static void handle_sta_disconnected(manager_runtime_t *runtime,
|
||||
bool had_attempt = runtime->attempt_deadline != 0;
|
||||
bool was_online = runtime->online;
|
||||
|
||||
mdns_service_stop();
|
||||
runtime->associated = false;
|
||||
runtime->online = false;
|
||||
runtime->attempt_deadline = 0;
|
||||
@@ -762,6 +888,22 @@ static void handle_message(manager_runtime_t *runtime,
|
||||
}
|
||||
break;
|
||||
|
||||
case MESSAGE_COMMAND_NEXT_PROFILE:
|
||||
if (manager_is_started()) {
|
||||
start_next_profile_after_current(runtime);
|
||||
}
|
||||
break;
|
||||
|
||||
case MESSAGE_COMMAND_MDNS_REANNOUNCE:
|
||||
if (runtime->online) {
|
||||
esp_err_t error = mdns_service_reannounce();
|
||||
if (error != ESP_OK) {
|
||||
ESP_LOGW(TAG, "mDNS reannouncement unavailable: %s",
|
||||
esp_err_to_name(error));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case MESSAGE_STA_CONNECTED:
|
||||
if (!manager_is_started() ||
|
||||
!connected_event_matches_active_profile(message)) {
|
||||
@@ -794,6 +936,7 @@ static void handle_message(manager_runtime_t *runtime,
|
||||
/* Ignore a delayed loss event after a newer DHCP lease. */
|
||||
break;
|
||||
}
|
||||
mdns_service_stop();
|
||||
runtime->online = false;
|
||||
runtime->stable_deadline = 0;
|
||||
runtime->attempt_deadline = esp_timer_get_time() + WIFI_MANAGER_ATTEMPT_US;
|
||||
@@ -1146,10 +1289,15 @@ static void cleanup_failed_init(bool wifi_initialized, bool wifi_handler_registe
|
||||
vQueueDelete(s_queue);
|
||||
s_queue = NULL;
|
||||
}
|
||||
if (s_config_writer_mutex != NULL) {
|
||||
vSemaphoreDelete(s_config_writer_mutex);
|
||||
s_config_writer_mutex = NULL;
|
||||
}
|
||||
if (s_mutex != NULL) {
|
||||
vSemaphoreDelete(s_mutex);
|
||||
s_mutex = NULL;
|
||||
}
|
||||
wifi_config_secure_wipe(&s_shared, sizeof(s_shared));
|
||||
s_task = NULL;
|
||||
}
|
||||
|
||||
@@ -1159,7 +1307,7 @@ esp_err_t wifi_manager_init(const wifi_app_config_t *config)
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
if (s_mutex != NULL) {
|
||||
if (s_mutex != NULL || s_config_writer_mutex != NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
@@ -1167,6 +1315,11 @@ esp_err_t wifi_manager_init(const wifi_app_config_t *config)
|
||||
if (s_mutex == NULL) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
s_config_writer_mutex = xSemaphoreCreateMutex();
|
||||
if (s_config_writer_mutex == NULL) {
|
||||
cleanup_failed_init(false, false, false, false);
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
s_queue = xQueueCreate(WIFI_MANAGER_QUEUE_LENGTH, sizeof(manager_message_t));
|
||||
if (s_queue == NULL) {
|
||||
cleanup_failed_init(false, false, false, false);
|
||||
@@ -1178,6 +1331,9 @@ esp_err_t wifi_manager_init(const wifi_app_config_t *config)
|
||||
s_shared.snapshot.state = WIFI_MANAGER_STATE_STOPPED;
|
||||
s_shared.snapshot.active_profile = -1;
|
||||
s_shared.snapshot.ap_policy = config->ap_policy;
|
||||
s_shared.snapshot.ap_ssid_len = config->ap_ssid_len;
|
||||
memcpy(s_shared.snapshot.ap_ssid, config->ap_ssid, config->ap_ssid_len);
|
||||
s_shared.snapshot.ap_ssid[config->ap_ssid_len] = '\0';
|
||||
s_shared.snapshot.ap_channel = config->ap_channel;
|
||||
s_shared.snapshot.sta_auth = WIFI_AUTH_OPEN;
|
||||
s_shared.snapshot.last_error = ESP_OK;
|
||||
@@ -1267,70 +1423,168 @@ esp_err_t wifi_manager_init(const wifi_app_config_t *config)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config)
|
||||
esp_err_t wifi_manager_get_working_config_versioned(wifi_app_config_t *config,
|
||||
uint32_t *generation)
|
||||
{
|
||||
if (config == NULL) {
|
||||
if (config != NULL) {
|
||||
wifi_config_secure_wipe(config, sizeof(*config));
|
||||
}
|
||||
if (generation != NULL) {
|
||||
*generation = 0U;
|
||||
}
|
||||
if (config == NULL || generation == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (s_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
copy_working_config(config);
|
||||
lock_shared();
|
||||
*config = s_shared.config;
|
||||
*generation = s_shared.snapshot.config_generation;
|
||||
unlock_shared();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config)
|
||||
esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config)
|
||||
{
|
||||
uint32_t generation = 0U;
|
||||
return wifi_manager_get_working_config_versioned(config, &generation);
|
||||
}
|
||||
|
||||
/* s_config_writer_mutex must be held by the caller. */
|
||||
static esp_err_t apply_working_config_serialized(
|
||||
const wifi_app_config_t *config, bool compare_generation,
|
||||
uint32_t expected_generation, uint32_t *resulting_generation)
|
||||
{
|
||||
lock_shared();
|
||||
if (compare_generation &&
|
||||
s_shared.snapshot.config_generation != expected_generation) {
|
||||
unlock_shared();
|
||||
return WIFI_MANAGER_ERR_CONFIG_CONFLICT;
|
||||
}
|
||||
if (s_shared.snapshot.config_generation == UINT32_MAX) {
|
||||
unlock_shared();
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
bool restart_radio = config_requires_radio_restart(&s_shared.config, config);
|
||||
if (restart_radio) {
|
||||
manager_message_t message = {.type = MESSAGE_COMMAND_APPLY};
|
||||
if (!enqueue_message(&message)) {
|
||||
unlock_shared();
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
}
|
||||
|
||||
s_shared.config = *config;
|
||||
++s_shared.snapshot.config_generation;
|
||||
s_shared.snapshot.ap_policy = config->ap_policy;
|
||||
++s_shared.snapshot.counters.applies;
|
||||
if (resulting_generation != NULL) {
|
||||
*resulting_generation = s_shared.snapshot.config_generation;
|
||||
}
|
||||
unlock_shared();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
esp_err_t wifi_manager_compare_exchange_working_config(
|
||||
const wifi_app_config_t *config, uint32_t expected_generation,
|
||||
uint32_t *resulting_generation)
|
||||
{
|
||||
if (resulting_generation != NULL) {
|
||||
*resulting_generation = 0U;
|
||||
}
|
||||
if (expected_generation == 0U) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
esp_err_t error = wifi_config_validate(config);
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
if (s_mutex == NULL) {
|
||||
if (s_mutex == NULL || s_config_writer_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
manager_message_t message = {.type = MESSAGE_COMMAND_APPLY};
|
||||
lock_config_writer();
|
||||
error = apply_working_config_serialized(
|
||||
config, true, expected_generation, resulting_generation);
|
||||
unlock_config_writer();
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_save_working_config_if_generation(
|
||||
uint32_t expected_generation)
|
||||
{
|
||||
if (expected_generation == 0U) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (expected_generation == UINT32_MAX) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (s_mutex == NULL || s_config_writer_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
wifi_app_config_t config = {0};
|
||||
lock_config_writer();
|
||||
lock_shared();
|
||||
if (!enqueue_message(&message)) {
|
||||
if (s_shared.snapshot.config_generation != expected_generation) {
|
||||
unlock_shared();
|
||||
return ESP_ERR_TIMEOUT;
|
||||
unlock_config_writer();
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
return WIFI_MANAGER_ERR_CONFIG_CONFLICT;
|
||||
}
|
||||
s_shared.config = *config;
|
||||
++s_shared.snapshot.config_generation;
|
||||
if (s_shared.snapshot.config_generation == 0U) {
|
||||
s_shared.snapshot.config_generation = 1U;
|
||||
}
|
||||
s_shared.snapshot.ap_policy = config->ap_policy;
|
||||
s_shared.snapshot.ap_channel = config->ap_channel;
|
||||
++s_shared.snapshot.counters.applies;
|
||||
config = s_shared.config;
|
||||
unlock_shared();
|
||||
return ESP_OK;
|
||||
|
||||
esp_err_t error = wifi_config_save(&config);
|
||||
wifi_config_secure_wipe(&config, sizeof(config));
|
||||
unlock_config_writer();
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t enqueue_lifecycle_command(manager_message_type_t type,
|
||||
int enabled_at_boot)
|
||||
{
|
||||
if (s_mutex == NULL) {
|
||||
if (s_mutex == NULL || s_config_writer_mutex == NULL) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
bool writes_config = enabled_at_boot >= 0;
|
||||
if (writes_config) {
|
||||
lock_config_writer();
|
||||
}
|
||||
|
||||
manager_message_t message = {.type = type};
|
||||
lock_shared();
|
||||
bool changes_config =
|
||||
writes_config &&
|
||||
s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot;
|
||||
if (changes_config && s_shared.snapshot.config_generation == UINT32_MAX) {
|
||||
unlock_shared();
|
||||
if (writes_config) {
|
||||
unlock_config_writer();
|
||||
}
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (!enqueue_message(&message)) {
|
||||
unlock_shared();
|
||||
if (writes_config) {
|
||||
unlock_config_writer();
|
||||
}
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
if (enabled_at_boot >= 0 &&
|
||||
s_shared.config.enabled_at_boot != (uint8_t)enabled_at_boot) {
|
||||
if (changes_config) {
|
||||
s_shared.config.enabled_at_boot = (uint8_t)enabled_at_boot;
|
||||
++s_shared.snapshot.config_generation;
|
||||
if (s_shared.snapshot.config_generation == 0U) {
|
||||
s_shared.snapshot.config_generation = 1U;
|
||||
}
|
||||
}
|
||||
unlock_shared();
|
||||
if (writes_config) {
|
||||
unlock_config_writer();
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -1349,6 +1603,16 @@ esp_err_t wifi_manager_reconnect(void)
|
||||
return enqueue_lifecycle_command(MESSAGE_COMMAND_RECONNECT, -1);
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_next_profile(void)
|
||||
{
|
||||
return enqueue_lifecycle_command(MESSAGE_COMMAND_NEXT_PROFILE, -1);
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_mdns_reannounce(void)
|
||||
{
|
||||
return enqueue_lifecycle_command(MESSAGE_COMMAND_MDNS_REANNOUNCE, -1);
|
||||
}
|
||||
|
||||
esp_err_t wifi_manager_get_snapshot(wifi_manager_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) {
|
||||
|
||||
+40
-4
@@ -60,6 +60,8 @@ typedef struct {
|
||||
|
||||
wifi_config_ap_policy_t ap_policy;
|
||||
bool ap_running;
|
||||
uint8_t ap_ssid_len;
|
||||
char ap_ssid[WIFI_CONFIG_SSID_MAX_LEN + 1U];
|
||||
uint8_t ap_client_count;
|
||||
uint8_t ap_channel;
|
||||
|
||||
@@ -70,6 +72,9 @@ typedef struct {
|
||||
wifi_manager_counters_t counters;
|
||||
} wifi_manager_snapshot_t;
|
||||
|
||||
/* A stale expected working-config generation maps cleanly to HTTP 409. */
|
||||
#define WIFI_MANAGER_ERR_CONFIG_CONFLICT ESP_ERR_INVALID_VERSION
|
||||
|
||||
/*
|
||||
* Initializes ESP-NETIF, the default event loop, both default Wi-Fi netifs,
|
||||
* Wi-Fi itself, and the permanent policy task. The manager never aborts the
|
||||
@@ -77,19 +82,50 @@ typedef struct {
|
||||
*/
|
||||
esp_err_t wifi_manager_init(const wifi_app_config_t *config);
|
||||
|
||||
/* Returns a copy of the RAM working configuration, including credentials. */
|
||||
/*
|
||||
* Returns a copy of the RAM working configuration, including credentials.
|
||||
* The caller owns the returned copy and must securely wipe it after use.
|
||||
*/
|
||||
esp_err_t wifi_manager_get_working_config(wifi_app_config_t *config);
|
||||
|
||||
/*
|
||||
* Replaces the RAM working configuration. Application is asynchronous; when
|
||||
* Wi-Fi is running, the manager task restarts it using the newest generation.
|
||||
* Atomically copies the credential-bearing working configuration and the exact
|
||||
* nonzero generation that identified it. Both outputs are cleared on failure;
|
||||
* the caller must securely wipe config after every successful call.
|
||||
*/
|
||||
esp_err_t wifi_manager_apply_working_config(const wifi_app_config_t *config);
|
||||
esp_err_t wifi_manager_get_working_config_versioned(wifi_app_config_t *config,
|
||||
uint32_t *generation);
|
||||
|
||||
|
||||
/*
|
||||
* Atomically replace the complete validated working configuration only when
|
||||
* expected_generation still identifies the current configuration. A stale
|
||||
* expectation returns WIFI_MANAGER_ERR_CONFIG_CONFLICT without queueing a
|
||||
* restart or changing state. resulting_generation is optional and is set to
|
||||
* zero on failure. Generation exhaustion returns ESP_ERR_INVALID_STATE.
|
||||
*/
|
||||
esp_err_t wifi_manager_compare_exchange_working_config(
|
||||
const wifi_app_config_t *config, uint32_t expected_generation,
|
||||
uint32_t *resulting_generation);
|
||||
|
||||
/*
|
||||
* Persist exactly the working configuration identified by expected_generation.
|
||||
* All config writers are excluded through the NVS operation. A stale
|
||||
* expectation returns WIFI_MANAGER_ERR_CONFIG_CONFLICT. Generation exhaustion
|
||||
* returns ESP_ERR_INVALID_STATE. The saved generation is not incremented because
|
||||
* the RAM working configuration is unchanged.
|
||||
*/
|
||||
esp_err_t wifi_manager_save_working_config_if_generation(
|
||||
uint32_t expected_generation);
|
||||
|
||||
/* Lifecycle requests are asynchronous and serialized by the manager task. */
|
||||
esp_err_t wifi_manager_start(void);
|
||||
esp_err_t wifi_manager_stop(void);
|
||||
esp_err_t wifi_manager_reconnect(void);
|
||||
/* Advance to the next enabled station profile in priority order, wrapping safely. */
|
||||
esp_err_t wifi_manager_next_profile(void);
|
||||
/* Reannounce the configured hostname when the manager currently has a STA IP. */
|
||||
esp_err_t wifi_manager_mdns_reannounce(void);
|
||||
|
||||
/* Snapshot data never contains station or AP passwords. */
|
||||
esp_err_t wifi_manager_get_snapshot(wifi_manager_snapshot_t *snapshot);
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
Pictogrammers Free License
|
||||
--------------------------
|
||||
|
||||
This icon collection is released as free, open source, and GPL friendly by
|
||||
the [Pictogrammers](http://pictogrammers.com/) icon group. You may use it
|
||||
for commercial projects, open source projects, or anything really.
|
||||
|
||||
# Icons: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
|
||||
Some of the icons are redistributed under the Apache 2.0 license. All other
|
||||
icons are either redistributed under their respective licenses or are
|
||||
distributed under the Apache 2.0 license.
|
||||
|
||||
# Fonts: Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
|
||||
All web and desktop fonts are distributed under the Apache 2.0 license. Web
|
||||
and desktop fonts contain some icons that are redistributed under the Apache
|
||||
2.0 license. All other icons are either redistributed under their respective
|
||||
licenses or are distributed under the Apache 2.0 license.
|
||||
|
||||
# Code: MIT (https://opensource.org/licenses/MIT)
|
||||
The MIT license applies to all non-font and non-icon files.
|
||||
+1
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="mdi-usb" viewBox="0 0 24 24"><path d="M15,7V11H16V13H13V5H15L12,1L9,5H11V13H8V10.93C8.7,10.56 9.2,9.85 9.2,9C9.2,7.78 8.21,6.8 7,6.8C5.78,6.8 4.8,7.78 4.8,9C4.8,9.85 5.3,10.56 6,10.93V13A2,2 0 0,0 8,15H11V18.05C10.29,18.41 9.8,19.15 9.8,20A2.2,2.2 0 0,0 12,22.2A2.2,2.2 0 0,0 14.2,20C14.2,19.15 13.71,18.41 13,18.05V15H16A2,2 0 0,0 18,13V11H19V7H15Z" /></svg>
|
||||
|
After Width: | Height: | Size: 403 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="mdi-wifi-strength-4" viewBox="0 0 24 24"><path d="M12,3C7.79,3 3.7,4.41 0.38,7C4.41,12.06 7.89,16.37 12,21.5C16.08,16.42 20.24,11.24 23.65,7C20.32,4.41 16.22,3 12,3Z" /></svg>
|
||||
|
After Width: | Height: | Size: 219 B |
@@ -0,0 +1,30 @@
|
||||
# Vendored web assets
|
||||
|
||||
The browser distributions below were downloaded from the exact-version npm packages via unpkg. `logo.png` is a project-owned optimized copy of `images/logo.png`, generated at 64×64 for the authenticated web interface.
|
||||
|
||||
| Package | Version | License | Local files | Upstream URLs |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `@xterm/xterm` | `5.5.0` | MIT (`xterm.LICENSE`) | `xterm.js`, `xterm.css` | <https://unpkg.com/@xterm/xterm@5.5.0/lib/xterm.js>, <https://unpkg.com/@xterm/xterm@5.5.0/css/xterm.css>, <https://unpkg.com/@xterm/xterm@5.5.0/LICENSE> |
|
||||
| `@xterm/addon-fit` | `0.10.0` | MIT (`addon-fit.LICENSE`) | `addon-fit.js` | <https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js>, <https://unpkg.com/@xterm/addon-fit@0.10.0/LICENSE> |
|
||||
| Project logo | — | Project-owned | `logo.png` | Generated from `images/logo.png` |
|
||||
|
||||
Package metadata: <https://unpkg.com/@xterm/xterm@5.5.0/package.json> and <https://unpkg.com/@xterm/addon-fit@0.10.0/package.json>.
|
||||
|
||||
The `.gz` files are deterministic build artifacts generated from the corresponding JS/CSS files with `gzip -9 -n -c`. The `-n` option omits source names and timestamps. PlatformIO's ESP-IDF/SCons bridge cannot reliably compile ESP-IDF's generated `BUILD_DIR/*.S` files, so `generate_embedded_assets.py` converts the compressed files into the deterministic `src/web_assets_data.c` byte arrays used by the firmware:
|
||||
|
||||
```sh
|
||||
python3 web_assets/generate_embedded_assets.py
|
||||
```
|
||||
|
||||
## SHA-256 verification
|
||||
|
||||
| File | SHA-256 |
|
||||
| --- | --- |
|
||||
| `xterm.js` | `1f991ac3b4b283ebf96e60ae23a00a52765dd3a2e46fa6fdda9f1aab032f7495` |
|
||||
| `xterm.css` | `ba8e6985669488981ccf40c0cefe3aba80722cb6c92de7ad628b0bd717faf2b6` |
|
||||
| `addon-fit.js` | `bdaefa370b1bfc42ee88d46fe6072400902a4d4b2d45cd93438dda9b23c97089` |
|
||||
| `xterm.LICENSE` | `b569f629d00f2626a8100df2a1798210535621e42164dfd426a6fe5aac7b0ccd` |
|
||||
| `addon-fit.LICENSE` | `e256f01188af527e4d06d21d06fbf785ae9c50d4b328bf03cbe0ba7f0aa4228f` |
|
||||
| `xterm.js.gz` | `042c744ad77ddeda439cc095a70a9b29c62eca541b18cd0d3ac80081cc492f50` |
|
||||
| `xterm.css.gz` | `876ead49256d30169786511ff27300116634d21951e46f373d19191928d385f3` |
|
||||
| `addon-fit.js.gz` | `163634a1eb3c4d7ec77faeb1bd60255872f2558e5c28756bfabb48cc00feafc5` |
|
||||
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2019, The xterm.js authors (https://github.com/xtermjs/xterm.js)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
Binary file not shown.
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: GPL-3.0-only
|
||||
"""Generate deterministic C arrays for PlatformIO-compatible asset embedding."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ASSETS = (
|
||||
("xterm.js.gz", "web_asset_xterm_js_gz"),
|
||||
("xterm.css.gz", "web_asset_xterm_css_gz"),
|
||||
("addon-fit.js.gz", "web_asset_addon_fit_js_gz"),
|
||||
("logo.png", "web_asset_logo_png"),
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ASSET_DIR = ROOT / "web_assets"
|
||||
HEADER_PATH = ROOT / "src" / "web_assets_data.h"
|
||||
SOURCE_PATH = ROOT / "src" / "web_assets_data.c"
|
||||
|
||||
|
||||
def format_array(data: bytes) -> str:
|
||||
lines = []
|
||||
for offset in range(0, len(data), 12):
|
||||
chunk = data[offset : offset + 12]
|
||||
lines.append(" " + ", ".join(f"0x{value:02x}" for value in chunk) + ",")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
header = """/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Generated web-asset data declarations. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
"""
|
||||
source = """/* SPDX-License-Identifier: MIT */
|
||||
/*
|
||||
* Deterministically generated from the vendored MIT-licensed gzip files in
|
||||
* web_assets/. See web_assets/SOURCES.md and the accompanying license files.
|
||||
* Regenerate with: python3 web_assets/generate_embedded_assets.py
|
||||
*/
|
||||
|
||||
#include "web_assets_data.h"
|
||||
|
||||
"""
|
||||
|
||||
for filename, symbol in ASSETS:
|
||||
data = (ASSET_DIR / filename).read_bytes()
|
||||
header += f"extern const uint8_t {symbol}[];\n"
|
||||
header += f"extern const size_t {symbol}_size;\n\n"
|
||||
source += (
|
||||
f"const uint8_t {symbol}[] __attribute__((aligned(4))) = {{\n"
|
||||
f"{format_array(data)}\n"
|
||||
"};\n"
|
||||
f"const size_t {symbol}_size = sizeof({symbol});\n\n"
|
||||
)
|
||||
|
||||
HEADER_PATH.write_text(header, encoding="utf-8", newline="\n")
|
||||
SOURCE_PATH.write_text(source, encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user