Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91267b371e | ||
|
|
1608641d50 | ||
|
|
8df1d2218b | ||
|
|
aa4bbc2c8c | ||
|
|
36e80811e8 | ||
|
|
737bd29f9e | ||
|
|
7ccc8799e9 | ||
|
|
9f6ebf2053 | ||
|
|
add399908a | ||
|
|
fa12440606 | ||
|
|
29a4953df0 | ||
|
|
d9ec3c08de | ||
|
|
60d9c54bb4 | ||
|
|
042499e4d6 | ||
|
|
36d41be422 | ||
|
|
4a4d615c59 | ||
|
|
989821b7c4 | ||
|
|
d9ac1319aa | ||
|
|
ac80863d80 | ||
|
|
82f21d6116 | ||
|
|
f6263042ff | ||
|
|
42f6423d4e | ||
|
|
22a7c7b0a5 | ||
|
|
23c190bcf0 | ||
|
|
94433ef975 | ||
|
|
42548f6334 | ||
|
|
5a2aa0d4d8 | ||
|
|
c73674cda2 | ||
|
|
93d8d1e5ca | ||
|
|
fe1e2d98b4 | ||
|
|
326119812f | ||
|
|
17520b15b7 | ||
|
|
0b86fd9c70 | ||
|
|
f15491f233 | ||
|
|
71f588360a | ||
|
|
e6db5428eb | ||
|
|
aeb2043396 | ||
|
|
e5dce12ed4 | ||
|
|
117c694cd4 | ||
|
|
f9ee6eec9c | ||
|
|
5a609fa40b | ||
|
|
4435a7fddd | ||
|
|
00f226dc59 | ||
|
|
a62a655ac1 | ||
|
|
93eef0e676 | ||
|
|
27c54c0a92 | ||
|
|
d4991658b1 | ||
|
|
d8999cd4a9 | ||
|
|
02fdeee345 | ||
|
|
af89dd1bd9 | ||
|
|
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 |
@@ -0,0 +1,10 @@
|
||||
CompileFlags:
|
||||
Add:
|
||||
- -isystem
|
||||
- /home/mscholz/.platformio/packages/toolchain-xtensa-esp-elf/xtensa-esp-elf/include
|
||||
Remove:
|
||||
- -mlongcalls
|
||||
- -mdisable-hardware-atomics
|
||||
- -fstrict-volatile-bitfields
|
||||
- -fno-tree-switch-conversion
|
||||
- -fno-shrink-wrap
|
||||
@@ -0,0 +1,17 @@
|
||||
// Folder-specific settings
|
||||
//
|
||||
// For a full list of overridable settings, and general information on folder-specific settings,
|
||||
// see the documentation: https://zed.dev/docs/configuring-zed#settings-files
|
||||
{
|
||||
"lsp": {
|
||||
"clangd": {
|
||||
"binary": {
|
||||
"path": "/usr/bin/clangd",
|
||||
"arguments": [
|
||||
"--background-index",
|
||||
"--query-driver=/home/mscholz/.platformio/packages/**/bin/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -7,6 +7,7 @@ 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)
|
||||
|
||||
@@ -10,17 +10,29 @@ ESP32-S3 firmware for a secure, multi-transport RS-232 adapter. It operates one
|
||||
- ESP32-S3-WROOM-1-N16R8 module
|
||||
- 16 MB flash
|
||||
- 8 MB octal PSRAM
|
||||
- [Adafruit MAX3243 full-pinout male RS-232 breakout](https://www.adafruit.com/product/6253)
|
||||
- Adafruit MAX3243 full-pinout RS-232 breakout; the male connector version is preferred (see [Hardware wiring](docs/wiring.md) for supported connector variants)
|
||||
|
||||
## Development status
|
||||
|
||||
Hardware characterization, the serial core, session broker, and native USB CDC-ACM are complete. The Wi-Fi foundation is implemented and substantially validated, with several smoke tests still pending. The project is currently in the **SSH-Phase**; later work includes interface hardware, security hardening, OTA, BLE, enterprise Wi-Fi, WireGuard evaluation, and optional storage features. See the [Roadmap](docs/roadmap.md) for validation details and roadmap.
|
||||
Hardware characterization, serial/USB/Wi-Fi/HTTPS/SSH and local display/control are implemented and hardware-validated. **Phase 8 role-based users and administration is complete:** 8A–8C were target-hardware validated and the user explicitly signed off tested firmware at **8D.22 (2026-09-13)**. See the [roadmap](docs/roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance evidence](docs/web_administration_acceptance.md). Very low internal/DMA lifetime minima remain a nonblocking headroom follow-up, not an approved reserve. Phase 9 hardening begins only on a separate request.
|
||||
|
||||
### Browser administration
|
||||
|
||||
Cookie login/logout supports both roles. Administrators also have an explicitly opened Admin shell sharing the canonical dispatcher, and typed **Serial, Accounts/password/SSH authorized keys, Network, Display, Broker, SSH, and HTTPS/Reboot** settings. Serial/Wi-Fi/client/writer quick controls reuse the same settings controllers. Navigation preserves terminal sessions and serial writer ownership; explicit transfer is confirmed and generation-safe. Browser-shell permissions remain narrower than typed Settings or UART0/admin SSH.
|
||||
|
||||
Network uses UTF-8 text/reversible hex SSIDs, explicit password Keep/Replace/disabled-STA Clear (never AP clear), RAM edits and explicit Save. Saved secrets are never prefilled/exported. Next profile follows enabled priority order, not the editor's selected index. Network `accepted` means owner admission, not online/DNS completion; disruption may precede acknowledgement.
|
||||
|
||||
HTTPS and SSH identity settings display public fingerprints and confirm both service and identity generations before rotation. HTTPS rotation commits before stop/restart and invalidates web logins; SSH stops before commit/restart and can disconnect clients even when persistence fails. A committed identity is never rolled back on restart failure. A lost response or timeout is not cancellation: inspect state rather than automatically replaying. Verify changed trust on UART0 with `web certificate info` or `ssh host-key info` before renewing browser trust/known_hosts; HTTPS restart requires fresh login. Browser identity reset/recovery/export is excluded; canonical CLI recovery remains available.
|
||||
|
||||
Keep UART0 ready for administrative recovery and native USB for network-independent UART1 access. USB is not an admin console, and whole-device reboot interrupts every transport. [Web administration contracts](docs/web_administration.md) cover API bounds, ownership, permissions, uncertainty and recovery. Dedicated typed network diagnostics and the unimplemented browser-session/USB control expansion were removed from scope; existing shell diagnostics and SSH settings remain.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Hardware wiring](docs/wiring.md): hardware profile, GPIO assignments, connector guidance, and safety notes.
|
||||
- [Electrical tests](docs/electrical_tests.md): MAX3243, UART loopback, and session-broker verification procedures.
|
||||
- [Command reference](docs/command_reference.md): UART0 administration, serial, broker, USB, Wi-Fi, web, SSH, and diagnostic commands.
|
||||
- [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 provisioning and administration, HTTPS/SSH authentication, session revocation, and reusable integrated web-administration regression procedures (not execution evidence).
|
||||
- [Web administration](docs/web_administration.md): current bounded API/owner and recovery contracts; [acceptance evidence](docs/web_administration_acceptance.md) records sign-off and telemetry limits.
|
||||
- [Command reference](docs/command_reference.md): UART0/admin-SSH administration, serial, broker, USB, Wi-Fi, mDNS, web, SSH, and diagnostic commands.
|
||||
|
||||
## Flash partition layout
|
||||
|
||||
@@ -28,7 +40,7 @@ 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, Wi-Fi, shared admin credential, HTTPS identity, and SSH host-key data |
|
||||
| `nvs` | `0x009000` | 512 KiB | Serial, Wi-Fi, mDNS hostname, local-display, role-based user, 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 |
|
||||
@@ -72,12 +84,18 @@ The firmware provides an interactive UART0 console at `serial-tool>`. Run `help`
|
||||
|
||||
The console supports session history, line editing, cursor movement, and hierarchical Tab completion. After an unattended boot, attach an ANSI-capable terminal and press Enter once to enable enhanced editing; this avoids blocking while no terminal is attached.
|
||||
|
||||
Serial configuration and Wi-Fi edits remain in RAM until explicitly saved with `serial save` or `wifi save`. Retrieve generated web and SSH credentials only from the physical UART0 console with `web credentials show`.
|
||||
Serial, Wi-Fi, and mDNS hostname edits remain in RAM until explicitly saved with `serial save`, `wifi save`, or `mdns save`. Authenticated admin SSH sessions expose the shared operational administration registry, including interactive secrets, TLS/SSH identity management, network diagnostics, and deferred reboot/SSH lifecycle commands. Create the first administrator on UART0 with `user add <username> admin` (optionally `--generate`). Explicit recovery of an unavailable user database remains UART0-only and rebuilds it empty; it refuses a healthy database. An administrator also cannot generate a replacement password for its own account over SSH, preventing the one-time value from being lost when that mutation revokes the session. Legacy web credential commands and `user bootstrap` are removed.
|
||||
|
||||
## Security notes
|
||||
|
||||
The HTTPS interface uses a device-specific self-signed certificate and HTTP Basic authentication over TLS; there is no plaintext HTTP or TCP serial listener. SSH supports password-authenticated interactive shell sessions only. The shared admin password, HTTPS private key, SSH private key, and Wi-Fi credentials are currently stored in plaintext application-owned NVS blobs. 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.
|
||||
The HTTPS interface uses a device-specific self-signed certificate and a same-origin login page with bounded server-side cookie sessions; HTTP Basic is no longer accepted. Open `/` or `/login`, sign in with a user-database password, and use **Sign out** before switching accounts. Four sessions have a one-hour absolute lifetime, including active serial connections; logout closes only that session's serial access. Login is globally limited to five credential verifications per 60 seconds, with explicit capacity/backoff errors. Direct-IP and mDNS access use separate host-only Secure/HttpOnly/SameSite=Strict cookies. Non-browser clients also require cookies, strict Origin and CSRF for mutations rather than Basic credentials. 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 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`). Third-party components remain subject to their respective licenses. The integration baseline uses Espressif registry components `wolfssl/wolfssl` `5.8.2~1` and `wolfssl/wolfssh` `1.4.20`; review upstream security releases before production use.
|
||||
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.
|
||||
|
||||
### Legacy credential removal
|
||||
|
||||
Missing user storage is persisted as an empty database; no shared credential is imported or synchronized. Existing valid v1 user records retain their accounts, roles, IDs, verifiers and keys without a schema change. HTTPS `web_sec/material` upgrades valid 1,392-byte v1 storage to 1,340-byte TLS-only v2, retaining exact certificate/key DER, fingerprint and generation, and committing before publication. Invalid records or migration failures fail closed rather than triggering fallback replacement. `web certificate rotate --force` remains available; `web reset --force` replaces TLS identity only, not users.
|
||||
|
||||
**Downgrade warning:** older v1-only firmware cannot read v2 HTTPS material. Logical NVS replacement is not a secure flash wipe; historical plaintext credentials can remain in flash. This cleanup requires no factory/partition erase. See [implementation and evidence limits](docs/legacy_credential_removal.md); final integration build evidence is pending.
|
||||
|
||||
+12
-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:
|
||||
@@ -66,9 +76,10 @@ dependencies:
|
||||
direct_dependencies:
|
||||
- espressif/esp_tinyusb
|
||||
- espressif/led_strip
|
||||
- espressif/mdns
|
||||
- idf
|
||||
- wolfssl/wolfssh
|
||||
- wolfssl/wolfssl
|
||||
manifest_hash: 34e93560f67a22e2a43647a4663b450e07ecd9fa3fdc2ac38343c1a76b513f5d
|
||||
manifest_hash: 4b6fa5a7e06122f9194adfc282a4baf4247d8a114b905e65f6397503e59f0898
|
||||
target: esp32s3
|
||||
version: 2.0.0
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# 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)
|
||||
```
|
||||
|
||||
## Typed Network settings
|
||||
|
||||
`web_network_settings` admits bounded current-admin operations to the existing ID-only dispatcher; `wifi_manager` remains radio/event/reannouncement owner and `mdns_service` owns independent hostname persistence. Zero-wait secret-free projections never copy saved PSKs onto HTTPD. Conditional compare/merge preserves omitted secrets, queue admission precedes Wi-Fi publication, and edits require explicit Save. Wi-Fi Load is stored-only; mDNS may load deterministic defaults. SSIDs remain byte-reversible; AP clear is denied. One login-bound slot and one-second timer bound queued secret retention to 30 seconds plus scheduling latency, not cancellation after owner admission. `accepted` is not online/DNS completion. See [Network contracts](../web_administration.md#network).
|
||||
|
||||
## 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 independent user database, committing an empty database when storage is missing. 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. HTTPS additionally requires its own security/runtime readiness; SSH independently requires its own security/runtime readiness, not HTTPS identity readiness. This reflects `main.c` after accepted legacy-credential cleanup.
|
||||
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. SSH starts before command registration, so role-`user` sessions can be admitted in that interval while role-`admin` sessions are rejected until the administration frontend is ready.
|
||||
|
||||
## Serial service and physical ownership
|
||||
|
||||
`serial_service` owns the UART1 driver while running. It exposes bounded RX/TX streams and a task that:
|
||||
|
||||
- continuously drains UART RX, even if UART event notification is incomplete;
|
||||
- moves pending TX to the UART FIFO without blocking indefinitely on CTS;
|
||||
- discards and accounts queued traffic during shutdown/reconfiguration.
|
||||
|
||||
`rs232_port_owner` separately protects the physical UART/MAX3243 resource:
|
||||
|
||||
- `NONE`: available;
|
||||
- `PHASE0`: hardware diagnostics own it;
|
||||
- `SERVICE`: serial service owns it;
|
||||
- `FAULT`: cleanup could not establish a safe state; reboot is required.
|
||||
|
||||
The owner is cooperative rather than an interceptor for UART/GPIO APIs. Active diagnostic commands claim `PHASE0`, and the running service claims `SERVICE`; boot-time static-safe GPIO initialization and service-owned restoration of that static mode are explicit exceptions. Unsafe cleanup keeps the transceiver disabled and marks a fault rather than attempting continued operation.
|
||||
|
||||
Serial configuration is a working RAM value. Applying it while running performs a stop/restart and attempts rollback on failure. Stop/reconfiguration discards and accounts serial-service RX/TX streams and task-local pending TX, but does not disconnect broker clients or clear their writer lease, events, or already-fanned output. An open USB session retries service start after a stop; existing WebSocket and role-`user` SSH sessions do not independently restart it. Persistence is explicit through save/reset commands.
|
||||
|
||||
## Session broker and data flow
|
||||
|
||||
The permanent `session_broker` task is the intended sole consumer/producer of serial-service data.
|
||||
|
||||
### RS-232 to clients
|
||||
|
||||
```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.
|
||||
|
||||
Binary serial WebSocket output uses the IDF-5.5.0-pinned `web_httpd_ws_send_binary` adapter: one owner-only session-override send of a bounded header+payload copy (516 bytes of local scratch, 512-byte payload). Existing generation validation and one outstanding work item per slot remain required. Non-full sends immediately replace that session's send override with a reject-only guard, mark it closing and shut down the socket before deferred cleanup; this prevents automatic SDK control replies from reentering TLS after incomplete output. HTTPD retains TLS destruction ownership. Text/control and admin output retain the SDK sender. One send call does not imply one TLS record, packet or peer receipt; the user signed off drop-free230400-baud full-client-mix operation at160MHz. Runtime stack-margin validation remains a separate follow-up; evidence and acceptance scope are in `current-state.md`.
|
||||
|
||||
Active-client counter snapshots expose ID/type/pending/HWM/UART/queued/read/dropped. HWM is maintained independently of web tracing and counter clear seeds current occupancy. Read means transport handoff, not peer receipt. Disconnect removes the client row while global totals retain traffic and unread-output discards. Independent default-disabled web performance capture uses two fixed slot records and nonwrapping epoch/generation fences; toggles fence in-flight samples, disable freezes aggregates, clear preserves enable state. Binary-only timestamps bound reservation-entry to callback-entry before locking, synchronous send calls and completion to broker-read return; idle and intervening work prevent scheduler-only/backlog-at-completion claims. No new instrumentation allocations or scheduling/buffer changes. Exact fields, overhead and capture contracts: [throughput diagnostics](../web_throughput_diagnostics.md).
|
||||
|
||||
### 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` owns HTTPS on port 443 with a persisted self-signed P-256 identity. `web_serial_transport` mediates two fixed WebSocket slots through the broker; HTTPD owns socket sends/close, the transport task owns broker IO. Four outstanding serial tickets, four cookie sessions, one optional admin WebSocket and six total HTTPD sockets are distinct limits; LRU is disabled. Current handler capacity is 39. Base HTTPS can serve authenticated non-WebSocket routes if optional serial/admin transport initialization fails.
|
||||
|
||||
Cookie login/logout replaces Basic/cache. Digest-only records carry copied principals, CSRF state, absolute expiry and nonreused originating-session IDs. Strict same-origin/CSRF mutations and session/principal checks gate admission; logout invalidates its session before transport cleanup, account mutations invalidate only the affected account, and ongoing currentness is authoritative. Authentication initialization failure gates HTTPS; failed start/accepted stop wipes records. RNG/SHA/database calls run outside short spinlocks with post-call epoch/identity revalidation. [Authentication contract](../web_administration.md#authentication-and-admission).
|
||||
|
||||
`web_httpd_adapter` is the sole private IDF 5.5.0 boundary for duplicate headers, admission-before-101, consumed-scratch wiping, staged optional URI registration, combined binary sends and owner-only idle sweeps. Re-audit its version guard on SDK upgrades. HTTPD debug logging must not expose headers/tickets. `web_diagnostics` independently observes public post-TLS callbacks using six metadata records and a default-disabled 32-event ring; it cannot see preaccept/in-progress/failed TLS. [Admission diagnostics](../web_admission_diagnostics.md).
|
||||
|
||||
`web_httpd_idle` uses one one-second timer, six rows and at most one queued owner probe. Current-owner shutdown follows 15 seconds of observed ordinary idle, exempting actual WebSockets/async/pending input. Owner delays prevent hard timeout guarantees; accepted-but-lost work stays reserved until successful destruction, failed stop retains ownership. [Idle lifecycle contract](../https_idle_cleanup.md).
|
||||
|
||||
`web_ui`/`web_login_ui` own authored documents/scripts and hash-bound CSP loaders; authentication documents/app are no-store. Checked-in generated xterm/logo assets are compiled, not regenerated by ordinary builds. Browser Serial/Admin/Settings navigation changes view/input only, preserving serial client/lease and hidden output draining. Session-identity changes require a clean document; pagehide/restore revalidates before exposing buffers. One shared quick-settings host/controller preserves drafts, stale selections and pending uncertainty. [Terminal and console contract](../web_administration.md#terminal-and-console-ownership).
|
||||
|
||||
### Browser administration and HTTPS lifecycle
|
||||
|
||||
`web_admin_transport`/`web_admin_tickets` add one optional admin socket, two session-bound tickets and admission to the same two remote-console slots shared with SSH, never a broker client. HTTPD owns a 1,552-byte PSRAM-only payload and IO; a 20 ms timer queues at most one poll. Current-owner shutdown avoids queued reusable socket pointers. Detach fences submissions and only successful HTTPD stop retires old work. Console/owner checks enforce currentness before sensitive work; unsupported parsed shell commands reject before effects. [Browser-shell policy](../web_administration.md#browser-shell-policy).
|
||||
|
||||
`web_lifecycle_settings` uses one original-login slot and send-return → nonreused-ID HTTPD callback → existing dispatcher for self-cutting HTTPS/reboot actions. Two-second ACK and 30-second dequeue bounds precede admission, not receipt/completion. Accepted-but-lost callbacks retain one reservation through failed stop; only callback/successful destruction retires it. Conditional stop/restart reserve saturated lifecycle generation; restart retains ownership through stop/start. Conditional reboot invokes canonical `esp_restart()` outside locks, never HTTPD self-stop or console-cleanup waits.
|
||||
|
||||
`web_server_replace_identity` reserves service before identity and retains both through commit → reserved stop/start. Direct security and canonical CLI/browser-shell paths share task-bound nonreused identity reservations. Crypto/NVS run outside short security/service locks; commit precedes publication/wipe. Precommit failure leaves identity/HTTPD/logins unchanged; postcommit lifecycle failure never rolls back identity and can leave served/stored fingerprints different. Failed stop skips start and retains canonical recovery. Public service/security projections are separate observations, not authorization. [HTTPS ownership, generation and recovery contract](../web_administration.md#https-and-reboot).
|
||||
|
||||
### SSH
|
||||
|
||||
Typed SSH settings use the existing ID dispatcher and original-login result slot, never HTTPD wolfSSH calls or owner waits. Conditional lifecycle/session controls compare a saturated service generation and exact nonreused session ID under canonical locks. `ssh_transport_replace_identity` reserves service then identity before stop, retaining the command mutex across stop → commit → conditional restart. Failed stop skips mutation/start; failed persistence may follow disconnection; committed identity is never rolled back after restart failure. Only the SSH owner frees context after all slots retire, and start rejects orphan handles. Direct security/CLI/deferred SSH callers share task-bound identity reservations; crypto/NVS run outside security locks. HTTPS remains available, so no self-cutting HTTP ACK gate is needed. [SSH contracts](../web_administration.md#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. after a database mutation commits, the command layer makes best-effort targeted WebSocket/SSH revocation calls; 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 final administrator cannot be deleted or demoted. UART0 establishes the first administrator through normal `user add <username> admin` and owns explicit unavailable-database recovery to empty. Recovery refuses a healthy database. No bootstrap API or command remains. Authenticated admin SSH can run the operational registry but is denied recovery; 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 and TLS/SSH private keys remain recoverable under physical flash extraction.
|
||||
|
||||
## Typed Accounts and Serial settings
|
||||
|
||||
`web_account_settings` owns one login-bound slot for create/password/role/delete/key operations; HTTPD reads compact zero-wait metadata and queues only IDs. The database compares target username/account-ID/auth-generation inside its mutation lock and shares canonical invariant/commit logic. Successful commands target-revoke, including self; result loss is uncertain. A one-second timer wipes queued non-executing credentials after 30 seconds plus scheduling latency; admitted work wipes locals on return. Generated password delivery is a separate no-retained-retrieval POST, not mutation; key listing is fingerprint-only with stable sparse indices. [Accounts contracts](../web_administration.md#accounts-and-authorized-keys).
|
||||
|
||||
`web_serial_settings` queues bounded typed operations to the same dispatcher, retaining one original-login result and a 30-second dequeue check. Apply/Defaults are RAM-only; Save persists device working state. Reconfiguration can discard serial-service pending bytes while broker clients/lease/output remain. Snapshot reads are zero-wait and consistent; `/api/status` reports unavailable running state as null. Settings navigation preserves both terminals; bounded completion checks and manual uncertainty recovery never replay mutations. [Typed API/lifetime](../web_administration.md#typed-settings-api-and-operation-lifetime).
|
||||
|
||||
## Console architecture
|
||||
|
||||
UART0 and admin SSH share canonical command implementations:
|
||||
|
||||
```text
|
||||
UART0 linenoise frontend --\
|
||||
> fixed request queue -> one dispatcher -> esp_console_run()
|
||||
admin SSH line editor ----/ |
|
||||
+-> registered *_console handlers
|
||||
```
|
||||
|
||||
`admin_ssh_console` creates the dispatcher before network services but marks command dispatch ready only after ESP-IDF console registration and successful UART frontend task creation. An admin SSH connection during that boot window is rejected rather than racing an incomplete registry.
|
||||
|
||||
The dispatcher is the sole caller of `esp_console_run()`, serializing UART0 and all admin SSH commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes.
|
||||
|
||||
The transport-neutral boundary retains `admin_ssh_console_open_owned()` and adds available-slot admission for runtime SSH/browser owners: copied transport-qualified slot/session/generation identity plus a firmware-lifetime immutable owner adapter. The existing two console slots are shared, not multiplied per frontend; active/executing slots cannot be replaced. Owners serialize per-session input, consume output and enforce transport liveness; completion scratch is claimed nonblockingly across owners. The existing control task calls drain/lifecycle adapters outside console locks. SSH uses generation-checked published snapshots, principal copies and its assigned console index, never wolfSSH from the control task. `SELF_CLOSE` is owner-relative; legacy SSH actions remain SSH-specific and unsupported owner actions are rejected. Dispatcher-side owner `is_current` checks run outside console locks, with full identity recheck after validation. Commands revalidate immediately before the runner; prompts revalidate before publication and after waits (250 ms polling plus check/scheduling latency), rejecting revoked submitted input and stale wakes. SSH preserves close intent through external-close consumption. Consumed output is wiped. These checks do not cancel arbitrary executing handlers or replace owner-side input/output and lifecycle validation.
|
||||
|
||||
For SSH, standard output/error is redirected to the invoking session's bounded output ring. `console_input` routes visible or hidden prompts to UART0 or the active SSH session. `exit` and Ctrl+D on an empty admin SSH line use bounded deferred self-disconnect after their acknowledgement drains; role-`user` SSH remains a binary-transparent serial stream. Session tokens include slot and generation so late queued work cannot attach to a reused SSH slot. Only the SSH owner task moves ring output through wolfSSH.
|
||||
|
||||
Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list.
|
||||
|
||||
Browser stop/reboot uses this same owner-adapter control path. Exact forced certificate rotation instead uses the typed queue union and immutable `dispatcher_actions` mask to hand off after drain/200 ms to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending input is discarded through execution and an executing slot remains reserved across self-detach. Canonical shared service/identity replacement preserves commit/stop/start failure semantics. Parsed other-account interactive add/password and forced role/delete are allowed, but browser self/generated/key/recovery and restricted network/SSH shell actions are not; typed Settings has separate permissions. Post-prompt currentness is operation admission, not an atomic session-liveness/NVS guarantee. [Browser policy and recovery](../web_administration.md#browser-shell-policy).
|
||||
|
||||
## Wi-Fi and persistence
|
||||
|
||||
`wifi_config` owns a fixed-width versioned NVS schema with four prioritized station profiles and AP policy `off`, `fallback`, or `always`. Missing configuration generates per-device defaults including a random AP password. Invalid stored data is generally left untouched while RAM defaults are used.
|
||||
|
||||
`wifi_manager` is a permanent task with one bounded command/event queue. ESP-IDF callbacks only copy compact events into the queue. The task owns association, DHCP deadlines, profile failover, AP policy, retries/backoff, next-profile requests, and the mDNS announcement lifecycle. `mdns_service` initializes the responder at most once after a validated STA `GOT_IP`; the managed component's own event handlers withdraw and restore the STA announcement across transient connectivity changes, while the project tracks whether announcement is currently expected. Initialization failure is latched rather than retried because partial upstream low-memory initialization is not safely recoverable; mDNS failure is nonfatal. It also reconciles against authoritative driver/netif state so dropped events do not permanently wedge policy. ESP-IDF Wi-Fi storage is RAM-only; the application blob is authoritative, and edits require explicit save. Edits to disabled station profiles are staged in RAM without restarting the radio; enabling/disabling a profile or changing enabled station/AP policy restarts it asynchronously. Start/stop—including local controls—intentionally update the RAM `enabled_at_boot` field. Working-configuration copies contain PSKs and must be securely wiped; routine status and the local UI use secret-free snapshots.
|
||||
|
||||
Persistent namespaces/blobs include:
|
||||
|
||||
- `serial/config`;
|
||||
- `wifi_app/config`;
|
||||
- `mdns_cfg/config`;
|
||||
- `local_ui/config`;
|
||||
- `web_sec/material`;
|
||||
- `user_db/database`;
|
||||
- `ssh_sec/material`.
|
||||
|
||||
Configuration modules generally choose RAM defaults without erasing incompatible storage. Security-material modules fail closed on malformed existing material and require explicit reset. OTA slots, coredump space, an NVS-key partition, and storage are reserved in `partitions.csv`; OTA, NVS encryption, coredump handling, and filesystem mounting are not implemented.
|
||||
|
||||
## Typed Display and Broker settings
|
||||
|
||||
`web_display_settings` queues IDs; `local_status_ui` owns a nonwrapping configuration generation and zero-wait writer reservation shared with CLI/legacy Apply. NVS runs outside critical sections. Save stabilizes RAM, Load preserves fallback behavior, Reset commits before RAM publication. Buttons/diagnostic holds change independent activity state, not config generation. Settings need an available UI task, not an attached OLED. [Display contract](../web_administration.md#serial-and-display).
|
||||
|
||||
`web_broker_settings` exposes compact zero-wait rows and confirmed writer assignment through one login-isolated slot. The broker atomically snapshots clients/writer/lease version and compares selected target/version inside the force-writer lock before effects. Three-bit slot/29-bit client generations retire rather than wrap; a separate saturated 32-bit lease generation advances before advisory event delivery and survives counter clear. Saturation blocks conditional assignment, not ordinary request/release/disconnect/recovery force. Contextual refresh never silently rebases explicit selections or clears sticky stale/absence latches; deliberate reselection is required. [Broker/context contracts](../web_administration.md#broker-and-contextual-controls).
|
||||
|
||||
## Local UI and hardware boundaries
|
||||
|
||||
`board_pins.h` centralizes project-assigned RS-232, diagnostic, RGB LED, and local-UI hardware resources; UART0 GPIOs remain local to `main.c`, and native USB uses platform wiring. `local_display` solely owns I2C0, the SSD1315-compatible OLED, its static framebuffer, and display mutex. Display frames belong to the initiating task. Dirty-page commits and I2C transactions are bounded.
|
||||
|
||||
When button GPIO initialization succeeds, `local_status_ui` starts a firmware-lifetime low-priority task that polls/debounces buttons, renders copied public snapshots, implements aging/wake behavior, and invokes a constrained set of public service APIs for local controls. It collects snapshots before opening a display frame, so service/broker locks are not held across I2C. It never parses CLI output, becomes a broker client, edits credentials, or assigns a writer; emergency action can only release the expected current writer.
|
||||
|
||||
The task can run with an absent OLED, and a fresh button press can request one bounded panel reprobe after successful I2C bus setup. Failed I2C bus creation is not recoverable through that path. The `display` configuration commands depend on the UI task. Long confirmation holds protect disruptive local actions, and stuck buttons are quarantined.
|
||||
|
||||
Hardware diagnostics are synchronous console commands. RS-232 tests own the physical port exclusively and restore safe GPIO state; OLED tests reuse the display service rather than taking independent I2C ownership.
|
||||
|
||||
## Concurrency and lifecycle constraints
|
||||
|
||||
- Broker, USB, web-transport, Wi-Fi, and SSH owner tasks are firmware-lifetime tasks; the local-UI task is also firmware-lifetime when button initialization allowed it to start. Stopping a service generally stops its runtime/listener, not the owner task.
|
||||
- Bounded queues, stream buffers, work bursts, and drop counters are part of slow-client and watchdog isolation.
|
||||
- Transport slot generations and account authentication generations solve different stale-reference problems; preserve both.
|
||||
- Library/hardware ownership is centralized: serial task owns UART1 while running, display service owns I2C/framebuffer, the SSH owner task owns post-initialization wolfSSH runtime calls, and the console dispatcher owns `esp_console_run()`.
|
||||
- Password authentication performs PBKDF2 outside the user-database mutex and revalidates afterward. Some password mutation paths currently derive verifiers while holding the mutation lock; do not generalize the authentication locking pattern without checking the exact path.
|
||||
- Avoid holding service/database/broker locks across I2C, network sends, or other potentially long operations unless the existing contract explicitly requires it. Preserve the existing broker-before-serial lock order.
|
||||
- Serial RX/TX stream payloads, broker per-client payloads, the transactional user-database candidate, and selected cryptographic allocations prefer PSRAM with internal fallback. The live user database, FreeRTOS control structures, UART driver buffers, and task stacks remain internal where deterministic/cache-disable access matters.
|
||||
- The build disables wolfSSL ESP32 AES/SHA acceleration, and the HTTPS path uses software AES for PSRAM-backed records. This preserves the validated workaround for uncoordinated mbedTLS/wolfSSL hardware-crypto locks and a prior mbedTLS external-RAM DMA watchdog stall.
|
||||
|
||||
## Legacy credential removal storage boundary
|
||||
|
||||
`user_database_init(load_result)` has no credential input. Missing storage is persisted empty; `user_database_recover_empty()` is the unavailable-only destructive recovery API. Valid v1 user bytes load without rewriting or account changes. The private `v1_admin_marker` retains its byte position and is derived from administrator count during mutations; it is not a public bootstrap state, new role or schema change. No user migration/bootstrap/synchronization API remains.
|
||||
|
||||
`web_security` owns TLS only. A private reader validates 1,392-byte v1 `web_sec/material`, copies exact key/certificate DER, fingerprint and generation into 1,340-byte v2, commits, then publishes. Temporary v1 credential-bearing input is wiped; no public legacy credential type/getter/rotation remains. Malformed/unknown records and read/validation/commit failures fail closed, with no fallback regeneration or overwrite of rejected records. Missing material may be generated; explicit reset replaces TLS only. Downgrade to v1-only firmware is incompatible. Logical NVS replacement is not secure flash erasure. Contracts/evidence: [legacy compatibility](../legacy_credential_removal.md).
|
||||
@@ -0,0 +1,194 @@
|
||||
# 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; `session_broker_get_management_snapshot()` and `session_broker_assign_writer_current()` atomically project/compare target and lease generation. Exhausted 29-bit client generations retire; saturated lease generation preserves ordinary recovery. Tests: `tests/session_broker_diagnostics/run.py`.
|
||||
- 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.
|
||||
- Diagnostics: `broker counters` adds active-client ID/type/pending/HWM/UART/queued/read/dropped rows; clear seeds HWM from pending, disconnect removes rows but retains global discard accounting. `tests/session_broker_diagnostics/run.py`; capture before disconnect, never use consuming `broker read` as a probe. Semantics/recipe: `docs/web_throughput_diagnostics.md`.
|
||||
|
||||
## 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_serial_transport.{h,c}`, `src/web_ui.{h,c}`, `src/web_console.{h,c}`
|
||||
- Ordinary HTTPS idle cleanup: `src/web_httpd_idle.{c,h}`, owner sweep in `web_httpd_adapter.{c,h}`, lifecycle/TLS composition in `web_server.c`; `tests/web_httpd_idle/run.py`. Independent of diagnostics/optional transports: 15-second observed idle, one-second timer/one queued probe, six rows, actual WS/async/pending exemptions, safe current-owner shutdown and stop/restart fencing. No LRU/socket/timeout/stack increase. SDK queue/owner-delay and regression contract: `docs/https_idle_cleanup.md`.
|
||||
- Independent throughput diagnostics: `web_serial_transport.{c,h}` owns two fixed per-slot binary-TX aggregates and epoch fences; `web_console.c` exposes default-disabled `web performance enable|disable|show|clear`. Queue-entry/callback-entry, synchronous-send and completion/drain-return estimates, not peer receipt or scheduler-only latency. `tests/web_serial_performance/run.py`; resource/evidence limits and UART0 paired capture: `docs/web_throughput_diagnostics.md`.
|
||||
- Storage compatibility: `user_database` persists missing storage empty and preserves valid v1 user bytes; private derived `v1_admin_marker`, no public bootstrap/migration/sync APIs. `web_security` privately migrates v1 1392-byte material to TLS-only v2 1340-byte material, exact identity/generation retained, commit before publish, fail closed without fallback overwrite. Credential commands removed; user generated passwords and TLS rotation remain. Contracts, downgrade and evidence limits: `docs/legacy_credential_removal.md`.
|
||||
- Security files: `src/web_security.{h,c}`, `src/web_cookie_auth.{h,c}`, `src/web_session_store.{h,c}`, `src/web_auth_parse.{h,c}`. Private IDF boundary: `src/web_httpd_adapter.{h,c}`.
|
||||
- HTTP policy/UI: `web_cookie_auth` + `web_auth_parse` enforce bounded cookie/Origin/CSRF/admin admission; `web_login_ui.{c,h}` serves login, `web_ui.c` owns session-fenced Serial/Admin/Settings and shared quick controllers. Tests: `tests/web_cookie_auth/run.py` (domain variants), `tests/web_auth_parse/run.py`, `tests/web_login_ui/run.py`, `tests/web_ui_session/run.py`.
|
||||
- Admission diagnostics: `web_diagnostics.{c,h}`, `tests/web_diagnostics/run.py`; six post-TLS records/32-event opt-in ring, no HTTPD off-owner inspection. [Contract](../web_admission_diagnostics.md).
|
||||
- Identity/lifecycle: `web_server_replace_identity()` + `web_security` reserve service before identity; commit before reserved stop/start, no rollback after commit. `web_lifecycle_settings.{c,h}` owns original-login ID/ACK handoff. Tests: `tests/web_security/run.py`, `tests/web_admin_transport/server_lifecycle.py`.
|
||||
- Asset files: authored/generated boundary in `src/web_assets_data.{h,c}`, `web_assets/SOURCES.md`, `web_assets/generate_embedded_assets.py`
|
||||
- Interfaces: web init/start/stop/snapshots; HTTP handlers; ticket mint/consume; attach/detach; targeted session revocation
|
||||
- Called by: startup, ESP-IDF HTTPS server, user administration revocation, console/local UI
|
||||
- Dependencies: user database, secure random, broker, 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/cookie session -> CSRF-protected ticket -> cookie/Origin/ticket admission -> WebSocket -> web transport -> broker`
|
||||
- Ownership: HTTPD owns socket send/close work; transport task owns broker mediation; two fixed WebSocket slots and four outstanding tickets.
|
||||
- Security constraints: Basic/cache removed; four absolute one-hour cookie sessions revalidate principal currentness. Four pre-login challenges (120 s), five credential attempts/60 s globally, no live session/challenge/ticket eviction. Origin/CSRF required for mutations; Origin/cookie/ticket before upgrade. Disconnect pauses reconnect but retains login; Sign out invalidates its session. Authored loader changes must update their hard-coded CSP hashes atomically.
|
||||
- Session-store boundary: admitted HTTPS start initializes records; auth-init failure gates HTTPS. Failed start/accepted stop disables and wipes state. Tickets/slots require nonzero non-reused session IDs; session/account/global revocation invalidates store records before socket cleanup. RNG/SHA/database calls run outside short portMUX sections; ID/expiry/epoch checks reject stale work. Run `python3 tests/web_session_store/run.py` and its `--serial` integration mode.
|
||||
- Asset constraint: `web_assets_data.c` is checked-in generated input to the build; do not hand-edit or regenerate casually.
|
||||
|
||||
### Browser admin backend
|
||||
|
||||
- Files: `src/web_admin_transport.{c,h}`, `src/web_admin_tickets.{c,h}`, protected registration/lifecycle in `web_server.c`, revocation through `web_serial_transport_revoke_*`, diagnostics in `web_console.c`.
|
||||
- Routes: CSRF-protected admin-only `POST /api/admin/ws-ticket`; ordinary `GET /ws/admin` with cookie/Origin/ticket/shared-console admission before explicit 101. Admin UI entry is explicit; no admin broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled; current overall capacity is 39 URI handlers.
|
||||
- Currentness/policy: `admin_ssh_console_open_available()` shares two slots with runtime SSH; transport-qualified tokens and owner adapters revalidate outside console locks before commands/prompts. Parsed browser policy remains narrower than typed Settings; [shell contract](../web_administration.md#browser-shell-policy). Tests: `tests/admin_console_boundary/{run,accounts,lifecycle}.py`, `tests/admin_ssh_policy/run.py`, `tests/web_admin_transport/run.py --tickets`, `tests/web_cookie_auth/run.py --admin`.
|
||||
- Ownership: 20 ms ESP timer queues at most one HTTPD poll, no new task; HTTPD owns 1,552 B PSRAM-only payload and IO. Closure uses HTTPD-owned `shutdown`, not IDF's reusable-pointer queued close. Detach fences submitters; only successful HTTPD stop retires queued state before restart. Session/principal currentness and generation checks protect all sensitive boundaries.
|
||||
|
||||
## Typed settings source and regression map
|
||||
|
||||
HTTPD reads zero-wait projections and queues only IDs to the existing dispatcher. One original-login slot per domain; canonical owners compare/reserve at execution. [API/lifetime and failure contracts](../web_administration.md#typed-settings-api-and-operation-lifetime).
|
||||
|
||||
| Domain | API / canonical owner | Focused source tests |
|
||||
|---|---|---|
|
||||
| Serial | `web_serial_settings.{c,h}` / `serial_service` | `tests/web_cookie_auth/run.py --serial-settings`, `tests/admin_console_boundary/run.py` |
|
||||
| Accounts / keys | `web_account_settings.{c,h}` / `user_database` | `tests/web_cookie_auth/run.py --accounts`, `tests/admin_console_boundary/accounts.py` |
|
||||
| Network | `web_network_settings.{c,h}` / `wifi_manager`, `mdns_service` | `tests/web_cookie_auth/run.py --network`, `tests/web_network_settings/run.py` |
|
||||
| Display | `web_display_settings.{c,h}` / `local_status_ui` | `tests/web_cookie_auth/run.py --display` |
|
||||
| Broker | `web_broker_settings.{c,h}` / `session_broker` | `tests/web_cookie_auth/run.py --broker`, `tests/session_broker_diagnostics/run.py` |
|
||||
| SSH | `web_ssh_settings.{c,h}` / `ssh_transport`, `ssh_security` | `tests/web_cookie_auth/run.py --ssh`, `tests/ssh_management/run.py`, `tests/ssh_management/security.py` |
|
||||
| HTTPS / reboot | `web_lifecycle_settings.{c,h}` / `web_server`, `web_security` | `tests/web_cookie_auth/run.py --lifecycle`, `tests/web_admin_transport/server_lifecycle.py` |
|
||||
|
||||
Shared UI regression: `tests/web_ui_session/run.py` and its domain `.cjs` fixtures / `layout.py`. These are navigation pointers, not claims of test execution.
|
||||
|
||||
## 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; `ssh_transport_get_management_snapshot()` / `ssh_transport_manage_current()` fence lifecycle and exact session admission. `ssh_transport_replace_identity()` reserves service before task-bound security identity across stop/commit/restart, retains context until all slots retire and rejects orphan starts. Tests: `tests/ssh_management/run.py`, `tests/ssh_management/security.py`, `tests/ssh_management/runtime.py`.
|
||||
- Called by: startup, network clients, user revocation, console/local UI
|
||||
- Dependencies: user database, broker, admin SSH console, secure random, wolfSSH/wolfSSL; boot start gate requires Wi-Fi and SSH security/runtime readiness, independently of HTTPS identity readiness (verified in `main.c` after accepted legacy cleanup).
|
||||
- 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_console.{h,c}`; `src/admin_command_gate.{h,c}` is currently a narrow recursive wrapper used only by the `user` command handler, not the global command serializer
|
||||
- Interfaces: credential-independent init/empty recovery, authenticate, principal-currentness, account/password/role/key mutations, snapshots
|
||||
- Called by: web and SSH authentication/currentness checks and console administration
|
||||
- Dependencies: NVS, secure random, mbedTLS cryptography; after a committed command-layer mutation, best-effort web/SSH revocation calls supplement authoritative transport currentness checks
|
||||
- Ownership: database mutex protects the internal live record and PSRAM-preferred transactional candidate; password authentication runs PBKDF2 outside the mutex and revalidates afterward, while mutation locking must be checked per operation.
|
||||
- Authorization: UART0 establishes the first administrator through normal `user add` and exclusively owns unavailable-database recovery to empty (healthy database refused); current admins may use admin SSH for other commands unless handler policy denies them. HTTPS serial/status permits both roles; administration requires `admin`.
|
||||
- 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 browser admin.
|
||||
|
||||
- Files: `src/admin_ssh_console.{h,c}`, `src/console_input.{h,c}`, `src/console_completion.{h,c}`, `src/system_console.{h,c}`, `src/network_console.{h,c}` and all `*_console.{h,c}` modules
|
||||
- Entry points: `admin_ssh_console_init()`, `admin_ssh_console_start_uart_frontend()`, command registration functions
|
||||
- Called by: startup, UART0 frontend, role-`admin` SSH transport, 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 slot generation; fixed output/history/prompt state is wiped immediately on idle close or after an executing handler returns. Admin SSH `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. Admin SSH 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
|
||||
- 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. Working-config copies contain PSKs and must be tightly scoped and wiped; 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}`
|
||||
- Interfaces: display init/frame/draw/commit/snapshot; UI start/activity/config; generation-checked settings projection/update and explicit persistence reservation; versioned NVS settings
|
||||
- Called by: startup, local UI task, diagnostics, display console
|
||||
- Dependencies: copied snapshots/public APIs from serial, broker, USB, Wi-Fi, web, SSH
|
||||
- Ownership: `local_display` solely owns I2C0 and framebuffer mutex; a frame belongs to its initiating task.
|
||||
- Lifecycle: 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. All configuration writers honor the UI owner's zero-wait reservation; NVS runs outside timing critical sections. Reset commits defaults before RAM publication, including CLI; buttons/diagnostic holds update activity, not configuration generation.
|
||||
|
||||
## 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_database.*`, `user_console.c`, transport revocation APIs |
|
||||
| Change Wi-Fi policy or profile persistence | `wifi_manager.*`, `wifi_config.*`, `wifi_console.c` |
|
||||
| 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,30 @@
|
||||
# Current project state
|
||||
|
||||
Working memory, not an implementation timeline. Source is authoritative; begin with [code map](code-map.md), then [architecture](architecture.md) and [decisions](design-decisions.md).
|
||||
|
||||
## Accepted state — 2026-09-13
|
||||
|
||||
- **8D.22 explicitly signed off by the user:** “Yep, I tested the firmware thats a 8d.22 signoff.” The retained Phase 8D scope is complete; earlier per-slice pending target/review/integration gates are superseded. Roadmap already records 8A/B/C as complete and target-hardware validated, so **Phase 8 is complete**. Acceptance does not manufacture individual unreported test passes.
|
||||
- [Roadmap](../roadmap.md#phase-8--role-based-users-and-administrative-access--complete) holds the completed-phase gist; [web administration](../web_administration.md) holds current API/owner/recovery contracts; [acceptance evidence](../web_administration_acceptance.md) holds the latest report and limits. The old plan/baseline/per-slice histories are consolidated, not archived as another timeline. Test READMEs now link directly to current contracts and evidence; obsolete forwarding notes were removed too.
|
||||
- Final prior production build **PASS: 100,556 B linked RAM / 1,828,573 B flash, CPU 160 MHz**. No build/test/device execution is implied by this documentation update. The prior combined binary WebSocket-send fix was separately user-accepted at **160 MHz / 230400 baud with full mix including browser admin**; preserve combined send and bounded failed-send isolation.
|
||||
- Latest loaded capture: two serial WS, USB, two SSH roles with SSH serial writer; browser admin used then closed, not active in the capture. Internal/DMA/PSRAM free **31,508 / 23,752 / 8,136,624 B**, minima **2,052 / 460 / 8,065,972 B**, largest **18,432 / 18,432 / 7,995,392 B**; SSH minimum-free stack **15,028 B**. Full boot/loaded table is in acceptance evidence. Web send/queue/protocol and SSH IO errors zero; one SSH handshake failure/session revocation. Missing latest broker/serial counters prohibit an exact zero-drop inference.
|
||||
|
||||
## Follow-ups, not acceptance blockers
|
||||
|
||||
- Extremely low internal/DMA lifetime minima warrant correlated transient-headroom investigation; overlapping capability pools and conservative/non-simultaneous region minima do not prove allocation failure. Numeric reserves, HTTPD/dispatcher stack margins, peak correlation and detailed soak/fault evidence remain unapproved/unreported. Do not reopen functional sign-off or invent a reserve threshold.
|
||||
- SDK TLS `-0x004C` is generic NET_RECV_FAILED, not OOM. Two boot auth failures plausibly involve stale cookies, but causation is unconfirmed. Prior intermittent web admission issues and accepted idle cleanup do not justify claiming every admission failure fixed.
|
||||
- Real DNS/reannouncement, NVS power-loss, browser geometry/accessibility, individual fault cases and exact duration claims require explicit evidence if investigated; retained [regression procedures](../user_administration_tests.md) are not execution records.
|
||||
- **Next: Phase 9 security/production hardening only if requested.** No source, configuration, test implementation, build, upload, erase, branch or commit is authorized by documentation consolidation.
|
||||
|
||||
## Scope and safety to retain
|
||||
|
||||
- 8D.15 dedicated typed network diagnostics was removed; shell diagnostics remain subject to frontend policy. Unimplemented 8D.19 ordinary browser-session/native-USB controls were removed; SSH settings remain. No implicit full browser-shell parity or browser identity reset/recovery/export.
|
||||
- One UART1 broker writer, isolated observers and binary transparency. UART0 is administrative recovery; native USB is network-independent UART1, not administration or uninterrupted reboot.
|
||||
- Typed operations carry original-login IDs to the existing dispatcher. Owner-reserved generations fence stale/ABA changes; later revocation/timeout does not cancel admitted work. HTTPS commits before stop/restart; SSH stops before commit/restart; committed identity never rolls back on lifecycle failure. Lost ACK/result means uncertainty, never automatic replay.
|
||||
- Preserve private IDF HTTPD version guards, at-most-one owner-work reservations through failed destruction, retained SSH context until all slots retire, bounded queues/buffers and secret-free metadata. Canonical recovery survives conditional-token exhaustion.
|
||||
|
||||
## Documentation handoff
|
||||
|
||||
Initial Git status was clean. This task changes root `README.md`, `docs/` and five test-directory READMEs; executable source/tests/config/generated assets remain untouched. Independent documentation review checked acceptance scope, owner contracts and local links. It restored explicit pointer-backed HTTPD response-header lifetime and same-version SDK-patch audit warnings, updated test README links, and removed obsolete forwarding notes without reopening sign-off.
|
||||
|
||||
Validation completed: independent Python local-link/anchor audit PASS across 29 authored Markdown files (122 local links, 53 Markdown fragments); 213 authored source/test/document files checked with zero obsolete Phase 8D filename references. Earlier path audit resolved127 expanded navigation references. GPT logs, vendored/generated trees and remote URL fetching excluded. Twenty obsolete phase documents removed, with no forwarding stubs/archive dump. Git diff/scope checks confirm documentation-only changes. No firmware tests/build/device commands run.
|
||||
@@ -0,0 +1,193 @@
|
||||
# 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.
|
||||
|
||||
## Display configuration has an owner reservation separate from button activity
|
||||
|
||||
**Decision:** `local_status_ui` owns a nonwrapping configuration generation and a zero-wait reservation shared by typed Display and CLI/legacy Apply. NVS runs outside timing critical sections; Save stabilizes selected bytes and Reset commits defaults before RAM publication. Load retains canonical fallback. Buttons/diagnostic holds update activity, not configuration generation.
|
||||
|
||||
**Consequence:** Compare before mutation, never overwrite intervening CLI edits, and do not make physical panel presence a configuration prerequisite. A successful config API is not proof of display IO. [Owner and persistence contract](../web_administration.md#serial-and-display).
|
||||
|
||||
## Typed Network edits preserve manager ownership and current secret bytes
|
||||
|
||||
**Decision:** HTTPD uses zero-wait secret-free projections and an ID-only dispatcher. Wi-Fi compare/merge/whole-candidate validation and queue-before-publication preserve omitted secrets and reject stale changes; its manager alone owns radio/reannouncement. mDNS generation/persistence is independent. Save stabilizes selected RAM; Wi-Fi Load is stored-only, not fallback-secret generation.
|
||||
|
||||
**Consequence:** Keep SSIDs reversible bytes, omitted/Replace/disabled-STA Clear distinct, AP clear denied, and Next profile separate from editor selection. mDNS may report RAM applied but reannouncement not queued, without rollback. A one-second queued-secret timer is not hard cancellation; `accepted` is not online/DNS. Recovery and no automatic replay are correctness requirements. [Network contract](../web_administration.md#network).
|
||||
|
||||
## 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.
|
||||
|
||||
**Throughput observation and controlled experiments:** The initial diagnostic baseline used CPU160MHz; a CPU240MHz-only experiment reduced but did not eliminate browser queue overflow. Combining binary WebSocket header/payload into one bounded session-override send eliminated reported drops, and the user signed off230400-baud full-client-mix operation after returning to160MHz. Retain the combined send, not the frequency increase; evidence and limits are in `current-state.md`. Preserve scheduling/priorities and 4096/512-byte broker/web buffers while gathering per-client HWM/drop attribution and independent opt-in web binary-TX timing. Fixed-slot epoch/generation-fenced aggregates avoid stale attribution; no new runtime allocations. Clear preserves queued data and seeds broker HWM; disconnected rows disappear while global discard counts remain. Callback timestamps precede the transport lock; synchronous send return is not peer receipt. Completion-to-read intervals include broker/mutex/control work and possible idle, even when the first read is nonempty; never label them pure scheduling latency or proof of backlog at completion. Compare enabled/disabled target captures before drawing overhead conclusions. Contracts and reproduction: [throughput diagnostics](../web_throughput_diagnostics.md).
|
||||
|
||||
**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/SSH/WebSocket slots, originating web sessions, queued admin operations and account principals carry distinct generation/identity fences. Browser cookie-session identity is not interchangeable with account identity. Invalidate session records before socket cleanup and retain authoritative currentness checks even when notifications fail.
|
||||
|
||||
**Consequence:** Never turn selected transport IDs into arbitrary broker IDs/fds, rebase a stale confirmation or wrap a published token. Exhausted broker/SSH slots retire; operation/reservation IDs do not reuse; saturated service/lease generations fence ABA without disabling canonical recovery. Reboot invalidates old logins. Validate/reserve at the owner immediately before effects, not snapshot-check/unlock/unconditional mutation. [Broker](../web_administration.md#broker-and-contextual-controls) and [service](../web_administration.md#service-lifecycle-and-identity-rotation) contracts.
|
||||
|
||||
## Confirmed writer transfer compares a lease version inside the broker lock
|
||||
|
||||
**Decision:** Compare the connected nonreused target and separate lease generation inside the force-writer lock. A client ID alone cannot fence release/reacquire ABA. Lease generation saturates, survives counter clear and advances before advisory event delivery, potentially twice for force transfer.
|
||||
|
||||
**Consequence:** Saturation rejects typed assignment but preserves ordinary request/release/disconnect and recovery force. Accepted serial TX is not recalled. Live contextual refresh must retain the selected versions and sticky stale/absence state until explicit reselection. [Wrap and UI contract](../web_administration.md#broker-and-contextual-controls).
|
||||
|
||||
## UART0 is the physical recovery authority
|
||||
|
||||
**Decision:** UART0 remains independent of UART1 and networking. The first administrator is created with normal `user add` on UART0; explicit unavailable-user-database recovery to empty is UART0-only and refuses healthy storage. No bootstrap command/API remains.
|
||||
|
||||
**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 unauthenticated first-admin provisioning or 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:** SSH self-close/reboot/lifecycle/host-key actions use existing bounded application-drain control; browser shell stop/reboot and typed certificate handoff use owner adapters, not command replay. Drain is not peer receipt. Crypto/NVS browser rotation runs on the existing dispatcher, not the small control stack; executing slots remain reserved across self-detach.
|
||||
|
||||
**HTTPS typed ACK:** Successful synchronous send return precedes one nonreused-ID HTTPD callback to the existing dispatcher. No captured request/fd/reusable operation pointer or lifecycle wait on HTTPD. Lost accepted work retains one reservation until callback or successful destruction; never release speculatively on timeout. Original-login/current-admin/dequeue deadline checks precede admission. Reserved restart may deliberately invalidate that login; subsequent revocation is not cancellation. Reboot invokes canonical `esp_restart()` outside locks without self-console cleanup.
|
||||
|
||||
**Identity replacement:** Compare/reserve service then task-bound nonreused identity token, shared by canonical/direct callers. Crypto/NVS run outside security locks. HTTPS commits before stop/start; SSH stops before commit/restart. Failed SSH stop skips mutation/start; persistence failure can follow client disconnection. Committed material is never rolled back on restart failure. SSH owner retains context until all slots retire; start rejects orphan handles. Public fingerprint projections do not authorize mutation, and stored/served HTTPS identity can differ after failed stop.
|
||||
|
||||
**Consequence:** Explicitly communicate partial effects, trusted UART0 fingerprint verification and fresh HTTPS login after restart. Preserve CLI recovery/reset but do not add browser Reset/export as if it were ordinary rotation. Native USB is independent UART1, not administration or uninterrupted reboot. [Lifecycle/identity contract](../web_administration.md#service-lifecycle-and-identity-rotation).
|
||||
|
||||
## Authentication uses copied principals and fail-safe currentness checks
|
||||
|
||||
**Decision:** Network sessions retain secret-free copied principals. Database/account ID/auth generation and originating web-session identity must be current at admission, before sensitive input and during reconciliation. Best-effort target notifications supplement, never replace, these checks and cannot roll back committed mutations.
|
||||
|
||||
**Consequence:** Shared remote-console slots require transport-qualified tokens and immutable owner adapters. Validate owner currentness outside console locks, then recheck identity. Owner-side HTTPD/SSH IO and generation-safe cleanup remain mandatory; session liveness checks do not cancel executing handlers. Browser-shell permissions are parsed and narrower than typed Settings. [Authentication](../web_administration.md#authentication-and-admission), [console policy](../web_administration.md#browser-shell-policy).
|
||||
|
||||
## Typed serial mutations share the administration dispatcher
|
||||
|
||||
**Decision:** Typed domains queue IDs to the existing serialized dispatcher, never CLI strings or secrets. One original-login slot per domain and a nonreused ID fence stale work; session/deadline checks precede canonical admission. Results are replaceable observations, not durable history/idempotency.
|
||||
|
||||
**Consequence:** No automatic mutation replay, including after navigation, timeout or logout. Ordinary deadlines limit dequeue admission, not execution. Accounts/Network queued-secret timers wipe only non-executing inputs; locals wipe after admitted work returns. Explicit RAM/NVS/reset semantics and partial-effect uncertainty must match each canonical owner. [API bounds and lifetime](../web_administration.md#typed-settings-api-and-operation-lifetime).
|
||||
|
||||
## Typed account selection is checked inside the database mutation lock
|
||||
|
||||
**Decision:** Target username/account ID/auth generation compare occurs inside the canonical mutation lock for role/delete/password/key changes. HTTPD uses compact zero-wait secret-free projections, not the blocking CLI snapshot. Successful changes notify only the target's web/SSH sessions, including self.
|
||||
|
||||
**Consequence:** Separate generated-value delivery from mutation and retain no retrieval history; context-bound saved acknowledgement is UX, not receipt proof. Self-revocation can deny results, so 401/disconnect cannot mean success or cancellation. Key slots are stable and sparse, fingerprint-only on output; import shares canonical validation. Final-admin invariants and UART0 provisioning/recovery remain. [Accounts contract](../web_administration.md#accounts-and-authorized-keys).
|
||||
|
||||
## Browser authentication has a narrow version-pinned HTTPD boundary
|
||||
|
||||
**Decision:** `web_httpd_adapter` alone accesses private IDF 5.5.0 parsed-header/session state. Reject duplicate/ambiguous headers; defer 101 until cookie/Origin/ticket/transport admission; wipe consumed scratch while preserving unread bytes. Stage optional Settings descriptor/name allocations before publication. Compile header/ticket debug logging out.
|
||||
|
||||
**Consequence:** Re-audit SDK assumptions on upgrade; never patch around Origin `null` by weakening same-origin policy. Browser authentication POST uses CORS mode with fixed same-origin URLs/credentials because no-referrer non-CORS POST can serialize Origin as null. Digest-only cookie/challenge sessions replace Basic without fallback or live-record eviction. CSP loader hashes and authored scripts change atomically. Navigation preserves terminals/lease, while session-identity changes require a clean document before showing retained buffers. [Authentication and terminal contracts](../web_administration.md#authentication-and-admission).
|
||||
|
||||
## 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. Legacy credential synchronization and reconciliation are removed. Missing user storage commits empty; valid user v1 bytes remain compatible, with private `v1_admin_marker` derived from admin count, not a public bootstrap contract. HTTPS v1 (1,392 bytes) migrates through a private validated reader to TLS-only v2 (1,340 bytes), preserving exact DER/fingerprint/generation and committing before publication. Failures fail closed without fallback regeneration or overwriting rejected records. See [legacy compatibility](../legacy_credential_removal.md).
|
||||
|
||||
**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 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. Logical NVS replacement can leave old plaintext credentials in flash and is not secure erasure; no factory erase is required by this cleanup. Older v1-only firmware cannot read v2 HTTPS material. 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`
|
||||
+91
-15
@@ -1,6 +1,10 @@
|
||||
# Command reference
|
||||
|
||||
Use these commands from the UART0 `serial-tool>` administration console. Run `help` for the available root commands and `<group> help` for a group summary. Configuration changes are RAM-only unless explicitly saved.
|
||||
UART0 and authenticated `admin` SSH sessions use the same registered command implementations through one serialized dispatcher. Admin SSH exposes the full operational registry, including interactive prompts, network diagnostics, reboot, and HTTPS/SSH material mutation. Create the first administrator through normal `user add <username> admin` on physical UART0; explicit recovery of an unavailable database is UART0-only. Admin SSH also rejects generating 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.
|
||||
|
||||
Browser admin uses the same dispatcher with a [narrower parsed frontend policy](web_administration.md#browser-shell-policy), independent of typed Settings permissions. It supports bounded deferred `reboot`, `web stop`, exact `web certificate rotate --force` and owner-relative `exit`. Drain (up to ten seconds plus 200 ms) is best-effort application-buffer acknowledgement, not peer receipt or an execution deadline; pending input is discarded. Certificate work runs on the existing dispatcher through the shared service-before-identity reservation, commits before stop/restart and never rolls back a committed identity after lifecycle failure. Failed stop retains ownership and skips start. Verify changed trust through UART0 `web certificate info`, recover with UART0/admin SSH `web stop` / `web start`, then sign in freshly. HTTPS-only actions leave SSH/native USB/UART0 independent; reboot affects every transport and loses unsaved RAM.
|
||||
|
||||
Browser `web` allows only status/stop/exact forced certificate rotation; `wifi`/`mdns` allow status only. Browser `user` allows status/list/show and interactive add/password plus forced role/delete for **other accounts**, not self/generated/key/recovery commands. Restricted SSH stop/disconnect/reset/host-key mutation remains unavailable in the browser shell. Typed Accounts/Network/SSH settings separately provide their documented bounded workflows; this is not shell parity. See [web administration](web_administration.md) for lifecycle/API ownership and uncertainty.
|
||||
|
||||
## System
|
||||
|
||||
@@ -8,7 +12,45 @@ Use these commands from the UART0 `serial-tool>` administration console. Run `he
|
||||
|---|---|
|
||||
| `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. |
|
||||
| `status` | Show quick MAX3243 signal state. |
|
||||
| `exit` | Close the current administrative SSH or browser session after its acknowledgement drains; unavailable on UART0. Browser `exit` leaves serial connected. Ctrl+D on an empty administrative command line does the same. |
|
||||
|
||||
## Role-based users
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `user status` / `user list` | Show database generation, capacity, administrator count, and all secret-free account summaries. |
|
||||
| `user show <username>` | Show one account's role, ID, authentication generation, and SSH-key fingerprints. |
|
||||
| `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 final administrator is 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 only its blob with an empty database; UART0-only, refuses a healthy database. |
|
||||
|
||||
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.
|
||||
|
||||
Missing `user_db/database` storage is committed empty. On UART0 run `user add <username> admin`, optionally with `--generate`, to create the first administrator. There is no bootstrap command, imported shared credential, or synchronization with HTTPS material. Existing valid v1 user databases load unchanged, including previously migrated role-`user` accounts; no account is silently promoted.
|
||||
|
||||
`user recover --force` is destructive and succeeds only while the database is unavailable. It rebuilds only the user blob empty, importing no credentials; then create an administrator with normal `user add` on UART0. It refuses a healthy initialized database, including a healthy empty one, and does not erase unrelated NVS data. Successful password, role, key and delete operations invalidate only that username's outstanding tickets and active web/SSH sessions; unrelated users remain connected.
|
||||
|
||||
## 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 save defaults first and then apply them. |
|
||||
|
||||
When both transitions are enabled, `off-seconds` must be greater than `dim-seconds`. Applying settings counts as local UI activity. 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.
|
||||
|
||||
Display settings require an available local UI task, not an attached panel. CLI and browser **Settings → Display** share the public configuration owner; concurrent mutations can report busy, and browser operations reject an intervening configuration edit rather than overwrite it. Save persists working RAM, not browser drafts. Load selects defaults when saved storage is absent/incompatible without rewriting NVS. Reset storage failure leaves RAM unchanged (commit-before-publication, no RAM rollback). Browser timeout/navigation does not cancel already-admitted work; Check Result and Refresh before retrying. [Display settings contract](web_administration.md#serial-and-display).
|
||||
|
||||
## Serial service
|
||||
|
||||
@@ -28,7 +70,7 @@ Defaults are 115200 baud, 8 data bits, no parity, one stop bit, no flow control,
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `broker status` / `broker clients` | Show broker state or connected clients. |
|
||||
| `broker counters` / `broker clear-counters` | Show or clear broker counters. |
|
||||
| `broker counters` / `broker clear-counters` | Show global totals plus active-client ID/type/pending/HWM/UART/queued/read/dropped; clear counters and seed HWM from current pending bytes without draining output. |
|
||||
| `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. |
|
||||
@@ -36,7 +78,7 @@ Defaults are 115200 baud, 8 data bits, no parity, one stop bit, no flow control,
|
||||
| `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.
|
||||
Each client has a generation-safe ID. There can be one writer and multiple observers; a slow observer loses only its own queued output. HWM is bounded by 4,096 bytes; read counts transport handoff, not peer receipt. Disconnected client rows disappear, while global totals retain traffic and unread-output discards until cleared. Capture counters before disconnect. `broker read` consumes queued data and must not be used as a throughput diagnostic probe. See [Web throughput diagnostics](web_throughput_diagnostics.md).
|
||||
|
||||
## Native USB CDC-ACM
|
||||
|
||||
@@ -47,7 +89,7 @@ Each client has a generation-safe ID. There can be one writer and multiple obser
|
||||
| `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. Supported host line coding is 110–1000000 baud, 7/8 data bits, none/odd/even parity, and 1/2 stop bits; settings apply only while USB owns the writer lease and are not saved automatically.
|
||||
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
|
||||
|
||||
@@ -55,6 +97,7 @@ Opening `/dev/ttyACM*` with DTR asserted creates the `usb-cdc` broker client, st
|
||||
|---|---|
|
||||
| `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. |
|
||||
@@ -67,7 +110,18 @@ Opening `/dev/ttyACM*` with DTR asserted creates the `usb-cdc` broker client, st
|
||||
| `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. Passwords are not displayed by ordinary status output.
|
||||
`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. Passwords are not displayed by ordinary status output.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -76,14 +130,16 @@ Opening `/dev/ttyACM*` with DTR asserted creates the `usb-cdc` broker client, st
|
||||
| `web` / `web help` | Show web-service command usage. |
|
||||
| `web status` | Show HTTPS and 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 physical-console-only shared admin credentials. |
|
||||
| `web credentials rotate --force` | Replace credentials and disconnect web and SSH sessions. |
|
||||
| `web counters` / `web clear-counters` | Show or clear ordinary HTTPS/serial WebSocket counters, independently of performance capture. |
|
||||
| `web diagnostics enable\|disable\|show\|clear` | Independent opt-in admission tracing and post-TLS occupancy; not serial throughput timing. |
|
||||
| `web performance enable\|disable\|show\|clear` | Default-disabled per-slot binary TX timing/count aggregates. Disable freezes; enable resumes; clear preserves enabled state. Each control operation fences in-flight samples with a new epoch. All actions print a snapshot. |
|
||||
| `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 web material. |
|
||||
| `web certificate rotate --force` | Replace the HTTPS certificate and private key. Browser admin defers commit and HTTPS restart; both browser routes close and new certificate trust/relogin is required. UART0/admin SSH behavior is unchanged. |
|
||||
| `web reset --force` | Explicitly replace HTTPS certificate/private key only, including missing, incompatible or damaged material; never changes users. |
|
||||
|
||||
HTTPS listens on port 443 only. Authenticate to `https://<device-address>/` as `admin` with the credential shown on UART0. The device serves its vendored xterm.js terminal without Internet access. Browser sessions use binary WebSocket frames and follow the broker's one-writer rule.
|
||||
Use UART0 for quiet throughput captures (authenticated admin SSH also exposes these commands). Performance output includes current-epoch pending age and count/sum/estimated-average/max timings for queue entry to callback entry, synchronous send, and completion to drain return. These are not peer acknowledgements or scheduler-only measurements; nonempty intervals may include idle. No secrets or serial payloads are printed. See [Web throughput diagnostics](web_throughput_diagnostics.md) for exact semantics, saturation limits and the reset/burst/drain/disable/capture-before-disconnect recipe.
|
||||
|
||||
HTTPS listens on port 443 only. Authenticate with any current user-database username/password; both `user` and `admin` roles receive the existing status and browser-terminal interface. The device serves vendored xterm.js without Internet access. Browser sessions use one-time account-bound tickets, binary WebSocket frames, and the broker's one-writer rule. The combined **Connect**/**Disconnect** control closes the current WebSocket and pauses automatic reconnect when active; after a user-paused disconnect, it changes to **Connect** to resume connection attempts. Account mutations revoke only that account's tickets and sessions.
|
||||
|
||||
## SSH serial transport
|
||||
|
||||
@@ -92,19 +148,26 @@ HTTPS listens on port 443 only. Authenticate to `https://<device-address>/` as `
|
||||
| `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 and broker roles. |
|
||||
| `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 uses the same `admin` credentials as HTTPS, but a separate host key. It accepts password-authenticated interactive shell/PTY sessions only; it does not provide public-key authentication, `exec`, SFTP, SCP, forwarding, or subsystems. Verify the fingerprint from `ssh host-key info` before accepting an SSH host key.
|
||||
SSH listens on port 22 and accepts user-database passwords plus stored `ssh-ed25519` and `ecdsa-sha2-nistp256` public keys. wolfSSH verifies key possession after the database authorizes the username/key pair; unsigned key probes do not complete authentication. A `user` receives the broker-backed UART1 serial stream. An `admin` receives the administration shell instead, does not become a broker client, and cannot acquire a UART1 writer lease.
|
||||
|
||||
UART0 and admin SSH submit to one bounded queue, and one dispatcher task is the sole caller of `esp_console_run()`. Consequently, SSH commands execute the canonical UART0 handlers and produce the same status and mutation behavior rather than using a second command implementation. Remote output is routed into the authenticated session's bounded output ring; only the SSH transport task accesses wolfSSH.
|
||||
|
||||
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. TLS certificate management, Wi-Fi secrets, and interactive user passwords/keys are available to authenticated administrators and must therefore be treated as remotely accessible administrative material. `user recover --force` remains UART0-only; `user bootstrap` and all `web credentials` commands are removed. 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. |
|
||||
@@ -112,5 +175,18 @@ SSH listens on port 22 and uses the same `admin` credentials as HTTPS, but a sep
|
||||
| `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. Diagnostics refuse to use UART1 until `serial stop` releases it. The RGB LED shows test state: blue idle, yellow/orange running, green passed, red failed.
|
||||
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.
|
||||
|
||||
HTTPS storage migration preserves the exact TLS identity and commits TLS-only v2 before publication. Older v1-only firmware cannot read v2. Logical NVS replacement is not secure flash erasure; no factory erase is required. See [legacy removal](legacy_credential_removal.md).
|
||||
|
||||
+187
-3
@@ -1,8 +1,192 @@
|
||||
# Electrical tests
|
||||
|
||||
These procedures verify the MAX3243 breakout, UART1 data path, hardware flow control, and session broker. They are manual tests: the firmware never starts one automatically.
|
||||
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.
|
||||
> **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
|
||||
|
||||
@@ -92,7 +276,7 @@ Each output should be negative. Exact voltages depend on supply, load, meter, an
|
||||
|
||||
## Recommended order
|
||||
|
||||
1. With no DE-9 jumpers, run `status`.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Bounded ordinary HTTPS idle retention
|
||||
|
||||
Current owner/SDK contract. The user accepted idle cleanup as working; [overall acceptance](web_administration_acceptance.md) records current status and evidence limits. No individual unreported soak, fault or reserve result is inferred.
|
||||
|
||||
## Evidence and scope
|
||||
|
||||
User-authorized response to the admission capture: post-TLS occupancy **6/6, ordinary4 / serial2 / admin0**, ordinary connection ages **50–74 seconds**; admin ticket returned in **14 ms**, no subsequent TLS/upgrade observed, ticket unconsumed. These ages measure connection lifetime, not time since the last request. They support investigating retained ordinary sockets, not claiming every captured socket was idle. Installed HTTPD excludes its listening fd from `select` while full with LRU disabled. New connections can therefore wait before TLS or upgrade even when ticket issuance was fast.
|
||||
|
||||
This policy releases **expired idle ordinary HTTPS connections**, not arbitrary ordinary requests to make room. The policy uses the existing six sockets, LRU-disabled operation, one-second receive/send and five-second TLS-handshake timeouts without expanding task stacks or queue capacities. Current overall handler capacity is 39. No new task. UART0 recovery, independent USB access, serial broker ownership, tickets, authentication and both WebSocket transports are unchanged.
|
||||
|
||||
## Usage and timeout semantics
|
||||
|
||||
- Automatically enabled for every successfully started HTTPS server, including when diagnostic tracing is disabled and either optional transport is unavailable. No new console command, setting or credential migration.
|
||||
- **15 seconds of observed ordinary idle retention**, checked by a **one-second ESP timer** that requests work on HTTPD. The three existing five-second browser status-poll intervals leave room for normal keepalive reuse instead of a TLS handshake per request.
|
||||
- A new post-TLS connection that has sent no request gets the same full idle window. Time spent establishing TLS does not consume it.
|
||||
- The first owner sweep observing a new connection or a changed successful-request completion marker starts a fresh window. The marker covers every ordinary route, including login, assets, status, typed settings, tickets and keepalive errors that return successfully—not just diagnostic wrappers. Failed requests are deleted by the normal SDK path instead.
|
||||
- HTTPD pending bytes, TLS pending bytes or a readable TCP fd reset the observation window. Negative TLS pending results and `select` errors conservatively reset it too. No bytes are read or discarded by the probe.
|
||||
- At the threshold, after current SDK classification and zero-time readiness checks, HTTPD calls `shutdown(current_fd, SHUT_RDWR)`. A successful shutdown is latched; a failed call retries on the next probe. The SDK's subsequent read/delete path owns socket close, TLS destruction, diagnostic close notification and freeing the slot. The probe never calls `close`, overrides TLS cleanup, or queues a session-close pointer.
|
||||
- **WebSockets (serial and admin), closing WebSockets and async requests are exempt.** The check uses actual SDK flags, not diagnostic metadata. Successful explicit 101/classification and request cleanup finish before the owner can sweep, so there is no ordinary-idle interval during upgrade admission.
|
||||
- Under an available owner and timely successful work delivery, expect 15 seconds plus the initial observation delay (nominally up to one second), the next probe delay (nominally up to one second), and SDK read/cleanup latency. This is a conservative sampled idle policy, **not a strict wall-clock timeout or admission SLA**.
|
||||
|
||||
### Deliberate limitations
|
||||
|
||||
1. Parsing an incomplete request, synchronous response sends, leftover-body purge and TLS handshakes serialize on HTTPD. The probe cannot interrupt them. Existing per-read/per-send timeouts remain; a peer trickling input can extend overall processing beyond one timeout. This change does not provide a slowloris deadline or solve TLS/memory/global-socket pressure.
|
||||
2. Four continuously active ordinary connections plus two WebSockets can still fill all six slots. They are not evicted. Likewise, an admission attempt immediately after fresh saturation can hit the browser's existing 15-second timeout before the conservative window expires; the user may still need to retry. Older idle retained sockets are eligible on the next delivered probe.
|
||||
3. As with any HTTP keepalive timeout, bytes arriving **after** the last readiness check can race a shutdown. Already executing HTTPD requests/responses are protected; future client intent cannot be predicted. A client may need a new connection. No new application-level mutation retry/replay is added. Response completion here means synchronous HTTPD completion, not proof of peer receipt/TCP acknowledgement.
|
||||
4. `httpd_queue_work` with `CONFIG_HTTPD_QUEUE_WORK_BLOCKING` disabled uses loopback UDP. A reported queue error releases the reservation and retries next tick. **A successful send is not an execution acknowledgement**: an accepted-but-lost control message leaves one reservation pending, disables further probes, and requires successful HTTPS stop/restart to restore probing. There is no speculative reservation timeout: it could accumulate delayed callbacks and violate the one-probe bound. This is explicitly regression-tested, not hidden behind a hard retention guarantee. HTTPD blockage or queue loss also cannot be repaired by raising sockets/LRU/timeouts here.
|
||||
5. A failed stop leaves cleanup detached and ownership retained for a later stop retry; it does not restart probes on a partially stopped server. Timer allocation/start failure gates HTTPS start with its error rather than silently starting without the policy. UART0/USB recovery is unaffected.
|
||||
|
||||
## Exact installed SDK audit
|
||||
|
||||
All SDK references below are under `~/.platformio/packages/framework-espidf/components/`; installed framework is ESP-IDF **5.5.0** (`3.50500.0`). Production private access remains solely in `src/web_httpd_adapter.c`, with the existing compile-time version guard extended to require idle-lifecycle re-audit.
|
||||
|
||||
- `esp_http_server/src/httpd_main.c`: `httpd_server` selects the listener only with capacity or LRU enabled. Control work runs **before** current data sessions, then accept. `httpd_process_session` skips async sessions and synchronously runs `httpd_sess_process`; errors delete the session before any subsequent owner work. Accept invokes TLS synchronously through `open_fn`.
|
||||
- `esp_http_server/src/httpd_sess.c`: `httpd_sess_process` calls `httpd_req_new`, then `httpd_req_delete`, and only after both return success assigns `session->lru_counter = ++hd->lru_counter`. This happens even with LRU disabled. New sessions zero the slot, including the marker; the global counter resets when all sessions are deleted. Application calls to `httpd_sess_update_lru_counter` are confined to verified serial-WebSocket send work (`web_serial_transport.c`), which the sweep exempts. This marker is not a timestamp and not an fd-generation token.
|
||||
- `esp_http_server/src/httpd_parse.c`: `httpd_req_new` synchronously parses and invokes the URI handler. `httpd_req_delete` drains any remaining body; cleanup clears `hd_req_aux.sd` and request pointers. A return from a diagnostic handler wrapper or a response-send call is earlier than this boundary. The sweep requires HTTPD's thread identity and no current `hd_req_aux.sd`, and skips `for_async_req`. Current ordinary handlers do not use async requests, out-of-owner sends, or unfinished chunked responses. Re-audit that contract if introduced.
|
||||
- `esp_http_server/include/esp_http_server.h` has no global synchronous post-request-cleanup hook. Its event notifications are not such a hook: `esp_http_server_dispatch_event` posts to the event loop. `HTTP_SERVER_EVENT_SENT_DATA` is emitted by `httpd_resp_send` and per `httpd_resp_send_chunk`, **before** handler return/body purge/cleanup. Send/receive overrides belong to HTTPS and do not expose a safe completion hook. URI matching and error handlers likewise cannot supply an all-route post-cleanup boundary.
|
||||
- `esp_https_server/src/https_server.c`: successful `httpd_ssl_open` stores the transport context and installs TLS send/recv/pending functions before synchronous `HTTPD_SSL_USER_CB_SESS_CREATE`. The application callback invalidates any old row for that fd even if the TLS pointer, socket-slot address and counter value were reused. If fd lookup unexpectedly fails, all observations reset conservatively. The close callback remains the diagnostic observer; SDK destruction remains intact. `httpd_ssl_pending` calls `esp_tls_get_bytes_avail` without consuming data; errors can post an existing SDK error event.
|
||||
- `httpd_sess_trigger_close` resolves fd to a raw reusable `sock_db *`, then queues `httpd_sess_close`. Its zero-counter/LRU guard does not prove the same connection still occupies that slot. **Not used by this policy.** Direct owner shutdown retains the slot until SDK read cleanup and has no deferred fd/pointer argument that could later close a replacement.
|
||||
- `httpd_queue_work` uses `cs_send_to_ctrl_sock` / `sendto` in `esp_http_server/src/util/ctrl_sock.c`. The actual generated config leaves `CONFIG_HTTPD_QUEUE_WORK_BLOCKING` undefined/off, selecting the nonblocking queue mode. The idle initializer explicitly rejects builds with that blocking option on. Successful `httpd_stop` waits for `THREAD_STOPPED`, frees HTTPD and ends possible old callback execution; failed stop is not a retirement boundary.
|
||||
|
||||
## Ownership and bounded storage
|
||||
|
||||
`src/web_httpd_idle.{c,h}` owns one persistent ESP timer, six static observation rows, lifecycle gate/generation and queued/submitting flags. It uses no request data, secret, dynamic per-connection allocation, payload buffer, new task or additional socket. Timer callback performs only short metadata locking and at most one queue submission; all private session access, readiness and shutdown run on HTTPD.
|
||||
|
||||
`src/web_server.c` serializes lifecycle. Prepare initializes rows **before** SSL startup; the TLS callback and sweep thereafter share the same owner. Attach publishes a nonzero, nonwrapping `uintptr_t` server generation, passed by value as opaque work argument—not a mutable shared descriptor or raw fd. At most one queued/executing probe is reserved; a separate submitting flag remains set until `httpd_queue_work` returns even if work already finished. That closes the callback-before-submit-return race.
|
||||
|
||||
Detach first prevents submissions, then waits at most one second for any submitting call to return. Fence timeout forbids SSL destruction, retaining the handle for retry. An already executing sweep may finish safely while stop waits for HTTPD. Only successful SDK stop retires a discarded queued reservation; restart gets a new generation even if the server handle is reused. A stale generation cannot sweep or clear a newer reservation. No counter wrap or generic off-owner session-list query is accepted.
|
||||
|
||||
`src/web_httpd_adapter.{c,h}` defines the six-row bound and 15-second policy and performs the version-pinned owner sweep. `src/CMakeLists.txt` adds only the new module. Diagnostics remains unchanged internally; server composes idle identity reset followed by existing diagnostic publication.
|
||||
|
||||
## Regression and resource limits
|
||||
|
||||
`tests/web_httpd_idle/run.py` exercises production lifecycle/sweep paths, installed SDK request cleanup, host socketpair/readiness/shutdown/fd reuse and deterministic TLS/timer/queue doubles. `tests/web_admin_transport/server_lifecycle.py` checks server composition. Historical host validation passed; these references do not claim a new run or real target scheduler/TLS timing.
|
||||
|
||||
Bounded storage: six observation rows (144 bytes), one persistent timer (installed non-profiled layout 32 bytes before allocator overhead), lifecycle/generation flags and one reserved probe. SDK control UDP/mailbox allocation is transient and separate. No per-connection payload/task/stack/socket growth. Actual HTTPD/timer margins and runtime overhead/reserve floors require target measurement, not host sizes. See [latest firmware resources](web_administration_acceptance.md#latest-firmware-evidence).
|
||||
|
||||
## Regression procedure — not execution evidence
|
||||
|
||||
1. Start with diagnostic capture disabled: establish two serial WebSockets and ordinary HTTPS fetches. Verify idle ordinary sockets disappear after the observation window while both serial clients/lease remain unchanged. Enable capture only as needed to compare close/open occupancy; ages remain connection ages, not idle timestamps.
|
||||
2. Reproduce the original ordinary4/serial2 full-slot case; wait beyond the idle window, then issue/open admin. Record client ticket/TLS/upgrade timings and occupancy without recording tickets/cookies. Verify no repeat reload loop is needed for already-old idle saturation. Separately test fresh saturation and acknowledge the existing 15-second browser timeout limit.
|
||||
3. Leave status polling active for several minutes: no five-second TLS reconnect churn; both WebSockets and binary serial data/broker isolation survive. Repeat Settings/account-key operations, large assets, login/logout and two browser contexts. Compare with polling paused to distinguish genuinely idle slots.
|
||||
4. Slow incomplete headers/bodies, pipelined requests, slow response readers and slow/failed TLS handshakes: no probe-driven close of an executing response or admitted WebSocket. Record owner delays; do not infer an overall request deadline from the unchanged one-second receive timeout.
|
||||
5. Repeated close/reopen/fd reuse, full-mix stop/start and certificate rotation via supported UART0/SSH/browser lifecycle paths. Confirm UART0 and USB remain available, queued work never affects replacement connections, failed-stop retries retain ownership, and no start allocates a second server.
|
||||
6. Capture settled/loaded/post-cleanup internal/DMA/PSRAM values and HTTPD/ESP-timer minimum-free stack. Soak at the accepted full client mix. Investigate control queue loss separately if probing appears stuck; successful stop/start is the safe recovery, not an eviction/capacity increase.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Legacy credential removal and storage compatibility
|
||||
|
||||
Current storage/recovery contract. The user accepted cleanup on 2026-09-08, confirming the certificate fingerprint was unchanged and preexisting test users remained usable. [Overall acceptance and evidence limits](web_administration_acceptance.md) supersede the old per-slice handoff; no unreported provisioning, recovery, power-loss or all-key tests are implied.
|
||||
|
||||
## User database
|
||||
|
||||
- `user_database_init(load_result)` takes no legacy credential. Missing `user_db/database` storage is committed as an empty v1 database; no account/password is imported.
|
||||
- Create the first administrator on physical UART0 with `user add <username> admin`, optionally `--generate`. Entered passwords use hidden confirmation; generated passwords are shown once. There is no reserved bootstrap account or public bootstrap state.
|
||||
- `user recover --force` is UART0-only, calls `user_database_recover_empty()`, and destructively rebuilds only an unavailable database empty. It refuses a healthy initialized database, including an empty one. Follow with normal `user add`; unrelated configuration and TLS/SSH identities are untouched.
|
||||
- Valid existing v1 database bytes load without rewriting or changing accounts, roles, IDs, authentication generations, verifiers or authorized keys. Previously migrated role-`user` accounts are not automatically promoted. The private `v1_admin_marker` preserves the old wire byte and is derived from administrator count during mutations; it is not a new role, public bootstrap field or schema change.
|
||||
- No public bootstrap, legacy user migration or synchronization API remains. Final-admin protection, conditional mutations, copied principal currentness and target-only revocation remain the canonical account contracts.
|
||||
|
||||
## HTTPS identity storage
|
||||
|
||||
`web_security` owns only TLS material. At the unchanged `web_sec/material` key, a private byte-oriented reader validates the shipped **1,392-byte v1** layout and builds **1,340-byte TLS-only v2**. It retains the **exact private-key DER, certificate DER, SHA-256 fingerprint and material generation**: this migration is not certificate rotation.
|
||||
|
||||
The v2 candidate is validated and committed before live publication. Temporary credential-bearing v1 input is wiped. No public legacy credential reader/type, display, rotation or authentication path remains. Unknown/malformed material, read failures, invalid cryptographic identity and migration write/commit failures fail closed; they do not trigger fallback regeneration or overwrite of rejected material. Truly missing material may be generated and saved normally.
|
||||
|
||||
`web credentials show`, `web credentials rotate --force` and `user bootstrap` (including its generated form) are removed. `web certificate info` and `web certificate rotate --force` remain subject to existing frontend policy. `web reset --force` explicitly replaces TLS certificate/private key only; it does not reset passwords, import accounts or synchronize verifiers. TLS replacement/restart can close browser sessions and requires renewed certificate trust/login, without changing user credentials or revoking unrelated SSH sessions.
|
||||
|
||||
## Compatibility and physical-security limits
|
||||
|
||||
**Older v1-only firmware cannot read v2 HTTPS storage.** Do not assume a downgrade preserves usable HTTPS or restores removed credentials. Existing user database v1 compatibility is separate from this HTTPS downgrade incompatibility.
|
||||
|
||||
Replacing an NVS blob logically removes credential fields from the current record; it is **not secure flash wiping**. Append-oriented historical copies may retain plaintext legacy credentials, and current Wi-Fi secrets/TLS/SSH private keys remain unencrypted. PBKDF2 verifiers remain subject to offline guessing. No factory/partition erase is required or authorized by this cleanup.
|
||||
|
||||
## Regression references
|
||||
|
||||
Use `tests/web_security/run.py`, `tests/admin_console_boundary/accounts.py`, `tests/admin_ssh_policy/run.py` and [current legacy-removal procedures](user_administration_tests.md#current-legacy-removal-regression-procedure). Prior host tests used NVS fault doubles and a generated identity wrapped in the historical TLS layout, not an independently captured old-device fixture; they do not prove flash durability or power-loss behavior. Procedures are not claims of execution.
|
||||
@@ -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 |
+79
-68
@@ -5,6 +5,7 @@ This document tracks the implementation and hardware-validation plan for the ESP
|
||||
## 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.
|
||||
@@ -36,14 +37,15 @@ These constraints apply across all phases:
|
||||
| 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 | **Planned** |
|
||||
| 8 | Security and production hardening | **Planned** |
|
||||
| 9 | Authenticated, rollback-capable OTA | **Planned** |
|
||||
| 10 | BLE serial transport and provisioning evaluation | **Planned** |
|
||||
| 11 | Advanced network integration | **Under evaluation** |
|
||||
| 12 | Optional filesystem-backed features | **Under evaluation** |
|
||||
| 7 | Local display and button interface | **Complete** |
|
||||
| 8 | Role-based users and administrative access | **In progress (8A–8C complete; integrated web administration 8D planned)** |
|
||||
| 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 foundation
|
||||
## Completed phases
|
||||
|
||||
### Phase 0 — RS-232 hardware characterization
|
||||
|
||||
@@ -55,7 +57,7 @@ Implemented and hardware-validated:
|
||||
- Hardware CTS transmit gating and RTS receive-backpressure tests.
|
||||
- Exclusive RS-232 port ownership and fault-safe cleanup.
|
||||
- RGB status indication for diagnostic state.
|
||||
- Potentially disruptive tests isolated under the `debug` CLI submenu; top-level `status` retained.
|
||||
- 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).
|
||||
|
||||
@@ -91,7 +93,7 @@ Implemented and hardware-validated:
|
||||
- Automatic broker connection when the host opens the port with DTR asserted.
|
||||
- Opportunistic writer acquisition with observer fallback.
|
||||
- Binary-transparent bidirectional serial data.
|
||||
- Supported host line-coding application while USB owns the writer lease.
|
||||
- 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
|
||||
@@ -107,6 +109,11 @@ Implemented and hardware-validated:
|
||||
- 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
|
||||
@@ -114,7 +121,7 @@ A WPA3-required profile correctly rejects an AP that does not advertise the requ
|
||||
Implemented and hardware-validated:
|
||||
|
||||
- HTTPS-only service on TCP port 443 using ESP-IDF mbedTLS.
|
||||
- Persistent random shared `admin` credential.
|
||||
- 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.
|
||||
@@ -134,69 +141,73 @@ Implemented and hardware-validated:
|
||||
- Viewport-constrained terminal fitting without recursive page growth.
|
||||
- Validation with multiple clients, ANSI colors, advanced escape sequences, and full-screen terminal software.
|
||||
|
||||
## Latest completed phase
|
||||
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
|
||||
|
||||
The SSH implementation is complete and hardware-validated. It provides:
|
||||
Implemented and target-hardware validated:
|
||||
|
||||
- wolfSSH on TCP port 22 while HTTPS remains on mbedTLS.
|
||||
- Password authentication using the shared `admin` credential.
|
||||
- A separate persistent ECDSA P-256 SSH host key and OpenSSH-compatible fingerprint.
|
||||
- Interactive shell/PTY channels only.
|
||||
- No `exec`, subsystem, SFTP, SCP, agent forwarding, or TCP forwarding.
|
||||
- Two fixed session/handshake slots.
|
||||
- Three password attempts and a 15-second handshake deadline.
|
||||
- No broker client before successful authentication and shell negotiation.
|
||||
- Binary-transparent serial forwarding with no in-band writer-control protocol.
|
||||
- Opportunistic writer acquisition and observer behavior through the common broker.
|
||||
- Credential-rotation revocation and UART0 session/host-key administration.
|
||||
- A single bounded wolfSSH owner task pinned to CPU 1.
|
||||
- wolfSSL/wolfSSH allocations that prefer PSRAM with internal-memory fallback.
|
||||
- A per-call wolfSSH receive budget to prevent pipelined input from monopolizing the owner task.
|
||||
- Bounded broker and WebSocket work bursts that guarantee scheduler/idle progress during sustained serial traffic.
|
||||
- Software AES for PSRAM-backed mbedTLS records plus software wolfCrypt AES/SHA, preventing mbedTLS and wolfSSL from independently reconfiguring shared ESP32 crypto hardware.
|
||||
- Internal task stacks retained for safety during flash/NVS cache-disable windows.
|
||||
- Heap and SSH stack telemetry through `memory` and `ssh status`.
|
||||
|
||||
SSH login, host-key verification, serial traffic, Web Serial coexistence, and USB CDC coexistence have been validated. During concurrency testing, the previously failing three-client arrangement used USB CDC and SSH as writer in turn while the remaining transports observed.
|
||||
|
||||
Early runs showed healthy heap values but CPU 0 idle-task watchdog timeouts, disproving the initial memory-pressure hypothesis. One trace exposed an unbounded broker scheduling path. A later, decisive trace repeatedly stopped inside mbedTLS's external-RAM hardware-AES DMA wait while HTTPD encrypted a WebSocket record. The broker and WebSocket producer now use bounded active bursts, mbedTLS software AES avoids the hanging PSRAM DMA path, and wolfSSL AES/SHA hardware acceleration is disabled because its private locks do not coordinate with ESP-IDF's crypto lock.
|
||||
|
||||
#### Phase 6 hardware validation
|
||||
|
||||
The final target-hardware retest covered:
|
||||
|
||||
1. USB CDC, one WebSocket terminal, and one SSH terminal connected concurrently.
|
||||
2. SSH as writer with USB CDC and WebSocket observing.
|
||||
3. USB CDC as writer with SSH and WebSocket observing.
|
||||
4. Serial traffic with active readers and writer-token release/reassignment.
|
||||
5. Continued HTTPS/WebSocket, SSH, USB CDC, serial-service, and UART0 responsiveness.
|
||||
|
||||
The software-crypto build no longer reproduces the HTTPD watchdog stall. This validates that the failure was a shared hardware-crypto/PSRAM DMA problem rather than heap exhaustion. Phase 6 is complete; these concurrent arrangements remain regression tests for future transport, TLS, memory-placement, and ESP-IDF changes.
|
||||
|
||||
## Planned phases
|
||||
|
||||
The order below is the current plan. Detailed requirements should be finalized at the start of each phase, and optional features must not weaken the completed serial and recovery paths.
|
||||
- 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
|
||||
|
||||
Add a standalone local status/control interface without making it a dependency of the serial core.
|
||||
Implemented and target-hardware validated:
|
||||
|
||||
Planned work:
|
||||
- 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).
|
||||
|
||||
- Select the actual display, button arrangement, electrical interface, and remaining GPIO assignment before implementation.
|
||||
- Add a board-profile abstraction where required instead of scattering new pin assumptions.
|
||||
- Show concise serial, Wi-Fi, service, client-count, and writer-owner status.
|
||||
- Provide safe button actions for navigation and common lifecycle or writer-control operations.
|
||||
- Require deliberate confirmation for destructive or security-sensitive actions.
|
||||
- Avoid displaying passwords or private-key material during ordinary operation.
|
||||
- Keep UART0 fully functional as the authoritative recovery interface if the display or buttons fail.
|
||||
- Bound display refresh and event processing so they cannot interfere with UART or network transport timing.
|
||||
### Phase 8 — Role-based users and administrative access — Complete
|
||||
|
||||
Completion requires electrical validation, operation with all current transports, and documented recovery behavior.
|
||||
Implemented and accepted: a bounded, persistent user system replaces the single shared network credential. 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.
|
||||
|
||||
### Phase 8 — Security and production hardening
|
||||
Completed implementation (8A–8C target-hardware validated; 8D explicitly accepted at 8D.22 on 2026-09-13):
|
||||
|
||||
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.
|
||||
- Historical migration/bootstrap supported the initial cutover. Current firmware instead commits missing storage empty, provisions the first admin with normal UART0 `user add`, and provides UART0-only unavailable-database recovery; legacy credential/bootstrap commands are removed.
|
||||
- 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 and SSH authenticate through the common database and copied secret-free principals; unavailable user storage fails closed. The initial HTTPS Basic path was replaced by cookie authentication in 8D.
|
||||
- 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.
|
||||
- Password/key login, ticket behavior, targeted revocation, recovery, and concurrent transport operation are target-hardware validated. Legacy credentials were subsequently removed without changing established TLS identity or valid users.
|
||||
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 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. First-admin provisioning and explicit `user recover --force` remain physical-UART0 operations (`user bootstrap` was later removed); 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 — Complete**
|
||||
- User explicitly signed off tested firmware at **8D.22 on 2026-09-13**, superseding earlier per-slice pending acceptance/review gates. Cookie login/logout replaces Basic; bounded digest-only sessions, same-origin/CSRF checks, principal currentness and targeted revocation protect serial and admin routes.
|
||||
- Admin-only Serial/Admin selection and Settings preserve connected terminal observation and serial writer ownership. The browser shell shares the serialized UART0/admin-SSH dispatcher, with bounded output, explicit admission and retained frontend restrictions—not unrestricted shell parity.
|
||||
- Typed Serial, Accounts/password/authorized-key, Network/Wi-Fi/mDNS, Display, Broker and SSH settings; confirmed HTTPS stop/restart/reboot and HTTPS/SSH identity rotation. Canonical owners compare reserved service/identity/target generations, preserve commit/failure semantics and expose no stored secrets. Admitted work is not cancelled by later logout/timeout; uncertain mutations are never automatically replayed.
|
||||
- Accessible Serial/Wi-Fi/client/writer contextual controls reuse existing controllers. Writer transfer is explicit and generation-safe; one writer, isolated observers, binary transparency, UART0 administrative recovery and network-independent native USB UART1 remain invariants.
|
||||
- Scope removed by user: **8D.15** dedicated typed network diagnostics (shell diagnostics retained) and the unimplemented **8D.19** ordinary browser-session/native-USB controls (SSH controls retained). Browser identity reset/recovery/export is excluded; canonical recovery remains available.
|
||||
- Prior final build PASS: **100,556 B linked RAM / 1,828,573 B flash, CPU 160 MHz**. Earlier combined binary WebSocket-send fix was explicitly accepted at **160 MHz / 230400 baud with full client mix including browser admin**. Latest overall sign-off does not invent individual fault/soak/duration results or a latest exact zero-drop comparison.
|
||||
- Latest loaded internal/DMA minima **2,052 / 460 B** remain a conservative transient-headroom follow-up, not a blocker reopening acceptance or an approved reserve. See [acceptance and telemetry](web_administration_acceptance.md), [current contracts](web_administration.md), [regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) and [legacy compatibility](legacy_credential_removal.md).
|
||||
|
||||
## Current and planned phases
|
||||
|
||||
Phase 8 is complete for its accepted scope. Phase 9 is next **only on a separate user request**; later work remains planned or under evaluation. Optional features must not weaken completed serial and recovery paths. General release gates below remain guidance for future work, not claims that every listed fault, soak or reserve measurement was individually performed for 8D.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -216,7 +227,7 @@ Planned work:
|
||||
|
||||
Security features must be validated first on expendable hardware before any irreversible production configuration is recommended.
|
||||
|
||||
### Phase 9 — Authenticated OTA and rollback
|
||||
### Phase 10 — Authenticated OTA and rollback
|
||||
|
||||
Use the existing dual 4 MiB application slots and `otadata` partition to add safe firmware updates.
|
||||
|
||||
@@ -234,7 +245,7 @@ Planned work:
|
||||
|
||||
OTA is complete only after successful update, interrupted-update, invalid-image, rollback, and configuration-persistence tests on hardware.
|
||||
|
||||
### Phase 10 — BLE
|
||||
### Phase 11 — BLE
|
||||
|
||||
Evaluate and, if resource limits permit, add BLE as another bounded transport or provisioning path.
|
||||
|
||||
@@ -251,18 +262,18 @@ Planned work:
|
||||
|
||||
BLE remains subordinate to stable Wi-Fi and serial operation; it should be omitted if coexistence or memory costs cannot be bounded acceptably.
|
||||
|
||||
### Phase 11 — Advanced network integration
|
||||
### Phase 12 — Advanced network integration
|
||||
|
||||
These features are candidates, not current commitments:
|
||||
|
||||
- mDNS advertisement for the certificate’s device-specific `.local` name and selected services.
|
||||
- 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 12 — Optional filesystem-backed features
|
||||
### Phase 13 — Optional filesystem-backed features
|
||||
|
||||
The `storage` partition is reserved but not currently mounted. Possible uses must be justified individually:
|
||||
|
||||
@@ -297,7 +308,7 @@ The following are not implemented merely because flash partitions or library sup
|
||||
- OTA download, image confirmation, or rollback policy.
|
||||
- Core-dump collection or secret-safe core-dump processing.
|
||||
- Filesystem mounting.
|
||||
- Public-key SSH user authentication.
|
||||
|
||||
- SFTP, SCP, SSH `exec`, forwarding, or subsystems.
|
||||
- General routing, NAPT, captive-portal interception, or unauthenticated serial access.
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# User administration and authentication tests
|
||||
|
||||
Reusable procedures, **not execution evidence**. [Phase 8 is accepted](web_administration_acceptance.md); overall 8D.22 user sign-off does not assert that every checklist item below ran. Historical 8A/B sections describe their original baselines only; current cookie authentication, removed bootstrap/credentials and UART0-only recovery override them. Use current contracts and the integrated regression section for present behavior. Never log generated/entered passwords, cookies/tickets or private/verifier material.
|
||||
|
||||
## Current Broker management regression procedure — 8D.16
|
||||
|
||||
The [Broker contract](web_administration.md#broker-and-contextual-controls) defines current API, generations, wrap safety and bounds. Retain these checks for future regressions; acceptance is recorded separately.
|
||||
|
||||
- Verify admin-only detailed client rows against non-consuming UART0 snapshots/counters; direct normal-user management GET/POST denied. Observe zero/one/full client mix without reading UART payloads as a probe.
|
||||
- Opening, selecting, cancelling, refreshing and navigating must leave writer and both terminals unchanged. Explicit full-page Refresh clears selection; contextual refresh retains selected target/version and sticky stale/absence latches until deliberate reselection. Native confirmation must identify exact target and snapshot writer.
|
||||
- Assign among USB/SSH/two browser serial clients with both admins connected. Disconnect/reuse target while confirmation is open; race writer release/reacquire, competing requests, shell force and local release. Stale confirmation must fail without changing the intervening lease; Refresh/reselect/confirm explicitly.
|
||||
- Test separate-login result isolation, same-login tab replacement, pending capacity, lost ACK/result, bounded polling/manual recovery, logout/expiry/revocation and HTTPS stop/restart. Never infer cancellation from connection loss or replay automatically.
|
||||
- At signed-off 160 MHz/230400 baud, check full-mix binary traffic/drop isolation, UART0/USB recovery and optional-route failure isolation. Capture exact revision/mix and internal/DMA/PSRAM plus HTTPD/dispatcher margins; host tests/build do not approve reserves or target throughput for an unmeasured workload.
|
||||
|
||||
## Current Network settings regression procedure — 8D.12/8D.13
|
||||
|
||||
The [Network contract](web_administration.md#network) defines the current byte codec, owner/persistence semantics and uncertainty. Retain these regression procedures without inferring individual execution from overall sign-off.
|
||||
|
||||
- Verify admin-only Settings/Network and direct-route normal-user denial, current cookie/principal, body/query/framing/Origin/CSRF checks, unavailable/contended snapshots and generation races against CLI/local controls.
|
||||
- Round-trip UTF-8 and arbitrary SSID bytes through text/hex, including NUL/BOM/non-UTF-8 and 32-byte boundaries. No saved PSK or length may appear in responses, status/logs/completion/local display. Keep omits credentials; Replace never accepts blank; disabled-STA Clear (including disable+clear) works; enabled-STA/AP clear is denied even when AP policy is off. Check transient-input expiry and context/session clearing.
|
||||
- Distinguish RAM Apply, explicit Save, stored-only Wi-Fi Load and reboot persistence. Missing/invalid/failing Wi-Fi storage must not install generated defaults or new AP secrets. Exercise stale generations, queue failure/drop accounting and NVS failures without secret logging. No Wi-Fi reset/default/export action exists.
|
||||
- Prepare UART0 and USB before confirming disruptive actions. Test Start/Stop (including RAM boot policy), Reconnect/Next, AP policies and stopped no-ops. The selected profile is an edit target, not explicit connection selection; Next uses canonical priority/wrap. Cancel confirmations and exercise lost ACK/401/disconnect, manual Check Result/Refresh, another-tab result replacement and no automatic replay. `accepted` is not online; delivery before disconnection is not guaranteed. Reconnect via STA/AP; UART0 administers recovery, USB preserves independent UART1 access.
|
||||
- Test mDNS generation/Set/Save/Load/Defaults, live/offline reannouncement, next STA IP, init/live failure isolation and `applied_not_queued`. Verify actual client DNS and changed-hostname browser trust/login, not merely `announced`. Confirm no unintentional Wi-Fi secret reset.
|
||||
- With USB/two web serial/SSH serial and both admin routes, verify hidden output draining and writer/observer preservation through Settings navigation. Separate actual network-disruption losses from serial/broker regressions. Exercise optional Network route/timer failure and stop/restart without taking down unrelated routes. Browser-shell restrictions remain unchanged.
|
||||
- Capture boot/full-mix internal/DMA/PSRAM free/minimum/largest blocks and memory floors during TLS/Network operations; timer heap/slot costs, repeated-operation cleanup/soak and **HTTPD/dispatcher stack margins** remain required. Record exact revision/client mix and nonsecret counters, including broker and manager queue drops. Host tests do not establish target reserve or hard scheduling/cancellation guarantees.
|
||||
|
||||
## Current legacy-removal regression procedure
|
||||
|
||||
The legacy bootstrap/credential/reconciliation instructions in the phase baselines below are **historical only**, superseded by [legacy credential removal](legacy_credential_removal.md) and the current [command reference](command_reference.md). Basic authentication is also historical; current HTTPS uses cookie login. The current overrides apply to later Phase 8C/browser procedures too: no `user bootstrap` or `web credentials` command remains, first-admin creation uses normal UART0 `user add`, and recovery rebuilds empty. Never treat the checklist below as evidence of execution.
|
||||
|
||||
1. On a disposable controlled NVS image with only `user_db/database` missing, boot and confirm an empty database is persisted with zero accounts/admins and no imported credential. Reboot and confirm it remains empty. Keep physical UART0 attached; do not factory-erase the device for this test.
|
||||
2. Run `user add maint admin` on UART0, check hidden password confirmation and cancellation, then confirm account/password persistence after reboot. Separately exercise `user add operator user --generate` and secure one-time display. Final-administrator delete/demotion must still fail. Existing SSH own-password generation restrictions and typed browser generated-password support remain unchanged.
|
||||
3. Load a valid existing v1 user image and verify accounts, roles, IDs, auth generations, verifiers and keys are unchanged, including a formerly migrated role-`user` account. No bootstrap status should appear and no account should be silently promoted. No web material change may synchronize a verifier.
|
||||
4. On a disposable malformed user image, confirm authentication fails closed without automatically overwriting storage. On UART0 run `user recover --force`, confirm empty storage, then `user add maint admin`. Recovery must refuse healthy databases, including healthy empty storage, and be unavailable through SSH/browser. Verify serial/Wi-Fi configuration and TLS/SSH identities remain intact. Inject read/write/commit failures where available and check failure isolation and complete committed records, not partial live mutations.
|
||||
5. Upgrade valid 1,392-byte v1 `web_sec/material`; verify persisted 1,340-byte TLS-only v2 and exact certificate/key DER, fingerprint and generation retention in a controlled fixture without logging private data. Reboot and confirm identity continuity. Malformed/unknown/cryptographically invalid records and migration read/write/commit failures must fail closed without fallback replacement. Do not interpret a failed commit as proof that no flash write occurred.
|
||||
6. Confirm help/completion/status expose no legacy credential/bootstrap operation or secret. Removed commands must reject without mutation. Exercise retained user generation and `web certificate rotate --force` through supported frontends. `web reset --force` must change TLS only, require new certificate trust/login after HTTPS restart, leave user credentials/generations unchanged, and not revoke unrelated SSH sessions.
|
||||
7. Recheck UART0/native USB availability and broker one-writer/isolated-observer behavior with network authentication unavailable. Record only nonsecret counters and telemetry. Older v1-only firmware cannot read HTTPS v2; logical NVS replacement is not secure flash erasure and no factory erase is required.
|
||||
|
||||
Acceptance and prior evidence limits are recorded in [legacy compatibility](legacy_credential_removal.md) and [overall acceptance](web_administration_acceptance.md); the preceding procedure is not an execution log.
|
||||
|
||||
## 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.
|
||||
|
||||
## Integrated web administration regression procedure
|
||||
|
||||
Current retained-scope checks for future changes. [8D.22 sign-off](web_administration_acceptance.md) closes the phase; these are **not additional acceptance conditions or claims of individual execution**. Record exact revision, workload/client mix, duration, counters and heap/stack evidence for any new run. Do not treat host doubles as target timing/power-loss proof or reserve approval. Prepare UART0 recovery/native USB before disruptive tests and do not erase persisted data without explicit approval.
|
||||
|
||||
### 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. First-admin provisioning remains UART0-only, remote recovery is rejected, and the removed bootstrap command is unavailable everywhere. 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. Typed settings and destructive operations
|
||||
|
||||
Exercise user/password/role/key management, serial settings and persistence, Wi-Fi profiles/AP policy/secrets, retained SSH service/session controls, display settings, HTTPS lifecycle/reboot and HTTPS/SSH rotation through typed bounded APIs. Dedicated typed network diagnostics and ordinary browser-session/native-USB management are excluded; exercise existing shell diagnostics only through permitted frontends. Compare resulting subsystem state with the equivalent canonical CLI behavior without routing API requests through command strings. Generated passwords appear once in no-store responses; destructive or self-terminating HTTPS/SSH/reboot/security actions require explicit confirmation and explain the expected connection loss.
|
||||
|
||||
### 5. 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 popovers. Verify one writer, isolated observers, bounded memory/queues, 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.
|
||||
|
||||
### 6. Service identity, lost acknowledgement and recovery
|
||||
|
||||
Confirm public fingerprints and both service/identity versions before HTTPS/SSH rotation. Race canonical CLI changes with open confirmations; stale operations must reject before mutation. For HTTPS, exercise send-return/ID-callback admission, accepted-but-lost work and failed destruction without accumulating callbacks; restart must retain owner reservation while deliberately invalidating logins. For SSH, failed stop must skip mutation/start and retained context must survive until every slot retires. Inspect precommit/postcommit partial effects without assuming an error means no change; never replay automatically. Verify new trust via UART0, fresh HTTPS login after restart, stopped-rotate versus CLI-reset behavior, and continued unrelated-service recovery. Browser identity Reset/recovery/export is not part of the workflow.
|
||||
|
||||
### 7. Display, API bounds and optional failures
|
||||
|
||||
Exercise Display generation conflicts, explicit RAM/Save/Load/Defaults/Reset, absent panel with available UI task and concurrent button activity. Reset storage failure must leave RAM unchanged. Across all typed domains check body/receive/schema limits, optional registration failures, one-slot/login isolation, queue expiry versus admitted execution, saturation and stale IDs. Secret timers cancel/wipe only non-executing work; logout/deadline cannot recall admitted commits. Compare Serial service discards with preserved broker clients/lease/output, rather than claiming uninterrupted data through explicit reconfiguration.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Web administration contracts
|
||||
|
||||
Current, accepted firmware behavior. Phase status and executed-evidence limits belong in the [roadmap](roadmap.md#phase-8--role-based-users-and-administrative-access--complete) and [acceptance record](web_administration_acceptance.md), not in implementation timelines. [Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) describe checks, not results. Source is authoritative; start with the [code map](agent/code-map.md).
|
||||
|
||||
## Authentication and admission
|
||||
|
||||
- HTTPS-only, same-origin login replaces Basic authentication entirely. Four digest-only opaque sessions have absolute one-hour expiry; four pre-login challenges last 120 seconds. Login is globally bounded to five credential attempts per 60 seconds. Live sessions/challenges/tickets are not evicted to admit another client.
|
||||
- Raw tokens travel only in the host-only `__Host-` cookie with Secure, HttpOnly, SameSite=Strict, Path=/ and explicit lifetime; no Domain. Session records retain copied secret-free principals, CSRF state and nonzero, nonreused originating-session IDs. Passwords, raw cookies/tickets, verifiers and private keys never enter routine snapshots/logs/completion/display.
|
||||
- Mutation admission requires current cookie/principal, strict Origin and CSRF validation; administration additionally requires current `admin`. Normal users retain serial/status but cannot invoke administration directly. Authentication POST fetches use CORS mode with fixed same-origin URLs and same-origin credentials: do not accept Origin `null` to compensate for browser no-referrer behavior.
|
||||
- Session-store initialization is part of admitted HTTPS start; authentication failure gates HTTPS. Failed start/accepted stop disables and wipes session state. Logout invalidates only the originating session before socket cleanup; account mutation invalidates that account's sessions/tickets, including deletion/recreation, without revoking unrelated accounts. Currentness checks remain authoritative if best-effort notifications fail.
|
||||
- Four serial tickets and two admin tickets are digest-only, single-use, 30-second, session/principal-bound records. Cookie/Origin/ticket/currentness and transport admission precede explicit WebSocket 101. Store RNG/SHA/database calls run outside short spinlocks; IDs/expiry/epochs fence stale publication without nested store/transport locks.
|
||||
- HTTPD remains bounded to six sockets, two serial WebSockets, one admin WebSocket and 39 method/path handlers; LRU eviction is disabled. Sessions, sockets, tickets and the two shared remote-console slots are separate capacity limits. Optional settings/admin failures preserve unrelated routes where their initialization contract permits; UART0 and native USB remain independent of web readiness.
|
||||
|
||||
`web_httpd_adapter` alone accesses private IDF 5.5.0 HTTPD state. It rejects duplicate/ambiguous headers, postpones 101 until admission, and wipes consumed header scratch while preserving right-aligned unread bytes. Optional Settings registration stages descriptor/name allocations before publishing either, avoiding the pinned public registration failure path. Re-audit these private boundaries on SDK upgrades and same-version SDK patches: the version guard does not detect patches that retain the same version number. HTTPD response headers are pointer-backed, not copied; both `Set-Cookie` value buffers must remain valid and distinct through response send. Do not reuse or wipe those buffers before sending completes. Do not enable header/ticket debug logging. Auth documents, scripts and sensitive responses are no-store with CSP/no-referrer/frame-denial protections; authored loader changes require matching CSP hashes. Generated assets are not a normal documentation/build output.
|
||||
|
||||
## Terminal and console ownership
|
||||
|
||||
Switching Serial/Admin/Settings never creates a second serial client, requests/releases the writer lease or reconnects serial. Hidden connected terminals keep draining; only selected terminal input is sent. Both browser terminals have separate 5,000-line scrollback and 64 KiB callback-accounted pending-output bounds with visible browser-drop counts. Admin input is bounded to 4 KiB admission and 512-byte frames. Admin open/reopen is explicit; admin close is isolated. Disconnect pauses serial reconnect but retains login; logout/expiry/revocation closes affected sockets normally.
|
||||
|
||||
The document binds terminal state to its first validated username/role/session-stable CSRF tuple. A different identity requires a clean document before showing retained output. Pagehide fences sockets/work and hides scrollback until same-session revalidation; admin is not automatically reopened. Fit readiness caches only success and uses at most three generation-fenced animation-frame retries per external request.
|
||||
|
||||
`admin_ssh_console` is the sole `esp_console_run()` caller for UART0, admin SSH and browser admin. Two fixed remote-console slots are shared across SSH/browser, not two per transport. Queue records carry copied principals and transport-qualified generation tokens. Owner currentness is checked outside console locks before commands and during prompts (250 ms polling plus validation/scheduling latency), then identity is rechecked. This is operation admission, not cancellation or rollback of arbitrary executing handlers. Consumed output and retired prompt/history state are wiped.
|
||||
|
||||
HTTPD alone owns browser-admin socket IO and its 1,552-byte PSRAM-only payload. A 20 ms timer queues at most one owner poll, not a new task. Close uses current-owner shutdown, not queued reusable `sock_db *` pointers. Detach fences submitters; queued state retires only after successful HTTPD destruction. Failed destruction retains ownership and prevents unsafe restart/reuse.
|
||||
|
||||
### Browser-shell policy
|
||||
|
||||
Typed Settings permissions do not expand shell permissions. Parsed canonical arguments, not raw prefixes or completion suggestions, control admission:
|
||||
|
||||
- Browser `web` permits only `web status`, `web stop`, and exact `web certificate rotate --force`; certificate info/reset, diagnostics/performance and other web forms are denied.
|
||||
- Browser `wifi`/`mdns` permit only status. Network mutation belongs to typed Settings or UART0/admin SSH.
|
||||
- Browser `user` permits status/list/show and interactive add/password plus forced role/delete for **other accounts only**. Self changes, generated passwords, key commands and recovery are denied there; typed Accounts supports the separately bounded self/generated/key workflows.
|
||||
- Browser SSH stop/disconnect/reset and host-key mutation are denied; typed SSH Settings has its own safe owner path. Do not claim full browser-shell parity.
|
||||
- Browser `reboot` and owner-relative `exit` are supported. First-admin provisioning uses normal `user add` on UART0; unavailable-database recovery is UART0-only. The legacy `user bootstrap` and web credential commands no longer exist.
|
||||
|
||||
Self-affecting shell actions use the existing bounded drain/control path (up to ten seconds plus a short delay), not guaranteed peer delivery. Browser certificate rotation hands a typed action after drain/200 ms to the existing 12 KiB dispatcher, never crypto/NVS on the 4 KiB control stack. Pending input is discarded through execution, and an executing slot remains reserved across self-detach. UART0/admin SSH retain canonical recovery actions.
|
||||
|
||||
## Typed settings API and operation lifetime
|
||||
|
||||
All routes below are under `/api/settings/`. Each domain has bodyless GET snapshot plus GET/POST operation unless noted. Requests reject queries, malformed/oversized/duplicate/unknown fields and inappropriate bodies; mutation routes require bounded JSON with current admin/Origin/CSRF. GET allows absent Origin but rejects mismatch. Snapshot contention/unavailability is not an empty successful projection. HTTPD performs bounded admission/encoding only; canonical mutations run on the existing four-entry administration dispatcher using **IDs only**, not command strings, credentials, request pointers or socket handles.
|
||||
|
||||
| Domain and source | Snapshot / operation suffix | Request / snapshot / result buffer bounds (bytes) |
|
||||
|---|---|---|
|
||||
| `web_serial_settings`, serial service | `serial` / `serial-operation` | 256 / 256 / 96 |
|
||||
| `web_account_settings`, user database | `accounts` / `account-operation` | 768 / 1024 (accounts), 512 (keys) / 96 |
|
||||
| `web_network_settings`, Wi-Fi + mDNS | `network` / `network-operation` | 768 / 2048 / 128 |
|
||||
| `web_display_settings`, local status UI | `display` / `display-operation` | 256 / 128 / 96 |
|
||||
| `web_broker_settings`, session broker | `broker` / `broker-operation` | 256 / 2048 / 96 |
|
||||
| `web_ssh_settings`, SSH owner/security | `ssh` / `ssh-operation` | 256 / 768 / 96 |
|
||||
| `web_lifecycle_settings`, HTTPS owner/security | `lifecycle` / `lifecycle-operation` | 256 / 320 / 96 |
|
||||
|
||||
Mutation bodies use at most four receive attempts. Each domain retains one original-login-bound pending/result slot, nonreused operation IDs and an executing reservation. Results are replaceable, login-isolated observations, not durable history or idempotency keys. Another tab can replace a completed result. Dispatcher currentness and the 30-second dequeue deadline precede canonical owner admission. **Admitted work may finish after logout, revocation, timeout or navigation.** A failed response, 401 or disappearance of a result proves neither success nor cancellation; inspect current state before any deliberate retry.
|
||||
|
||||
Accounts and Network each use a one-second timer to cancel/wipe queued, non-executing secrets after 30 seconds plus scheduling latency. Shared input wipes on dequeue/rejection; executing locals wipe on return. This is not a hard wall-clock erasure/execution guarantee. Other domain deadlines are dequeue checks, not new cancellation timers. UI fences late/session-changed responses, bounds request/result checking to 15 seconds and never automatically replays/restores mutations. Serial/Accounts/Network/Display/Broker use bounded automatic checks where implemented (at most ten one-second GETs); SSH and HTTPS/Reboot require manual Check Result/Refresh.
|
||||
|
||||
### Serial and Display
|
||||
|
||||
Serial Apply/Defaults change RAM; Save persists working device state, not browser drafts. Load follows canonical defaults/fallback behavior. Reset uses canonical apply/persist/best-effort rollback. Reconfiguration/stop discards serial-service RX/TX and task-local pending data but preserves broker clients, lease and already-fanned output. USB with DTR may restart a stopped service. `/api/status` uses a zero-wait consistent serial snapshot (`running:null` if unavailable).
|
||||
|
||||
Display accepts dim/off timeouts 0–86400 seconds and an expected nonzero configuration generation. All writers, including CLI/legacy Apply, share a zero-wait owner reservation; NVS runs outside critical sections. Save stabilizes selected RAM; Load retains canonical fallback without rewriting NVS; Reset commits defaults **before** RAM publication, leaving RAM unchanged on failure. Buttons/diagnostic holds update activity, not configuration generation. Configuration needs an available UI task, not a physically present panel, and never takes over I2C.
|
||||
|
||||
### Accounts and authorized keys
|
||||
|
||||
The database owns eight accounts, final-admin protection and conditional target username/account-ID/auth-generation checks inside its mutation lock. Create/password/role/delete and key mutations share canonical commit/invariant logic. HTTPD uses zero-wait secret-free projections, not blocking CLI snapshots. Successful mutations request target-only web/SSH revocation; self mutation can revoke access before result retrieval.
|
||||
|
||||
Separate bodyless POST `accounts/generate-password` returns one 24-character value without mutation or retained retrieval. The browser uses a 60-second context-bound saved acknowledgement before separate submission; this is UX, not delivery proof, and JavaScript strings cannot be securely wiped. POST `accounts/keys` returns only slot/type/SHA-256 fingerprints for the selected identity. Key add/delete/clear share the operation slot; public-key import is at most 384 decoded text bytes in the 768-byte request and uses canonical Ed25519/P-256 validation. Three stable key indices may be sparse; never interpret response-array position as an index. No stored key blob, verifier or password is exported.
|
||||
|
||||
### Network
|
||||
|
||||
Wi-Fi config/runtime is one zero-wait consistent projection; mDNS is a separate projection, not cross-domain atomic authorization. Four stable profiles carry enabled/priority/security/SSID/password-configured metadata. `mixed` means WPA2-or-stronger, not open. `announced` is expected STA announcement, not verified DNS.
|
||||
|
||||
SSID wire values are reversible **bytes**, maximum 32: printable ASCII, standard single-character JSON escapes and `\u00HH`, with no raw non-ASCII, non-byte Unicode or surrogates. NUL/non-UTF-8 round-trip. UI text is UTF-8-encoded before byte serialization; exact reversible text or literal hex preserves existing bytes and BOM, with no silent replacement/truncation.
|
||||
|
||||
Flat operations select one domain/target:
|
||||
|
||||
- `wifi-patch` + Wi-Fi generation: optional boot policy, AP policy/channel/SSID/password/clear; `profile-patch` additionally selects stable profile 0–3 and optional enabled/priority/security.
|
||||
- `wifi-save|wifi-load` require Wi-Fi generation. `start|stop|reconnect|next-profile` use canonical connection controls, not explicit selected-profile connection.
|
||||
- `mdns-set` requires its generation and suffix; `mdns-save|mdns-load|mdns-defaults` require its generation. Suffix is 1–55 lowercase ASCII letters/digits/hyphens, no leading/trailing hyphen, producing `sak-<suffix>`.
|
||||
|
||||
Omitted fields preserve current bytes under the Wi-Fi mutex. Password Keep omits, Replace is 8–63 printable ASCII bytes, disabled-STA Clear is explicit; replacement and clear cannot coexist. AP clear is always denied, even while off. No saved PSK or length is returned. Wi-Fi compare/merge/whole-candidate validation and required queue admission precede RAM publication. Save stabilizes selected bytes; Load reads stored configuration only and cannot generate fallback secrets. No browser Wi-Fi reset/default-secret/export route exists.
|
||||
|
||||
Edits require explicit Save. Disabled-profile-only edits do not restart active radio; enabled-policy/AP changes follow canonical asynchronous restart. Start/Stop change RAM enabled-at-boot; Reconnect/Next are no-ops while stopped. Next follows enabled priority order with wrap; editor selection is not connection selection. mDNS independently owns generation/persistence; the Wi-Fi manager owns radio/reannouncement. mDNS RAM change followed by queue failure is reported without rollback; its Load may choose deterministic MAC-derived defaults.
|
||||
|
||||
Network result fields are `id/action/state/error`: `pending`, `accepted`, `ok`, `failed`, `cancelled`, `stale`, `invalid`, `loaded_defaults`, `applied_not_queued`, or login-isolated `idle`. `accepted` means RAM/owner-queue admission, **not** association/DHCP/DNS completion. `ok` denotes explicit Save. Loss can precede response; recover via STA/AP and inspect before retrying. UART0 administers recovery; native USB provides network-independent UART1, not an admin console.
|
||||
|
||||
### Broker and contextual controls
|
||||
|
||||
Broker management atomically copies eight compact client rows, writer and lease generation under one zero-wait mutex acquisition, without reading UART payloads or consuming events. Conditional assignment compares the selected nonzero target ID and lease generation under the same force-writer lock before any effects. Stale/absent targets conflict; same-current-target assignment is a no-op only after validation. Accepted serial TX is not recalled by transfer.
|
||||
|
||||
IDs have three slot bits and 29 generation bits; exhausted client slots retire until reboot rather than wrap. The separate 32-bit lease version saturates at UINT32_MAX, survives counter clear and advances on grant/release/revoke before advisory event delivery. Forced transfer can advance twice; it is not a count. Saturation blocks typed assignment but leaves ordinary request/release/disconnect and canonical recovery force available. Reboot invalidates old browser sessions.
|
||||
|
||||
Serial/Wi-Fi/Broker quick views reuse one nonmodal settings host and existing controllers/drafts, not parallel editors. Hover/focus/click/tap never mutate. Network quick mode excludes password controls. Full-page drafts are protected; promotion preserves controller/selection, departure fences reads/timers without cancelling admitted work. Dismissal restores the prior terminal without stealing unrelated focus; explicit Escape/Close returns focus to its trigger.
|
||||
|
||||
Broker contextual reads use one timer five seconds after successful completion and a five-second whole-read deadline, stopping on errors/uncertainty/departure. Explicit target/lease selection is not rebased by refresh. Sticky stale/absence latches require deliberate reselection even if later snapshots match again. Confirmation captures exact versions before session revalidation; guarded aria-disabled controls preserve focus during updates.
|
||||
|
||||
## Service lifecycle and identity rotation
|
||||
|
||||
Service generations are saturated, distinct from identity and session generations, and advance on admitted canonical lifecycle attempts including failures; counter clear/reinit cannot revive stale versions. Public zero-wait metadata authorizes nothing. Conditional controls compare under the canonical owner reservation; no snapshot-check/unconditional-mutate gap is allowed. Reboot invalidates originating logins and boot-local reservation IDs.
|
||||
|
||||
### HTTPS and reboot
|
||||
|
||||
GET `lifecycle` has seven fields: `generation`, `running`, `transitioning`, `controllable`, `identity_generation`, `fingerprint`, `rotatable`. Stored HTTPS fingerprint is 64 lowercase hex SHA-256 digits. Stop/restart/reboot require exactly action + service generation; rotate also requires identity generation. Unknown/duplicate/escaped/coerced fields and zero/saturated versions reject. Unavailable identity yields generation zero/empty fingerprint/not rotatable without removing ordinary service controls.
|
||||
|
||||
Self-cutting HTTP actions use **successful synchronous send return → one nonreused-ID HTTPD callback → existing dispatcher**. Send return is not browser receipt. The callback never waits or performs lifecycle work, and captures no request/fd/reusable operation pointer. A two-second ACK admission deadline and post-validation 30-second dequeue deadline are not execution bounds. Accepted-but-lost HTTPD work retains one reservation until callback arrival or successful HTTPD destruction; failed destruction cannot release it or accumulate more callbacks. The original login/current admin is revalidated before owner admission.
|
||||
|
||||
`web_server_stop_current()`/`web_server_restart_current()` compare and reserve under the server mutex. Restart retains transition ownership through stop/start; failed stop skips start, failed cleanup retains handles and canonical recovery, and reinit preserves the error. `web_server_reboot_current()` reserves the HTTPS generation then uses canonical `esp_restart()` outside locks, not an HTTPD stop wait or self-console cleanup.
|
||||
|
||||
`web_server_replace_identity(service_generation, identity_generation, reset, &committed)` reserves **service before identity**, before any crypto/storage. Both nonzero generations mean conditional healthy-running rotation; both zero retain CLI semantics; conditional Reset is invalid. Canonical CLI/browser-shell rotation and direct security rotate/reset share nonreused task-owner-bound identity reservation tokens. Only the owner can replace once/release; exhaustion fails closed until reboot. Crypto/NVS run outside service/security mutexes and spinlocks; commit precedes live publication and old-key wipe. Reservation remains held while reserved start copies the committed pair.
|
||||
|
||||
HTTPS ordering is **commit → stop → restart**. Precommit generation/RNG/storage failure leaves identity, HTTPD and logins unchanged (service version may advance after admission). Postcommit lifecycle failure never rolls back identity; failed stop can leave old served and new stored fingerprints different. Running replacement restarts HTTPS; canonical stopped rotation stays stopped, while CLI TLS-only reset can recover unavailable identity and starts a stopped service. Browser has no Reset/recovery/export action.
|
||||
|
||||
Save drafts; rotation/restart invalidates all web logins and closes both browser routes. Inspect `web certificate info` through trusted UART0, verify fingerprint before renewing trust, then reload/sign in freshly. Accepting a warning alone is not trusted verification. Use canonical UART0/admin SSH `web stop` / `web start` for retained-server recovery. Network/SSH/USB are not stopped by HTTPS-only operations; whole-device reboot interrupts all transports and loses unsaved RAM.
|
||||
|
||||
### SSH
|
||||
|
||||
GET `ssh` supplies service/session state plus identity generation, fixed P-256 algorithm, unpadded OpenSSH `SHA256:` base64 fingerprint and rotatable flag. Service actions use exactly `action`, `generation`, `target`; rotate adds `identity_generation` and requires target zero. Start/stop and exact-session disconnect use published state, saturated service generation and the command mutex; exhausted SSH session slots retire rather than wrap. Disconnect success is an owner close request, not completed teardown. HTTPD never calls wolfSSH or waits for the SSH task.
|
||||
|
||||
`ssh_transport_replace_identity()` compares/reserves **service then identity before stop or storage**, holding the existing command mutex across **stop → generate/commit/publish → conditional restart**. Canonical UART0/deferred admin-SSH rotation/reset and direct security mutations share the task-bound nonreused identity reservation. Crypto/NVS run outside security locks/spinlocks. Both nonzero versions select conditional rotation (also while stopped); both zero preserve canonical semantics, with no conditional Reset.
|
||||
|
||||
Failed stop/timeout skips mutation and never starts again; pending owner work is not cancelled. Persistence failure after successful stop may already have disconnected clients and attempts old-identity restart if previously running. Committed replacement remains committed if restart fails. Stopped rotation stays stopped; canonical reset can recover unavailable material/start stopped SSH. Only the SSH owner frees runtime context after all slots retire, before clearing cleanup admission; start rejects orphan handles. wolfSSH copies DER into its context; caller/candidate/superseded private bytes are wiped.
|
||||
|
||||
SSH changes leave invoking HTTPS available, so they use the ordinary ID-dispatcher/result path, not HTTPS's self-cutting ACK gate. UI confirms both versions and all-SSH/session scope, retains stale selection and manual 15-second/no-replay flow. A failed result may represent partial effects. Verify new trust through UART0 `ssh host-key info` before updating known_hosts; no HTTPS relogin is inherently required. Browser Reset/recovery/key export and user authorized-key changes are separate, excluded from host rotation.
|
||||
|
||||
## Diagnostics, recovery and scope boundaries
|
||||
|
||||
See [admission diagnostics](web_admission_diagnostics.md), [ordinary HTTPS idle cleanup](https_idle_cleanup.md), [throughput diagnostics](web_throughput_diagnostics.md) and [legacy storage compatibility](legacy_credential_removal.md). Broker read means transport handoff, not peer receipt; capture non-consuming counters before disconnect. TLS `-0x004C` is generic NET_RECV_FAILED, not evidence of OOM. Resource minima and counter observations require attribution, not inferred causes.
|
||||
|
||||
Phase 8D.15's dedicated typed network-diagnostics UI/API was removed: diagnostics remain shell-based, subject to frontend policy. The unimplemented 8D.19 ordinary browser-session/native-USB control expansion was removed; existing SSH controls remain. No full shell parity, browser identity recovery/reset/export, encryption, secure boot or OTA is implied by acceptance. UART0 is the administrative recovery authority; native USB is binary-transparent, network-independent UART1 access. Neither permits bypassing the broker's single writer or recalling already-admitted work.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Web administration acceptance
|
||||
|
||||
## Phase 8D.22 sign-off — 2026-09-13
|
||||
|
||||
The user explicitly accepted the tested firmware: **“Yep, I tested the firmware thats a 8d.22 signoff.”** Phase 8D is complete for its retained scope. This supersedes earlier per-slice target-pending, parent-review and integration-acceptance gates; it does not require additional testing to establish the user's sign-off. Phase 8A/B/C were already recorded as complete and target-hardware validated in the roadmap, so [Phase 8 is complete](roadmap.md#phase-8--role-based-users-and-administrative-access--complete).
|
||||
|
||||
Accepted functionality comprises cookie authentication, isolated browser serial/admin terminals, typed Serial/Accounts/authorized-key/Network/Display/Broker/SSH/HTTPS settings, contextual controls, confirmed generation-safe writer/service/identity actions and retained UART0/native-USB recovery. [Current contracts](web_administration.md) define actual permissions, bounded failures and partial effects; acceptance is not a claim of unrestricted browser-shell parity.
|
||||
|
||||
Scope decisions remain effective: 8D.15 dedicated typed network diagnostics was removed (shell diagnostics retained); the unimplemented 8D.19 ordinary browser-session/native-USB control remainder was removed (SSH controls retained). Browser identity Reset/recovery/export was not added. Phase 9 security/production hardening is next only on a separate user request.
|
||||
|
||||
## Latest firmware evidence
|
||||
|
||||
The **prior final firmware build passed**, reporting **100,556 bytes linked RAM / 1,828,573 bytes flash**, at **160 MHz**. This is recorded prior build evidence, not a build run during documentation consolidation. Host regression suites and independent implementation reviews were previously reported passing; no new host/runtime test pass is asserted here.
|
||||
|
||||
Latest user telemetry, bytes:
|
||||
|
||||
| Sample / memory capability | Free | Minimum free | Largest block |
|
||||
|---|---:|---:|---:|
|
||||
| Boot internal 8-bit | 59,808 | 58,840 | 31,744 |
|
||||
| Boot internal DMA | 52,052 | 51,084 | 31,744 |
|
||||
| Boot PSRAM | 8,196,968 | 8,183,972 | 8,126,464 |
|
||||
| Loaded after burst, internal 8-bit | 31,508 | 2,052 | 18,432 |
|
||||
| Loaded after burst, internal DMA | 23,752 | 460 | 18,432 |
|
||||
| Loaded after burst, PSRAM | 8,136,624 | 8,065,972 | 7,995,392 |
|
||||
|
||||
Loaded SSH minimum-free stack was **15,028 bytes**. The capture had two active SSH sessions across the serial/admin roles, two serial WebSockets and USB, with SSH holding the serial writer. Browser admin had been used and then closed; it was **not active in the captured loaded sample**. Web send/queue/protocol error counters were zero; SSH IO errors were zero, with one handshake failure and one session revocation retained without attributing a cause.
|
||||
|
||||
Latest broker/serial counters were not supplied, so these transport counters do **not** establish an exact latest zero-drop or byte-integrity result. No latest full-mix-with-browser-admin-active, individual fault-injection, exact duration, soak, cleanup-cycle or reserve-floor result is inferred. Generic SDK TLS `-0x004C` / NET_RECV_FAILED is not an OOM diagnosis. Two boot authentication failures could plausibly involve stale browser cookies, but that explanation is unconfirmed.
|
||||
|
||||
## Earlier acceptance retained without replaying the timeline
|
||||
|
||||
- M1 browser login/logout and M2 shared browser administration were explicitly accepted by the user; later Serial/account/Network presentation and legacy-credential cleanup also received scoped acceptance.
|
||||
- The user explicitly accepted the combined binary WebSocket-send fix at **160 MHz, 230400 baud with the full client mix, including browser admin**. That prior acceptance stands independently of the latest capture's closed browser-admin socket. It does not imply an unreported latest exact counter comparison or soak duration. Keep the combined send and bounded failed-send isolation, not the earlier frequency-only experiment.
|
||||
- Ordinary HTTPS idle cleanup was accepted as working; that is not a guarantee against all future admission failures or owner delays.
|
||||
|
||||
## Nonblocking follow-ups and evidence limits
|
||||
|
||||
The extremely low internal/DMA lifetime minima remain an unresolved transient-headroom follow-up, **not a blocker reopening 8D sign-off and not an approved reserve**. Capability pools overlap; summed per-region lifetime minima can be conservative/non-simultaneous and do not prove an allocation failure. Do not add internal and DMA numbers together or attribute an error to memory pressure without correlated evidence. HTTPD/dispatcher stack margins, peak correlation, allocation reserve policy and long-run cleanup/soak evidence remain distinct future measurements.
|
||||
|
||||
[Regression procedures](user_administration_tests.md#integrated-web-administration-regression-procedure) and focused test runners remain available for future changes; listing them is not evidence they all ran on hardware. Documentation-only consolidation changes no firmware, configuration, generated assets or test implementation and performs no build/upload/erase/commit. No new Phase 9 work is authorized by this acceptance.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Web admission diagnostics
|
||||
|
||||
Current default-disabled instrumentation, not an admission fix or an allocation-failure detector. `src/web_diagnostics.{c,h}` observes public synchronous HTTPS create/close callbacks and the four serial/admin ticket/upgrade handlers. It adds no owner task, queued probe or socket-capacity change. See [ordinary idle cleanup](https_idle_cleanup.md) for the independent cleanup policy and [acceptance evidence](web_administration_acceptance.md) for user reports.
|
||||
|
||||
## Usage and interpretation
|
||||
|
||||
Use UART0 (preferred during network stalls) or authenticated admin SSH:
|
||||
|
||||
```text
|
||||
web diagnostics clear
|
||||
web diagnostics enable
|
||||
web diagnostics show
|
||||
```
|
||||
|
||||
Enable before reproducing the failed third connection. At failure, run `web diagnostics show` promptly, alongside existing `memory`/`web counters`/browser Network timing evidence; repeat the snapshot if admission remains stalled. Then `web diagnostics disable` freezes event retention (live occupancy continues updating); `web diagnostics show` prints retained history, and `web diagnostics clear` erases history/counters without changing live sockets or enable state. The setting is RAM-only and defaults off after boot. Existing browser-shell policy deliberately denies these commands, even though shared completion offers the fixed forms. There is no HTTP diagnostic endpoint.
|
||||
|
||||
- Six connection records are maintained from boot even with capture disabled, so enabling on an already loaded server does not mislabel existing sockets. Each successful TLS connection gets a monotonically increasing, non-wrapping, firmware-lifetime 64-bit `conn` sequence independent of fd, cookie, ticket, broker or user identity. Clear, disable and HTTPS restart do not reset it. Internal TLS object identity is used only during synchronous cleanup; no pointer is exported or dereferenced by the console.
|
||||
- Snapshot output gives its boot-relative `snapshot_us`, post-TLS occupancy split ordinary/serial-WS/admin-WS and each live fd/connection sequence/open time/age. `kind=0/1/2` means ordinary/serial/admin. Ordinary includes every successful TLS socket not yet observed as upgraded, including idle keep-alives, assets/login/settings and sockets not yet used for HTTP. Classification is actual public `httpd_ws_get_fd_info()` state after upgrade-handler return, **not inferred from `ESP_OK`**. No URI string is inspected.
|
||||
- The fixed 32-entry overwrite ring records successful TLS open, TLS transport-context close, and enter/result for serial/admin ticket and upgrade handlers. `t_us` is boot-relative observation time; result `dt_us` measures the underlying handler only (including its work/IO, excluding entry resource sampling); close `dt_us` is successful-TLS connection lifetime. Open/enter durations are zero. Open/result occupancy includes the connection; close occupancy excludes it. Event IDs survive clear; `overwritten` counts evicted retained records since clear. `unmatched` counts duplicate create/unmatched close; `lost` counts untrackable creates (getter failure, metadata/sequence exhaustion). Nonzero anomaly counters mean occupancy is not trustworthy as complete evidence. Counters saturate.
|
||||
- `rc` is the exact handler return, **not HTTP status or ticket issuance outcome**: sending a 401/403/503 can return `ESP_OK`. Match browser HTTP status and existing rejection/issuance counters; there is no ticket-value correlation, request ID, authentication identity, header/body/query logging, or allocation-failure attribution. Upgrade success is visible in the occupancy classification. Ring overwrite or clear/toggle during an operation can leave unpaired enter/result records; do not invent a duration for a missing result.
|
||||
- Each retained event samples free/largest bytes for internal 8-bit, internal DMA and PSRAM 8-bit, plus the current HTTPD task's minimum-free stack **in ESP-IDF bytes**. Capability scans run outside the diagnostic lock. Samples are sequential, not an atomic heap snapshot; overlapping internal/DMA pools must not be added. Stack watermark includes diagnostic call overhead and is not dispatcher margin. `show` does not query a live task handle: resources are historical event samples, not fresh heap values at show time. Use `memory` for current system heap.
|
||||
- Console snapshots copy only local diagnostic metadata under a short portMUX, never inspect HTTPD session internals or wait for its owner. Printing occurs outside the lock. At most 32 ID-qualified rows and six live records are printed, even during churn; concurrent clear/overwrite is reported as “no longer retained”. Capture epochs reject samples crossing enable/disable/clear, and sequence checks prevent a stale upgrade result from reclassifying a reused fd. No queued diagnostic work exists, hence no outstanding probe or stale queue lifetime to retire on restart. Minimal connection bookkeeping and upgrade classification remain active while disabled; heap scans and event recording do not. Instrumentation still has CPU/static RAM cost, not zero perturbation.
|
||||
|
||||
## SDK audit and explicit blind spots
|
||||
|
||||
Audited installed PlatformIO ESP-IDF **5.5.0**, `components/esp_https_server/src/https_server.c` (`httpd_ssl_open`, `httpd_ssl_close`), `components/esp_http_server/src/httpd_sess.c` (`httpd_sess_delete`) and `httpd_main.c`. HTTPS performs synchronous TLS creation before the configured `open_fn` and `user_cb` create callback. It installs a transport-context destructor; that destructor invokes the public close callback before deleting TLS/freeing the context. HTTPD's default close closes the fd, then clears contexts, then frees its session slot. The diagnostic close therefore marks a cleanup observation, not a FIN timestamp or causal close reason. It uses the stored fd, not a getter on an already-closed socket. **Neither `open_fn` nor `close_fn` is replaced**, preserving all existing HTTPS cleanup ownership and failure behavior. Callbacks execute synchronously under the existing HTTPD lifecycle; successful stop finishes cleanup before restart, while failed/partial stop retains remaining live metadata. No asynchronous fd-only events are consumed, avoiding event-delay/fd-reuse ambiguity.
|
||||
|
||||
This deliberately bounded first slice does **not** measure TCP connect/accept/listen backlog, pending clients when IDF stops accepting at capacity, handshake begin/duration/failure, TLS allocation failure or aggregate lwIP socket pressure. The public configured open hook is post-TLS too; adding it would not fix these blind spots. No owner-queued client-list probe is added. Occupancy is an owner-published **successful-TLS lower bound**, not the complete HTTPD session table while a handshake is in progress. A clean **6/6** snapshot supports established-connection saturation at that instant (three WS + three ordinary is directly distinguishable); fewer than six does not exonerate admission/TLS/global socket pressure. Correlate time with browser evidence and existing secret-free TLS errors; do not call this pre-TLS tracing or claim the root cause is proven. Existing capacities, receive/send/handshake timeouts and accepted admission issue remain unchanged.
|
||||
|
||||
## Regression entry points and limits
|
||||
|
||||
`python3 tests/web_diagnostics/run.py` covers bounded metadata/ring behavior and SDK guards; `python3 tests/web_admin_transport/server_lifecycle.py` covers lifecycle composition. These are reusable host commands, not a new execution claim. Doubled TLS/scheduling and host tests cannot establish target admission latency, reserve floors or fault causes. Capture on UART0 alongside `memory`, `web counters`, browser timings and non-consuming broker/serial counters, without cookies/tickets/headers. Generic TLS NET_RECV_FAILED (`-0x004C`) and NET_CONN_RESET (`-0x0050`) are not OOM diagnoses.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Web serial throughput diagnostics
|
||||
|
||||
## Current contract and accepted fix
|
||||
|
||||
Default-disabled instrumentation measures broker fan-out and binary WebSocket TX, not CPU usage, peer receipt or pure scheduler latency. The user separately accepted the combined binary header/payload-send fix at **160 MHz / 230400 baud with full mix including browser admin**. [Acceptance evidence](web_administration_acceptance.md) distinguishes that report from the latest capture and unmeasured reserves. The old frequency-only experiment is not the retained solution.
|
||||
|
||||
`web_httpd_ws_send_binary` is an IDF-5.5.0-pinned owner-only session-override send with a bounded 516-byte header/payload scratch copy and 512-byte payload. Generation validation and one outstanding item per slot remain. A non-full send installs a reject-only override before close/shutdown, preventing SDK automatic PONG/CLOSE from reentering TLS with different arguments after incomplete output, even if shutdown fails. No retry/replay; HTTPD owns TLS destruction. Text/control/admin retain the SDK sender. One API call does not promise one TLS record/packet or peer receipt. Keep CPU160, priorities, scheduling and 4096-byte broker/512-byte web payload bounds unless separately changing them with evidence.
|
||||
|
||||
## Broker accounting
|
||||
|
||||
`broker counters` retains global totals and adds one row per active client: generation-safe ID, type, pending bytes, output high-water mark (HWM), UART bytes considered for that client, queued bytes, read bytes and dropped bytes. `broker clients` helps map the IDs to active transports; `web performance show` supplies each web slot's broker ID.
|
||||
|
||||
- HWM is peak output occupancy, bounded by 4,096 bytes. `broker clear-counters` clears totals but seeds each active HWM with current pending occupancy, not zero. It does not drain queues or change ownership.
|
||||
- `read` means handed to a transport, not received or rendered by a peer. Queued counts successful copies; UART counts bytes considered while the client was connected.
|
||||
- Disconnected per-client rows disappear and slot/generation reuse resets them. Global counters retain disconnected traffic until cleared, including accepted-but-unread output discarded on disconnect. Thus global drops need not mean only overflow, and queued plus dropped need not equal UART fan-out after disconnect discards.
|
||||
- Capture before disconnect. **Do not use `broker read` for observation:** it consumes data intended for that transport. Snapshot/counter commands do not consume serial data.
|
||||
|
||||
## Independent web performance capture
|
||||
|
||||
Use physical UART0 for the least intrusive capture. Commands are also available through the authenticated admin SSH registry. `web performance enable|disable|show|clear` is separate from admission tracing (`web diagnostics enable|disable|show|clear`) and from ordinary `web counters|clear-counters`.
|
||||
|
||||
Performance capture defaults disabled. Enable resumes aggregates; disable freezes aggregates; clear resets aggregates while preserving enable state. Each control operation advances a nonwrapping epoch and fences in-flight samples. Exhaustion fails closed. Slot generation and broker identity also fence reuse; this is not a disconnected-session archive.
|
||||
|
||||
Each active one of two fixed slots shows fd, generation, broker ID, pending/measured-pending/executing state and current-epoch `pending_age_us`. Age starts at reservation-path entry and includes an executing send. If `measured_pending=0`, zero age is unavailable, not evidence of immediate service. Disabling invalidates current-epoch pending age; capture a live `show` only if investigating a stuck queue, accepting its perturbation.
|
||||
|
||||
Only serial **binary TX** is measured. Text/control frames are not samples, though they can delay subsequent binary work. No payloads, passwords, keys, cookies, tickets, verifier material or other authentication secrets are retained or printed.
|
||||
|
||||
| Printed timing | Exact interpretation |
|
||||
|---|---|
|
||||
| `queue->callback-entry` | Reservation-path entry to HTTPD callback entry, timestamped before the callback takes the transport lock. Includes submission-path work, not just HTTPD queue residence; owned callbacks retired without sending can count. |
|
||||
| `send-call` | Time around the actual synchronous HTTPD-owner send API call. Return is not peer acknowledgement, browser receipt or rendering. |
|
||||
| `completion->first-drain-attempt-return` | Successful send-call completion to return of the first subsequent broker read attempt, including empty/error attempts. |
|
||||
| `completion->next-nonempty-drain-return (includes idle)` | Completion to the next successful nonempty broker read return, possibly after empty attempts and source idle time. |
|
||||
| `completion->first-attempt-nonempty-return` | Subset where the first subsequent read returns data. Excludes observed empty attempts, but does not prove backlog existed at send completion. |
|
||||
|
||||
Each timing reports count, sum in microseconds, integer estimated average (`sum/count`, zero for no samples) and maximum. Completion intervals are not scheduler-only: they include transport work, broker read/mutex time and intervening control-frame work, and may include idle time even in the first-attempt-nonempty subset. They do not isolate a particular task, TLS operation or network cause.
|
||||
|
||||
`queued_frames/bytes` counts transport reservations, including reported queue failures; `queue_errors` records those failures. `sent_frames/bytes` counts successful send-call returns. `send_errors` includes owner-context rejection, whereas send-call timings require an actual API call. `retired` records measured callbacks retired without sending. Saturating aggregates set `saturated=1`; totals and averages then cease to be reliable. Epoch fencing intentionally prevents old work contaminating a new capture, so do not demand equality across toggles or in-flight clears.
|
||||
|
||||
## Reproducible UART0 capture
|
||||
|
||||
1. Record firmware/build identity, baud/framing/flow control, source burst size, browser versions, client IDs/roles and full-mix topology. Establish all serial clients and both admin routes first. Quiet the UART source and allow queues and connection activity to settle. Do not reconnect/login during the trace.
|
||||
2. Optionally stop independent admission tracing before the timed capture. Then reset in this order, while quiet:
|
||||
|
||||
```text
|
||||
web diagnostics disable
|
||||
web performance clear
|
||||
web performance enable
|
||||
broker clear-counters
|
||||
serial clear-counters
|
||||
web clear-counters
|
||||
```
|
||||
|
||||
The first command is optional; record whether admission tracing was enabled. Resets are sequential, not an atomic cross-service snapshot.
|
||||
3. Send the known burst once, stop the source and wait for transport queues to drain. Avoid repeated console/status printing during the burst. A quiet final broker pending count of zero alone does not prove browser receipt; retain peer byte counts too.
|
||||
4. Stop performance capture first, then capture all counters **before disconnecting any client**:
|
||||
|
||||
```text
|
||||
web performance disable
|
||||
web performance show
|
||||
broker counters
|
||||
serial counters
|
||||
web counters
|
||||
broker clients
|
||||
```
|
||||
|
||||
Disable already prints the performance snapshot; `show` is an explicit repeatable frozen-aggregate record. Keep connection counts and source/peer totals with the sample.
|
||||
5. Only after saving evidence, change to one browser, keeping the other full-mix participants and serial settings equivalent. Quiet/settle and repeat the complete reset/burst/drain/stop/capture sequence. Do not compare a fresh capture to lifetime counters.
|
||||
6. Separately compare equivalent performance-enabled and performance-disabled bursts with fresh ordinary counter resets. Keep performance disabled for the latter; do not treat retained performance aggregates as that run's measurements. Keep admission tracing state equal and record it.
|
||||
|
||||
At 230400 baud, 8N1, 4,096 bytes represents approximately **177.8 ms** of continuous input retention from an empty broker queue, ignoring flow-control pauses. Compare per-client drops/HWM with latency maxima against this scale, not as a hard deadline or proof of cause; occupancy, idle gaps and timings cover different boundaries. For future regressions, gather per-client attribution and controlled comparisons before changing buffers, priorities, scheduling or CPU.
|
||||
|
||||
## Resources and regression references
|
||||
|
||||
Two fixed diagnostic records plus nonwrapping epoch/timestamp/slot fences and broker HWM fields add bounded storage, no new instrumentation allocations/tasks/queues/payloads. Disabled is not zero overhead: gate branches remain and broker occupancy/HWM is always active. Enabled timing and short aggregate locks have runtime costs not established by host tests. The combined-send local scratch adds HTTPD stack use; actual reserve remains a target measurement.
|
||||
|
||||
`tests/session_broker_diagnostics/run.py` covers counters and generation-safe management; `tests/web_serial_performance/run.py` covers production send/drain paths, epochs, retirement, SDK wire compatibility and failed-send/control-reply isolation. `tests/admin_ssh_policy/run.py` covers SSH-allowed/browser-denied tracing commands. Prior focused/broad host validation and firmware builds passed; no tests were run during documentation consolidation. These harnesses do not prove real TLS/scheduler overhead, peer-byte integrity or long-soak behavior. Use the capture procedure above for future attribution, retaining explicit workload/duration/counter provenance.
|
||||
+32
-1
@@ -100,9 +100,40 @@ The USB-to-UART bridge's DTR/RTS controls serve automatic boot/reset and do not
|
||||
|
||||
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 safe loopback wiring, polarity checks, UART flow-control verification, and session-broker loopback testing.
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Import("env")
|
||||
|
||||
env.Replace(COMPILATIONDB_INCLUDE_TOOLCHAIN=True)
|
||||
@@ -13,3 +13,5 @@ board_build.partitions = partitions.csv
|
||||
|
||||
monitor_speed = 115200
|
||||
monitor_filters = esp32_exception_decoder
|
||||
|
||||
extra_scripts = pre:extra_script.py
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# ESP32-S3-WROOM-1-N16R8 hardware configuration
|
||||
# Single-variable web throughput experiment; target validation pending.
|
||||
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
|
||||
CONFIG_SPIRAM=y
|
||||
CONFIG_SPIRAM_MODE_OCT=y
|
||||
@@ -49,6 +51,20 @@ 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,10 +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"
|
||||
@@ -14,24 +21,48 @@ idf_component_register(
|
||||
"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_console.c"
|
||||
"web_security.c"
|
||||
"web_serial_transport.c"
|
||||
"web_serial_settings.c"
|
||||
"web_account_settings.c"
|
||||
"web_network_settings.c"
|
||||
"web_display_settings.c"
|
||||
"web_broker_settings.c"
|
||||
"web_ssh_settings.c"
|
||||
"web_lifecycle_settings.c"
|
||||
"web_admin_tickets.c"
|
||||
"web_admin_transport.c"
|
||||
"web_assets_data.c"
|
||||
"web_ui.c"
|
||||
"web_server.c"
|
||||
"web_diagnostics.c"
|
||||
"web_session_store.c"
|
||||
"web_auth_parse.c"
|
||||
"web_httpd_adapter.c"
|
||||
"web_httpd_idle.c"
|
||||
"web_cookie_auth.c"
|
||||
"web_login_ui.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
|
||||
@@ -46,11 +77,22 @@ idf_component_register(
|
||||
led_strip
|
||||
lwip
|
||||
mbedtls
|
||||
mdns
|
||||
nvs_flash
|
||||
wolfssl__wolfssh
|
||||
wolfssl__wolfssl
|
||||
)
|
||||
|
||||
# Only web_httpd_adapter.c uses this private, version-checked boundary.
|
||||
target_include_directories(${COMPONENT_LIB} PRIVATE
|
||||
"$ENV{IDF_PATH}/components/esp_http_server/src"
|
||||
"$ENV{IDF_PATH}/components/esp_http_server/src/port/esp32")
|
||||
|
||||
# HTTPD debug logs include header values; URI warnings include ticket queries.
|
||||
# Compile those out, independently of runtime log-level changes.
|
||||
idf_component_get_property(httpd_lib esp_http_server COMPONENT_LIB)
|
||||
target_compile_definitions(${httpd_lib} PRIVATE LOG_LOCAL_LEVEL=ESP_LOG_ERROR)
|
||||
|
||||
# Public wolfSSH headers include wolfCrypt configuration from user_settings.h.
|
||||
target_compile_definitions(${COMPONENT_LIB} PRIVATE
|
||||
WOLFSSL_USER_SETTINGS
|
||||
|
||||
@@ -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,169 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Bounded administrative dispatcher with a small remote-owner boundary. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "user_database.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* Nonblocking typed settings admission to the canonical dispatcher. */
|
||||
esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_lifecycle_settings(uint32_t id);
|
||||
|
||||
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
|
||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||
|
||||
#define ADMIN_CONSOLE_TRANSPORT_SSH 0U
|
||||
#define ADMIN_CONSOLE_TRANSPORT_WEB 1U
|
||||
|
||||
typedef struct {
|
||||
uint8_t slot_index;
|
||||
uint32_t session_id;
|
||||
uint32_t slot_generation;
|
||||
uint8_t transport; /* Zero is SSH, including legacy designated initializers. */
|
||||
} admin_ssh_console_token_t;
|
||||
|
||||
typedef enum {
|
||||
ADMIN_SSH_DEFER_NONE = 0,
|
||||
ADMIN_SSH_DEFER_REBOOT,
|
||||
ADMIN_SSH_DEFER_STOP,
|
||||
ADMIN_SSH_DEFER_DISCONNECT,
|
||||
ADMIN_SSH_DEFER_HOST_KEY_ROTATE,
|
||||
ADMIN_SSH_DEFER_HOST_KEY_RESET,
|
||||
ADMIN_CONSOLE_DEFER_SELF_CLOSE,
|
||||
ADMIN_CONSOLE_DEFER_WEB_STOP,
|
||||
ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
|
||||
} admin_ssh_deferred_action_type_t;
|
||||
|
||||
/* Small owner boundary; module/API names are retained for existing SSH callers.
|
||||
* Exactly two shared console slots, not two per transport. slot_index addresses
|
||||
* this pool; open_available atomically selects a free slot. Owners must not reuse an identity while
|
||||
* old work can exist. transport is a firmware-assigned namespace (0 = SSH).
|
||||
* An occupied or still-executing slot cannot be replaced by open_owned().
|
||||
*
|
||||
* The immutable adapter lives for firmware lifetime. Callbacks run on the
|
||||
* control task OUTSIDE console locks for drained/perform, except perform actions
|
||||
* selected by dispatcher_actions run serialized on the existing 12KiB dispatcher
|
||||
* after drain/delay and queued identity/principal revalidation (no command replay).
|
||||
* Zero dispatcher_actions preserves legacy control-task execution. Required is_current
|
||||
* runs on the dispatcher outside console locks; it must be bounded and validate
|
||||
* full transport identity, originating-session liveness and principal binding,
|
||||
* without calling socket libraries or handlers. Core separately checks accounts.
|
||||
* drained must be nonblocking, validate the full identity and include pending
|
||||
* owner output. perform must revalidate identity and marshal lifecycle work to
|
||||
* its owner, never call socket libraries here. Neither callback may call console
|
||||
* handlers. supported_actions is a bitmask (1U << action); reject unsupported
|
||||
* actions before side effects. Legacy STOP/DISCONNECT/key actions mean SSH;
|
||||
* SELF_CLOSE means this frontend; WEB_STOP means HTTPS, not SSH.
|
||||
* WEB_CERTIFICATE_ROTATE replaces the HTTPS identity and restarts HTTPS.
|
||||
* These WEB actions and SELF_CLOSE ignore argument.
|
||||
*
|
||||
* One owner serializes feed calls per session; different owners may feed in
|
||||
* parallel. Shared completion scratch is nonblocking/serialized by the core.
|
||||
* The owner alone consumes output, maintains authentication/session liveness,
|
||||
* and calls close on disconnect/revocation. Core copies/rechecks principals at
|
||||
* admission and dispatch. Dispatch and prompts also check owner currentness;
|
||||
* blocked prompts recheck every 250ms (plus check/scheduling latency). This does
|
||||
* not cancel or roll back arbitrary executing handlers. Admission remains the
|
||||
* owner's responsibility; is_current need not accept unpublished admission.
|
||||
* Close wakes prompts; executing state is retained until the handler returns.
|
||||
* Output remains bounded (5s write backpressure); deferred work waits at most
|
||||
* 10s for application drain plus 200ms, NOT peer-delivery confirmation.
|
||||
* Dispatcher actions then wait behind queued commands/prompts, with input gated
|
||||
* until completion or cancellation; the drain bound is not an execution deadline.
|
||||
* No new tasks, queues, slots, or browser endpoint are provided by this API.
|
||||
*/
|
||||
typedef struct {
|
||||
uint32_t supported_actions;
|
||||
uint32_t dispatcher_actions; /* Subset of supported_actions; immutable. */
|
||||
bool (*is_current)(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal);
|
||||
bool (*drained)(const admin_ssh_console_token_t *token);
|
||||
esp_err_t (*perform)(const admin_ssh_console_token_t *token,
|
||||
admin_ssh_deferred_action_type_t action, uint32_t argument);
|
||||
} admin_console_owner_t;
|
||||
|
||||
/* Selects any inactive, nonexecuting slot from the shared two-slot pool.
|
||||
* Input slot_index is ignored; only slot_index changes, and only on success.
|
||||
* Caller supplies transport/session_id/slot_generation and must retain the
|
||||
* returned token. Full pool returns ESP_ERR_INVALID_STATE, like open_owned.
|
||||
*/
|
||||
esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_console_owner_t *owner);
|
||||
|
||||
esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal,
|
||||
const admin_console_owner_t *owner);
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
bool command_pending;
|
||||
bool input_pending;
|
||||
bool output_pending;
|
||||
bool deferred_action_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);
|
||||
|
||||
/* Valid only while a registered command callback runs on the dispatcher task. */
|
||||
bool admin_ssh_console_dispatch_is_remote(void);
|
||||
bool admin_ssh_console_dispatch_is_web(void);
|
||||
const user_principal_t *admin_ssh_console_dispatch_principal(void);
|
||||
/* Revalidate account, originating owner/session and token before side effects.
|
||||
* False outside the dispatcher; UART0 dispatch remains physically trusted. */
|
||||
bool admin_ssh_console_dispatch_is_current(void);
|
||||
/* Shared parsed browser account policy: dispatcher admission + handler defense. */
|
||||
bool admin_ssh_console_web_user_command_allowed(
|
||||
size_t argc, char **argv, const user_principal_t *principal);
|
||||
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);
|
||||
|
||||
/* SSH compatibility entry point, implemented by the owner in ssh_transport.c.
|
||||
* Token/principal are copied; no SSH or socket objects cross this boundary.
|
||||
* Existing feed/close/read/snapshot APIs below also accept open_owned tokens.
|
||||
*/
|
||||
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal);
|
||||
void admin_ssh_console_close(const admin_ssh_console_token_t *token);
|
||||
|
||||
/* Called by the session owner. Returns false when input must be backpressured. */
|
||||
bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token);
|
||||
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
|
||||
const uint8_t *data, size_t length,
|
||||
size_t *consumed);
|
||||
|
||||
/* Called by the session owner; copies already-produced output without blocking. */
|
||||
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
|
||||
uint8_t *data, size_t capacity,
|
||||
size_t *received);
|
||||
esp_err_t admin_ssh_console_get_session_snapshot(
|
||||
const admin_ssh_console_token_t *token,
|
||||
admin_ssh_console_session_snapshot_t *snapshot);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -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. */
|
||||
|
||||
+206
-20
@@ -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,19 @@ 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 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 +143,7 @@ static const char *const s_completion_candidates[] = {
|
||||
"wifi start",
|
||||
"wifi stop",
|
||||
"wifi reconnect",
|
||||
"wifi next-profile",
|
||||
"wifi save",
|
||||
"wifi load",
|
||||
"wifi defaults",
|
||||
@@ -109,6 +167,14 @@ static const char *const s_completion_candidates[] = {
|
||||
"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",
|
||||
@@ -116,10 +182,14 @@ static const char *const s_completion_candidates[] = {
|
||||
"web stop",
|
||||
"web counters",
|
||||
"web clear-counters",
|
||||
"web credentials",
|
||||
"web credentials show",
|
||||
"web credentials rotate",
|
||||
"web credentials rotate --force",
|
||||
"web diagnostics enable",
|
||||
"web diagnostics disable",
|
||||
"web diagnostics show",
|
||||
"web diagnostics clear",
|
||||
"web performance enable",
|
||||
"web performance disable",
|
||||
"web performance show",
|
||||
"web performance clear",
|
||||
"web certificate",
|
||||
"web certificate info",
|
||||
"web certificate rotate",
|
||||
@@ -144,6 +214,115 @@ static const char *const s_completion_candidates[] = {
|
||||
"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)
|
||||
@@ -160,27 +339,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,6 +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,45 @@
|
||||
/* 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);
|
||||
|
||||
typedef enum {
|
||||
LOCAL_UI_SETTINGS_APPLY, LOCAL_UI_SETTINGS_SAVE, LOCAL_UI_SETTINGS_LOAD,
|
||||
LOCAL_UI_SETTINGS_DEFAULTS, LOCAL_UI_SETTINGS_RESET
|
||||
} local_ui_settings_action_t;
|
||||
|
||||
/* Zero-wait RAM projection. Generation is nonzero and never wraps. */
|
||||
esp_err_t local_status_ui_get_settings(local_ui_config_t *config, uint32_t *generation);
|
||||
/* Reserve configuration across storage IO, without holding a critical section.
|
||||
* Zero expected_generation is for canonical unconditional CLI operations only.
|
||||
* Nonzero stale generations return ESP_ERR_INVALID_STATE; contention returns
|
||||
* ESP_ERR_TIMEOUT. Load retains the canonical default fallback. Reset commits
|
||||
* defaults before publishing RAM, so a storage failure needs no RAM rollback.
|
||||
* No display IO occurs here; successful RAM changes signal renderer activity. */
|
||||
esp_err_t local_status_ui_update_settings(local_ui_settings_action_t action,
|
||||
uint32_t expected_generation, const local_ui_config_t *config, bool *loaded_defaults);
|
||||
|
||||
/* 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,187 @@
|
||||
/* 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 "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;
|
||||
uint32_t generation;
|
||||
esp_err_t error = local_status_ui_get_settings(&config, &generation);
|
||||
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_update_settings(LOCAL_UI_SETTINGS_APPLY, generation, &config, NULL);
|
||||
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(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) {
|
||||
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_SAVE, 0, NULL, NULL);
|
||||
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 loaded_defaults = false;
|
||||
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_LOAD, 0, NULL, &loaded_defaults);
|
||||
if (error == ESP_OK) error = local_status_ui_get_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",
|
||||
loaded_defaults ? "default" : "stored");
|
||||
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 defaults;
|
||||
local_ui_config_defaults(&defaults);
|
||||
esp_err_t error = local_status_ui_update_settings(LOCAL_UI_SETTINGS_RESET, 0, NULL, NULL);
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
+109
-9
@@ -1,10 +1,20 @@
|
||||
#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"
|
||||
@@ -20,6 +30,8 @@
|
||||
#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"
|
||||
@@ -35,7 +47,6 @@ static const char *TAG = "firmware";
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "ESP32-S3 Serial Swiss Army Knife SSH transport phase started");
|
||||
|
||||
if (esp_psram_is_initialized()) {
|
||||
ESP_LOGI(TAG, "PSRAM initialized: %u bytes", (unsigned int)esp_psram_get_size());
|
||||
@@ -54,6 +65,44 @@ void app_main(void)
|
||||
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;
|
||||
@@ -81,8 +130,24 @@ void app_main(void)
|
||||
"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 administrative credential",
|
||||
web_security_source == WEB_SECURITY_LOAD_STORED ? "stored" : "newly generated");
|
||||
ESP_LOGI(TAG, "Using %s HTTPS identity",
|
||||
web_security_source == WEB_SECURITY_LOAD_STORED
|
||||
? "stored"
|
||||
: (web_security_source == WEB_SECURITY_LOAD_MIGRATED_V1
|
||||
? "migrated v1"
|
||||
: "newly generated"));
|
||||
}
|
||||
|
||||
user_database_load_result_t user_database_source = USER_DATABASE_LOAD_EMPTY;
|
||||
esp_err_t user_database_error = user_database_init(&user_database_source);
|
||||
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"
|
||||
: "new empty");
|
||||
}
|
||||
|
||||
esp_err_t web_runtime_error = web_server_init();
|
||||
@@ -136,6 +201,23 @@ 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) {
|
||||
wifi_error = wifi_manager_init(&wifi_config);
|
||||
@@ -168,8 +250,8 @@ void app_main(void)
|
||||
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) {
|
||||
if (wifi_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",
|
||||
@@ -180,6 +262,16 @@ void app_main(void)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -187,8 +279,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
|
||||
@@ -205,18 +298,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,208 @@
|
||||
/* 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 uint32_t s_config_generation;
|
||||
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_config_generation = 1;
|
||||
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();
|
||||
if (s_config_generation == UINT32_MAX) { unlock_service(); return ESP_ERR_INVALID_STATE; }
|
||||
s_config = *config;
|
||||
++s_config_generation;
|
||||
unlock_service();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void snapshot_locked(mdns_service_snapshot_t *snapshot)
|
||||
{
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
snapshot->config_generation = s_config_generation;
|
||||
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;
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_get_snapshot(mdns_service_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot || !s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
lock_service();
|
||||
snapshot_locked(snapshot);
|
||||
unlock_service();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot) return ESP_ERR_INVALID_ARG;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
snapshot_locked(snapshot);
|
||||
unlock_service();
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
|
||||
const mdns_config_t *config, bool *stored)
|
||||
{
|
||||
if (!stored || action > MDNS_SETTINGS_DEFAULTS || action < MDNS_SETTINGS_SET ||
|
||||
(action == MDNS_SETTINGS_SET && mdns_config_validate(config) != ESP_OK)) return ESP_ERR_INVALID_ARG;
|
||||
*stored = true;
|
||||
if (!s_mutex) return ESP_ERR_INVALID_STATE;
|
||||
lock_service();
|
||||
if (!generation || generation != s_config_generation) { unlock_service(); return ESP_ERR_NOT_FOUND; }
|
||||
esp_err_t error = ESP_OK;
|
||||
mdns_config_t candidate = s_config;
|
||||
if (action == MDNS_SETTINGS_SAVE) error = mdns_config_save(&s_config);
|
||||
else if (s_config_generation == UINT32_MAX) error = ESP_ERR_INVALID_STATE;
|
||||
else {
|
||||
if (action == MDNS_SETTINGS_SET) candidate = *config;
|
||||
else if (action == MDNS_SETTINGS_LOAD) error = mdns_config_load(&candidate, stored);
|
||||
else mdns_config_defaults(&candidate);
|
||||
if (error == ESP_OK) error = mdns_config_validate(&candidate);
|
||||
if (error == ESP_OK) { s_config = candidate; ++s_config_generation; }
|
||||
}
|
||||
unlock_service();
|
||||
return error;
|
||||
}
|
||||
|
||||
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();
|
||||
/* A suffix staged while offline must reach the already-created responder
|
||||
* when the next STA IP arrives, even if its reannounce command ran offline. */
|
||||
return mdns_service_reannounce();
|
||||
}
|
||||
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,38 @@
|
||||
/* 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 {
|
||||
uint32_t config_generation;
|
||||
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);
|
||||
|
||||
/* Zero-wait secret-free projection for HTTPD; ESP_ERR_TIMEOUT on contention. */
|
||||
esp_err_t mdns_service_get_settings(mdns_service_snapshot_t *snapshot);
|
||||
typedef enum { MDNS_SETTINGS_SET, MDNS_SETTINGS_SAVE, MDNS_SETTINGS_LOAD,
|
||||
MDNS_SETTINGS_DEFAULTS } mdns_settings_action_t;
|
||||
/* Dispatcher-only. Check generation and mutate/persist under the service mutex.
|
||||
* ESP_ERR_NOT_FOUND is stale. LOAD may select deterministic MAC defaults (stored
|
||||
* reports that distinction). Caller separately queues manager reannouncement. */
|
||||
esp_err_t mdns_service_update_current(uint32_t generation, mdns_settings_action_t action,
|
||||
const mdns_config_t *config, bool *stored);
|
||||
|
||||
/* 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);
|
||||
|
||||
+43
-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;
|
||||
}
|
||||
|
||||
@@ -619,6 +646,18 @@ esp_err_t serial_service_get_config(serial_config_t *config)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t serial_service_get_snapshot(serial_service_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) return ESP_ERR_INVALID_ARG;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
if (!s_initialized) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_state_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
snapshot->config = s_config;
|
||||
snapshot->running = atomic_load(&s_running);
|
||||
xSemaphoreGive(s_state_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
size_t serial_service_read(uint8_t *data, size_t size)
|
||||
{
|
||||
if (!s_initialized || data == NULL || size == 0) {
|
||||
|
||||
@@ -48,6 +48,14 @@ bool serial_service_is_running(void);
|
||||
esp_err_t serial_service_apply_config(const serial_config_t *config);
|
||||
esp_err_t serial_service_get_config(serial_config_t *config);
|
||||
|
||||
typedef struct {
|
||||
serial_config_t config;
|
||||
bool running;
|
||||
} serial_service_snapshot_t;
|
||||
|
||||
/* Nonblocking, consistent working configuration/state; no hardware or NVS IO. */
|
||||
esp_err_t serial_service_get_snapshot(serial_service_snapshot_t *snapshot);
|
||||
|
||||
/*
|
||||
* Access is intentionally nonblocking. The session broker is the sole
|
||||
* logical RX consumer and TX producer; calls are serialized internally to
|
||||
|
||||
+90
-5
@@ -47,6 +47,8 @@ static session_broker_slot_t s_slots[SESSION_BROKER_MAX_CLIENTS];
|
||||
static session_broker_client_id_t s_writer_id;
|
||||
static uint32_t s_connected_clients;
|
||||
static uint64_t s_event_sequence;
|
||||
/* Saturation disables management confirmations, never ordinary recovery. */
|
||||
static uint32_t s_writer_generation = 1U;
|
||||
static session_broker_global_counters_t s_counters;
|
||||
static bool s_initialized;
|
||||
|
||||
@@ -102,6 +104,10 @@ static void broadcast_event_locked(session_broker_event_type_t type,
|
||||
session_broker_client_id_t client_id,
|
||||
session_broker_client_id_t writer_id)
|
||||
{
|
||||
if ((type == SESSION_BROKER_EVENT_WRITER_GRANTED ||
|
||||
type == SESSION_BROKER_EVENT_WRITER_RELEASED ||
|
||||
type == SESSION_BROKER_EVENT_WRITER_REVOKED) && s_writer_generation != UINT32_MAX)
|
||||
++s_writer_generation;
|
||||
session_broker_event_t event = {
|
||||
.sequence = ++s_event_sequence,
|
||||
.type = type,
|
||||
@@ -149,6 +155,10 @@ static void fan_out_rx_locked(const uint8_t *data, size_t size)
|
||||
|
||||
slot->counters.uart_rx_bytes += size;
|
||||
size_t queued = xStreamBufferSend(slot->output, data, size, 0);
|
||||
size_t pending = xStreamBufferBytesAvailable(slot->output);
|
||||
if (pending > slot->counters.output_high_water_bytes) {
|
||||
slot->counters.output_high_water_bytes = pending;
|
||||
}
|
||||
size_t dropped = size - queued;
|
||||
slot->counters.output_queued_bytes += queued;
|
||||
slot->counters.output_dropped_bytes += dropped;
|
||||
@@ -295,7 +305,8 @@ esp_err_t session_broker_connect(session_broker_client_type_t type,
|
||||
session_broker_slot_t *slot = NULL;
|
||||
size_t slot_index = 0U;
|
||||
for (; slot_index < SESSION_BROKER_MAX_CLIENTS; ++slot_index) {
|
||||
if (!s_slots[slot_index].connected) {
|
||||
if (!s_slots[slot_index].connected &&
|
||||
s_slots[slot_index].generation < SESSION_BROKER_MAX_GENERATION) {
|
||||
slot = &s_slots[slot_index];
|
||||
break;
|
||||
}
|
||||
@@ -315,9 +326,7 @@ esp_err_t session_broker_connect(session_broker_client_type_t type,
|
||||
}
|
||||
|
||||
uint32_t generation = slot->generation + 1U;
|
||||
if (generation == 0U || generation > SESSION_BROKER_MAX_GENERATION) {
|
||||
generation = 1U;
|
||||
}
|
||||
/* Exhausted slots are retired until reboot: no 29-bit ID reuse. */
|
||||
|
||||
xStreamBufferReset(slot->output);
|
||||
xQueueReset(slot->events);
|
||||
@@ -466,13 +475,19 @@ esp_err_t session_broker_release_writer(session_broker_client_id_t client_id)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
static esp_err_t broker_force_writer(session_broker_client_id_t client_id,
|
||||
uint32_t expected_generation)
|
||||
{
|
||||
if (!s_initialized) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
if (expected_generation && (expected_generation == UINT32_MAX ||
|
||||
expected_generation != s_writer_generation)) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
session_broker_slot_t *new_writer = NULL;
|
||||
if (client_id != SESSION_BROKER_NO_CLIENT) {
|
||||
new_writer = find_slot_locked(client_id);
|
||||
@@ -518,6 +533,73 @@ esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
{
|
||||
return broker_force_writer(client_id, 0);
|
||||
}
|
||||
|
||||
esp_err_t session_broker_assign_writer_current(session_broker_client_id_t client_id,
|
||||
uint32_t generation)
|
||||
{
|
||||
if (!client_id || !generation) return ESP_ERR_INVALID_ARG;
|
||||
return broker_force_writer(client_id, generation);
|
||||
}
|
||||
|
||||
esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot) return ESP_ERR_INVALID_ARG;
|
||||
if (!s_initialized) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
snapshot->generation = s_writer_generation;
|
||||
snapshot->writer_id = s_writer_id;
|
||||
for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) {
|
||||
const session_broker_slot_t *slot = &s_slots[i];
|
||||
if (!slot->connected) continue;
|
||||
session_broker_management_client_t *client = &snapshot->clients[snapshot->count++];
|
||||
client->id = slot->id;
|
||||
client->type = slot->type;
|
||||
memcpy(client->name, slot->name, sizeof(client->name));
|
||||
client->pending = xStreamBufferBytesAvailable(slot->output);
|
||||
client->high_water = slot->counters.output_high_water_bytes;
|
||||
client->dropped = slot->counters.output_dropped_bytes;
|
||||
}
|
||||
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) {
|
||||
@@ -708,6 +790,8 @@ esp_err_t session_broker_clear_counters(void)
|
||||
memset(&s_counters, 0, sizeof(s_counters));
|
||||
for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) {
|
||||
memset(&s_slots[i].counters, 0, sizeof(s_slots[i].counters));
|
||||
s_slots[i].counters.output_high_water_bytes =
|
||||
xStreamBufferBytesAvailable(s_slots[i].output);
|
||||
}
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
@@ -726,6 +810,7 @@ esp_err_t session_broker_clear_client_counters(session_broker_client_id_t client
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
memset(&slot->counters, 0, sizeof(slot->counters));
|
||||
slot->counters.output_high_water_bytes = xStreamBufferBytesAvailable(slot->output);
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
+41
-2
@@ -49,6 +49,11 @@ typedef struct {
|
||||
session_broker_client_id_t writer_id;
|
||||
} session_broker_event_t;
|
||||
|
||||
/*
|
||||
* Active-client counters cover this connection (or the last counter clear).
|
||||
* They become unavailable on disconnect and reset on slot/generation reuse;
|
||||
* global totals retain disconnected clients' traffic until explicitly cleared.
|
||||
*/
|
||||
typedef struct {
|
||||
/* UART bytes considered for delivery while this client was connected. */
|
||||
uint64_t uart_rx_bytes;
|
||||
@@ -67,13 +72,18 @@ typedef struct {
|
||||
uint64_t events_queued;
|
||||
uint64_t events_popped;
|
||||
uint64_t event_drops;
|
||||
/* Peak output occupancy, <= SESSION_BROKER_OUTPUT_SIZE; clear seeds pending. */
|
||||
size_t output_high_water_bytes;
|
||||
} session_broker_client_counters_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t uart_rx_bytes;
|
||||
/* UART RX drained when there were no connected observers. */
|
||||
uint64_t unobserved_rx_bytes;
|
||||
/* Queue/read/drop totals count one copy per client observer. */
|
||||
/* Queue/read/drop totals count one copy per client observer.
|
||||
* Drops include full-buffer losses and unread output discarded on disconnect.
|
||||
* Read means handed to a transport, not confirmed delivery to its peer.
|
||||
*/
|
||||
uint64_t output_queued_bytes;
|
||||
uint64_t output_read_bytes;
|
||||
uint64_t output_dropped_bytes;
|
||||
@@ -110,6 +120,31 @@ typedef struct {
|
||||
session_broker_global_counters_t counters;
|
||||
} session_broker_global_snapshot_t;
|
||||
|
||||
/* Compact, atomic, non-consuming management projection. No transport pointers. */
|
||||
typedef struct {
|
||||
session_broker_client_id_t id;
|
||||
session_broker_client_type_t type;
|
||||
char name[SESSION_BROKER_CLIENT_NAME_MAX + 1U];
|
||||
size_t pending, high_water;
|
||||
uint64_t dropped;
|
||||
} session_broker_management_client_t;
|
||||
typedef struct {
|
||||
uint32_t generation;
|
||||
session_broker_client_id_t writer_id;
|
||||
size_t count;
|
||||
session_broker_management_client_t clients[SESSION_BROKER_MAX_CLIENTS];
|
||||
} session_broker_management_snapshot_t;
|
||||
|
||||
/* Zero-wait atomic snapshot. Generation survives counter clears; UINT32_MAX
|
||||
* means confirmations exhausted until reboot. Every lease transition advances
|
||||
* it, including release/reacquire ABA. Client IDs never wrap within a boot. */
|
||||
esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *snapshot);
|
||||
/* Nonzero target and generation required; compare + target validation + transfer
|
||||
* share the broker lock. Stale/exhausted generation or absent target has no effects.
|
||||
* Existing unconditional force remains available to recovery/console callers. */
|
||||
esp_err_t session_broker_assign_writer_current(session_broker_client_id_t client_id,
|
||||
uint32_t generation);
|
||||
|
||||
/*
|
||||
* Allocates all eight output streams and event queues, then starts the
|
||||
* permanent broker task. The serial service must already be initialized
|
||||
@@ -130,6 +165,8 @@ 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);
|
||||
/* 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);
|
||||
|
||||
/*
|
||||
@@ -156,7 +193,9 @@ esp_err_t session_broker_get_global_snapshot(session_broker_global_snapshot_t *s
|
||||
size_t session_broker_list_clients(session_broker_client_snapshot_t *clients,
|
||||
size_t capacity);
|
||||
|
||||
/* Counter clearing does not reset client IDs, queued data, or event sequence. */
|
||||
/* Counter clearing does not reset client IDs, queued data, or event sequence.
|
||||
* Client output high-water marks restart at current queued occupancy, not zero.
|
||||
*/
|
||||
esp_err_t session_broker_clear_counters(void);
|
||||
esp_err_t session_broker_clear_client_counters(session_broker_client_id_t client_id);
|
||||
|
||||
|
||||
@@ -182,6 +182,26 @@ static int show_counters(void)
|
||||
counter->events_queued,
|
||||
counter->events_popped,
|
||||
counter->event_drops);
|
||||
|
||||
session_broker_client_snapshot_t clients[SESSION_BROKER_MAX_CLIENTS];
|
||||
size_t count = session_broker_list_clients(clients, SESSION_BROKER_MAX_CLIENTS);
|
||||
printf("Active clients (since connect/clear; lost on disconnect; global totals retained):\n");
|
||||
printf("Output bytes: HWM <= %u; clear seeds pending; read = handed to transport.\n",
|
||||
(unsigned int)SESSION_BROKER_OUTPUT_SIZE);
|
||||
printf("Global dropped also includes unread output discarded on disconnect.\n");
|
||||
printf("ID type pending HWM UART queued read dropped\n");
|
||||
for (size_t index = 0; index < count; ++index) {
|
||||
const session_broker_client_snapshot_t *client = &clients[index];
|
||||
printf("%-10lu %-9s %-7u %-7u %" PRIu64 " %" PRIu64 " %" PRIu64 " %" PRIu64 "\n",
|
||||
(unsigned long)client->id,
|
||||
client_type_name(client->type),
|
||||
(unsigned int)client->output_bytes_pending,
|
||||
(unsigned int)client->counters.output_high_water_bytes,
|
||||
client->counters.uart_rx_bytes,
|
||||
client->counters.output_queued_bytes,
|
||||
client->counters.output_read_bytes,
|
||||
client->counters.output_dropped_bytes);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
+84
-19
@@ -9,12 +9,13 @@
|
||||
#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 "web_security.h"
|
||||
#include "user_database.h"
|
||||
|
||||
static void print_usage(void)
|
||||
{
|
||||
@@ -43,6 +44,22 @@ static const char *state_name(ssh_transport_session_state_t state)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -52,14 +69,23 @@ static int print_sessions(const ssh_transport_snapshot_t *snapshot)
|
||||
if (!session->active) {
|
||||
continue;
|
||||
}
|
||||
printf(" id=%" PRIu32 " slot=%u peer=%s state=%s auth=%s broker=%" PRIu32
|
||||
" role=%s rx-pending=%s tx-pending=%s closing=%s\n",
|
||||
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->broker_client_id,
|
||||
session->broker_client_id == SESSION_BROKER_NO_CLIENT
|
||||
? "unattached"
|
||||
: (session->writer ? "writer" : "observer"),
|
||||
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");
|
||||
@@ -76,23 +102,13 @@ static int show_status(bool sessions_only)
|
||||
return 1;
|
||||
}
|
||||
if (!sessions_only) {
|
||||
char username[WEB_SECURITY_USERNAME_CAPACITY + 1U] = {0};
|
||||
size_t username_length = 0U;
|
||||
esp_err_t username_error = web_security_copy_username(
|
||||
username, sizeof(username), &username_length);
|
||||
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));
|
||||
if (username_error == ESP_OK) {
|
||||
printf("Authentication: SSH password, username=%.*s, shared with HTTPS\n",
|
||||
(int)username_length, username);
|
||||
} else {
|
||||
printf("Administrative credentials unavailable: %s\n",
|
||||
esp_err_to_name(username_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",
|
||||
@@ -130,6 +146,10 @@ static int show_counters(void)
|
||||
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",
|
||||
@@ -188,6 +208,18 @@ static bool parse_session_id(const char *text, uint32_t *session_id)
|
||||
|
||||
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);
|
||||
@@ -237,6 +269,16 @@ static int command_ssh(int argc, char **argv)
|
||||
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));
|
||||
@@ -263,7 +305,30 @@ static int command_ssh(int argc, char **argv)
|
||||
printf("Session ID must be a nonzero decimal integer.\n");
|
||||
return 1;
|
||||
}
|
||||
esp_err_t error = ssh_transport_disconnect(session_id);
|
||||
esp_err_t error;
|
||||
if (admin_ssh_console_dispatch_is_remote()) {
|
||||
ssh_transport_snapshot_t snapshot;
|
||||
error = ssh_transport_get_snapshot(&snapshot);
|
||||
bool found = false;
|
||||
if (error == ESP_OK) {
|
||||
for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
if (snapshot.sessions[index].active &&
|
||||
snapshot.sessions[index].session_id == session_id) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
}
|
||||
if (error == ESP_OK) {
|
||||
error = admin_ssh_console_dispatch_defer(
|
||||
ADMIN_SSH_DEFER_DISCONNECT, session_id);
|
||||
}
|
||||
} else {
|
||||
error = ssh_transport_disconnect(session_id);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not disconnect SSH session: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
|
||||
+83
-45
@@ -48,6 +48,9 @@ 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 uint32_t s_identity_token, s_next_identity_token;
|
||||
static TaskHandle_t s_identity_owner;
|
||||
static bool s_identity_used;
|
||||
|
||||
static bool bytes_are_zero(const uint8_t *data, size_t size)
|
||||
{
|
||||
@@ -338,6 +341,10 @@ esp_err_t ssh_security_init(ssh_security_load_result_t *load_result)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
if (s_identity_token) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
ssh_security_blob_t candidate;
|
||||
bool missing = false;
|
||||
error = load_blob(&candidate, &missing);
|
||||
@@ -411,60 +418,91 @@ esp_err_t ssh_security_get_metadata(ssh_security_metadata_t *metadata)
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_rotate(void)
|
||||
esp_err_t ssh_security_get_identity_snapshot(ssh_security_identity_snapshot_t *snapshot)
|
||||
{
|
||||
if (s_security_mutex == NULL) {
|
||||
if (!snapshot) return ESP_ERR_INVALID_ARG;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
esp_err_t error = s_material_ready ? ESP_OK : ESP_ERR_INVALID_STATE;
|
||||
if (error == ESP_OK) {
|
||||
snapshot->metadata.generation = s_material.generation;
|
||||
memcpy(snapshot->metadata.sha256_fingerprint, s_material.sha256_fingerprint,
|
||||
sizeof(snapshot->metadata.sha256_fingerprint));
|
||||
snapshot->busy = s_identity_token != 0 || s_next_identity_token == UINT32_MAX;
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token)
|
||||
{
|
||||
if (!token || (reset && generation)) return ESP_ERR_INVALID_ARG;
|
||||
*token = 0;
|
||||
if (reset) {
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error == ESP_OK) error = ensure_mutex();
|
||||
if (error != ESP_OK) return error;
|
||||
}
|
||||
if (!s_security_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_security_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
if (s_identity_token || s_next_identity_token == UINT32_MAX ||
|
||||
(!s_material_ready && !reset) ||
|
||||
(s_material_ready && s_material.generation == UINT32_MAX) ||
|
||||
(generation && generation != s_material.generation)) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
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));
|
||||
*token = s_identity_token = ++s_next_identity_token;
|
||||
s_identity_owner = xTaskGetCurrentTaskHandle();
|
||||
s_identity_used = false;
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return error;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_reset(void)
|
||||
esp_err_t ssh_security_replace_reserved(uint32_t token)
|
||||
{
|
||||
esp_err_t error = secure_random_init();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
error = ensure_mutex();
|
||||
if (error != ESP_OK) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (!s_security_mutex || !token) return ESP_ERR_INVALID_STATE;
|
||||
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;
|
||||
if (s_identity_token != token || s_identity_used ||
|
||||
s_identity_owner != xTaskGetCurrentTaskHandle()) {
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
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));
|
||||
s_identity_used = true;
|
||||
uint32_t generation = s_material_ready ? s_material.generation + 1U : 1U;
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
|
||||
/* Reservation excludes writers while crypto and flash run outside locks. */
|
||||
ssh_security_blob_t candidate = {0};
|
||||
esp_err_t error = generate_blob(&candidate, generation);
|
||||
if (error == ESP_OK) error = save_blob(&candidate);
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
if (error == ESP_OK) install_blob(&candidate);
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
return error;
|
||||
}
|
||||
|
||||
void ssh_security_release_identity(uint32_t token)
|
||||
{
|
||||
if (!s_security_mutex || !token) return;
|
||||
xSemaphoreTake(s_security_mutex, portMAX_DELAY);
|
||||
if (s_identity_token == token && s_identity_owner == xTaskGetCurrentTaskHandle()) {
|
||||
s_identity_token = 0;
|
||||
s_identity_owner = NULL;
|
||||
}
|
||||
xSemaphoreGive(s_security_mutex);
|
||||
}
|
||||
|
||||
static esp_err_t replace_identity(bool reset)
|
||||
{
|
||||
uint32_t token = 0;
|
||||
esp_err_t error = ssh_security_reserve_identity(0, reset, &token);
|
||||
if (error == ESP_OK) error = ssh_security_replace_reserved(token);
|
||||
ssh_security_release_identity(token);
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t ssh_security_rotate(void) { return replace_identity(false); }
|
||||
esp_err_t ssh_security_reset(void) { return replace_identity(true); }
|
||||
|
||||
+17
-1
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
@@ -38,7 +39,22 @@ 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. */
|
||||
typedef struct {
|
||||
ssh_security_metadata_t metadata;
|
||||
bool busy;
|
||||
} ssh_security_identity_snapshot_t;
|
||||
|
||||
/* Zero-wait atomic public projection; no private material. */
|
||||
esp_err_t ssh_security_get_identity_snapshot(ssh_security_identity_snapshot_t *snapshot);
|
||||
/* Owner transaction: nonreused token, reserve before side effects and retain through
|
||||
* restart. Only the reserving task may replace once and release. Zero generation
|
||||
* selects canonical semantics; reset additionally permits unavailable material. */
|
||||
esp_err_t ssh_security_reserve_identity(uint32_t generation, bool reset, uint32_t *token);
|
||||
esp_err_t ssh_security_replace_reserved(uint32_t token);
|
||||
void ssh_security_release_identity(uint32_t token);
|
||||
|
||||
/* Rotation requires valid live material; reset replaces any stored state.
|
||||
* Direct callers share the reservation but do not restart the transport. */
|
||||
esp_err_t ssh_security_rotate(void);
|
||||
esp_err_t ssh_security_reset(void);
|
||||
|
||||
|
||||
+674
-86
File diff suppressed because it is too large
Load Diff
+48
-1
@@ -9,6 +9,7 @@
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "session_broker.h"
|
||||
#include "user_database.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -26,6 +27,12 @@ typedef enum {
|
||||
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;
|
||||
@@ -51,20 +58,30 @@ typedef struct {
|
||||
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;
|
||||
|
||||
@@ -82,19 +99,49 @@ typedef struct {
|
||||
ssh_transport_counters_t counters;
|
||||
} ssh_transport_snapshot_t;
|
||||
|
||||
typedef enum {
|
||||
SSH_TRANSPORT_MANAGE_START = 0,
|
||||
SSH_TRANSPORT_MANAGE_STOP,
|
||||
SSH_TRANSPORT_MANAGE_DISCONNECT,
|
||||
} ssh_transport_management_action_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t generation;
|
||||
bool running;
|
||||
bool transitioning;
|
||||
ssh_transport_session_snapshot_t sessions[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
} ssh_transport_management_snapshot_t;
|
||||
|
||||
/* Compact published state only; no wolfSSH calls or task-stack scan. */
|
||||
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *snapshot);
|
||||
/* Dispatcher-only conditional admission; success on disconnect means owner notified,
|
||||
* not peer receipt/cleanup. Lifecycle timeout does not cancel admitted work. */
|
||||
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action,
|
||||
uint32_t target, uint32_t generation);
|
||||
|
||||
/* 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);
|
||||
|
||||
/* Conditional off-HTTPD rotation: both generations checked/reserved before stop.
|
||||
* Zero generations retain canonical rotate/reset semantics. A failed stop skips
|
||||
* mutation/start; persistence failure may already have disconnected all SSH.
|
||||
* committed reports irreversible publication even if restart subsequently fails. */
|
||||
esp_err_t ssh_transport_replace_identity(uint32_t service_generation,
|
||||
uint32_t identity_generation,
|
||||
bool reset, bool *committed);
|
||||
|
||||
/* 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 transport session or all authenticated/handshaking sessions. */
|
||||
/* 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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_console.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_system.h"
|
||||
@@ -47,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 console 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. */
|
||||
|
||||
+33
-155
@@ -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,14 +46,15 @@ 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};
|
||||
@@ -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,505 @@
|
||||
/* 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_database.h"
|
||||
#include "web_serial_transport.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 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 revoke_user_network_sessions(const char *username)
|
||||
{
|
||||
size_t username_length = strlen(username);
|
||||
esp_err_t web_error = web_serial_transport_revoke_user(
|
||||
(const uint8_t *)username, username_length);
|
||||
esp_err_t ssh_error = ssh_transport_revoke_user(
|
||||
(const uint8_t *)username, username_length);
|
||||
if (web_error != ESP_OK && web_error != ESP_ERR_INVALID_STATE) {
|
||||
printf("Warning: WebSocket 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_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\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);
|
||||
}
|
||||
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_count == 0U) {
|
||||
printf("No administrators; use 'user add <username> admin' on UART0.\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)
|
||||
{
|
||||
esp_err_t error = user_database_recover_empty();
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not recover user database: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("User database rebuilt empty; no credentials imported.\n");
|
||||
printf("Use 'user add <username> admin' on UART0 to create an administrator.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static esp_err_t mutation_currentness(void)
|
||||
{
|
||||
if (admin_ssh_console_dispatch_is_remote() &&
|
||||
!admin_ssh_console_dispatch_is_current()) {
|
||||
return ESP_ERR_NOT_ALLOWED;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
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;
|
||||
if (generated) {
|
||||
user_database_generated_password_t password;
|
||||
error = user_database_create_generated((const uint8_t *)username,
|
||||
strlen(username), role, &password);
|
||||
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 = mutation_currentness();
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_create((const uint8_t *)username, strlen(username),
|
||||
role, password, password_length);
|
||||
}
|
||||
secure_wipe(password, sizeof(password));
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not add user: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(username);
|
||||
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;
|
||||
if (generated) {
|
||||
user_database_generated_password_t password;
|
||||
error = user_database_generate_password((const uint8_t *)username,
|
||||
strlen(username), &password);
|
||||
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 = mutation_currentness();
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_set_password((const uint8_t *)username,
|
||||
strlen(username),
|
||||
password, password_length);
|
||||
}
|
||||
secure_wipe(password, sizeof(password));
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not change password: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(username);
|
||||
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;
|
||||
esp_err_t error = user_database_add_ssh_key(
|
||||
(const uint8_t *)username, strlen(username), type, type_length,
|
||||
blob, blob_length, &key_index);
|
||||
secure_wipe(blob, sizeof(blob));
|
||||
if (error != ESP_OK) {
|
||||
if (error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY) {
|
||||
printf("Could not add SSH key: that public key is already assigned to this account.\n");
|
||||
} else if (error == ESP_ERR_NO_MEM) {
|
||||
printf("Could not add SSH key: the account already has %u keys.\n",
|
||||
USER_DATABASE_MAX_SSH_KEYS_PER_USER);
|
||||
} else {
|
||||
printf("Could not add SSH key: %s\n", esp_err_to_name(error));
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(username);
|
||||
printf("SSH public key added at index %u. Public-key login is active.\n",
|
||||
(unsigned int)key_index);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int add_key(const char *username)
|
||||
{
|
||||
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();
|
||||
/* Repeat admission on canonical parsed arguments: direct handler calls must
|
||||
* not bypass self-target, generated-secret or UART0-only restrictions. */
|
||||
if (admin_ssh_console_dispatch_is_web() &&
|
||||
(!admin_ssh_console_web_user_command_allowed((size_t)argc, argv, principal) ||
|
||||
!admin_ssh_console_dispatch_is_current())) {
|
||||
printf("Browser account command restricted or session no longer current.\n");
|
||||
return 1;
|
||||
}
|
||||
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 == 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) {
|
||||
esp_err_t error = mutation_currentness();
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_delete((const uint8_t *)argv[2], strlen(argv[2]));
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not delete user (the final admin is protected): %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(argv[2]);
|
||||
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;
|
||||
}
|
||||
esp_err_t error = mutation_currentness();
|
||||
if (error == ESP_OK) {
|
||||
error = user_database_set_role((const uint8_t *)argv[2], strlen(argv[2]), role);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not change role (the final admin is protected): %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(argv[2]);
|
||||
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;
|
||||
}
|
||||
esp_err_t error = user_database_remove_ssh_key(
|
||||
(const uint8_t *)argv[3], strlen(argv[3]), index);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not delete SSH key: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(argv[3]);
|
||||
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) {
|
||||
esp_err_t error = user_database_clear_ssh_keys(
|
||||
(const uint8_t *)argv[3], strlen(argv[3]));
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not clear SSH keys: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
revoke_user_network_sessions(argv[3]);
|
||||
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);
|
||||
+1304
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
/* 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_EMPTY,
|
||||
} user_database_load_result_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;
|
||||
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;
|
||||
|
||||
/* Missing storage is persisted empty; valid v1 records load unchanged.
|
||||
* Corrupt/unsupported storage fails closed and is never automatically replaced. */
|
||||
esp_err_t user_database_init(user_database_load_result_t *load_result);
|
||||
/* Explicit UART0 recovery only; refuses an initialized database. No credentials
|
||||
* are imported or created. Caller enforces physical-console authorization. */
|
||||
esp_err_t user_database_recover_empty(void);
|
||||
esp_err_t user_database_get_snapshot(user_database_snapshot_t *snapshot);
|
||||
|
||||
/* Compact secret-free list, zero-wait mutex acquisition; no key material. */
|
||||
typedef struct {
|
||||
uint32_t user_id, auth_generation;
|
||||
user_role_t role;
|
||||
char username[USER_DATABASE_USERNAME_CAPACITY + 1U];
|
||||
} user_database_account_t;
|
||||
typedef struct {
|
||||
size_t count;
|
||||
user_database_account_t users[USER_DATABASE_MAX_USERS];
|
||||
} user_database_accounts_t;
|
||||
esp_err_t user_database_get_accounts(user_database_accounts_t *accounts);
|
||||
/* Zero-wait, identity-conditional projection; fingerprints only, no key blobs.
|
||||
* Output is cleared on failure; absent/stale identity returns NOT_FOUND. */
|
||||
esp_err_t user_database_get_account_keys(const user_database_account_t *expected,
|
||||
user_database_user_snapshot_t *snapshot);
|
||||
esp_err_t user_database_add_ssh_key_current(const user_database_account_t *expected,
|
||||
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_current(const user_database_account_t *expected,
|
||||
uint8_t key_index);
|
||||
esp_err_t user_database_clear_ssh_keys_current(const user_database_account_t *expected);
|
||||
/* Compare target identity under the mutation lock, before candidate/commit.
|
||||
* ESP_ERR_NOT_FOUND means absent or stale; existing account invariants apply. */
|
||||
esp_err_t user_database_delete_current(const user_database_account_t *expected);
|
||||
esp_err_t user_database_set_role_current(const user_database_account_t *expected,
|
||||
user_role_t role);
|
||||
esp_err_t user_database_set_password_current(const user_database_account_t *expected,
|
||||
const uint8_t *password, size_t password_length);
|
||||
/* RNG only: no database initialization, account mutation or persistence. Caller
|
||||
* owns/wipes successful output; failures clear it. Same generator as CLI. */
|
||||
esp_err_t user_database_generate_password_value(user_database_generated_password_t *generated);
|
||||
|
||||
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_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
|
||||
@@ -0,0 +1,484 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_account_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "secure_random.h"
|
||||
#include "mbedtls/base64.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_auth_parse.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "web_serial_transport.h"
|
||||
|
||||
enum { IDLE, PENDING, OK, FAILED, CANCELLED, STALE, PROTECTED, DUPLICATE, FULL };
|
||||
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "stale", "protected", "duplicate", "full"};
|
||||
typedef enum { ACTION_ROLE, ACTION_DELETE, ACTION_CREATE, ACTION_PASSWORD,
|
||||
ACTION_KEY_ADD, ACTION_KEY_DELETE, ACTION_KEY_CLEAR } account_action_t;
|
||||
static const char *const s_actions[] = {"role", "delete", "create", "password", "key-add", "key-delete", "key-clear"};
|
||||
typedef struct {
|
||||
uint32_t id;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
user_database_account_t target;
|
||||
int64_t deadline;
|
||||
user_role_t role;
|
||||
unsigned state;
|
||||
account_action_t action;
|
||||
bool executing;
|
||||
uint8_t password[USER_DATABASE_PASSWORD_CAPACITY + 1U];
|
||||
size_t password_length;
|
||||
char key_type[USER_DATABASE_SSH_KEY_TYPE_CAPACITY + 1U];
|
||||
uint8_t key_blob[USER_DATABASE_SSH_KEY_BLOB_CAPACITY];
|
||||
size_t key_blob_length;
|
||||
uint8_t key_index;
|
||||
} account_operation_t;
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static account_operation_t s_operation;
|
||||
static uint32_t s_next_id;
|
||||
static esp_timer_handle_t s_secret_timer;
|
||||
static bool s_secret_timer_started;
|
||||
|
||||
static bool credential_action(account_action_t action)
|
||||
{
|
||||
return action == ACTION_CREATE || action == ACTION_PASSWORD;
|
||||
}
|
||||
|
||||
static void wipe_input(account_operation_t *operation)
|
||||
{
|
||||
secure_wipe(&operation->principal, sizeof(operation->principal));
|
||||
secure_wipe(&operation->target, sizeof(operation->target));
|
||||
secure_wipe(operation->password, sizeof(operation->password));
|
||||
operation->password_length = 0;
|
||||
secure_wipe(operation->key_type, sizeof(operation->key_type));
|
||||
secure_wipe(operation->key_blob, sizeof(operation->key_blob));
|
||||
operation->key_blob_length = 0;
|
||||
operation->key_index = 0;
|
||||
}
|
||||
|
||||
static void expire_secret(void *unused)
|
||||
{
|
||||
(void)unused;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
/* Inspect only the current ID/deadline, never a captured/rearmed job. A late
|
||||
* tick cannot cancel a replacement before its own deadline or executing work. */
|
||||
if (s_operation.id && s_operation.state == PENDING && !s_operation.executing &&
|
||||
credential_action(s_operation.action) && esp_timer_get_time() >= s_operation.deadline) {
|
||||
s_operation.state = CANCELLED;
|
||||
wipe_input(&s_operation);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static bool ensure_secret_timer(void)
|
||||
{
|
||||
/* HTTPD is the sole admission owner. Once started, this one firmware-lifetime
|
||||
* timer is never stopped/rearmed/deleted. Expiry is best-effort scheduling,
|
||||
* not hard realtime; no network/database work runs in its callback. */
|
||||
if (!s_secret_timer) {
|
||||
const esp_timer_create_args_t args = {.callback = expire_secret, .name = "account-secret"};
|
||||
if (esp_timer_create(&args, &s_secret_timer) != ESP_OK) return false;
|
||||
}
|
||||
if (!s_secret_timer_started) {
|
||||
if (esp_timer_start_periodic(s_secret_timer, 1000000ULL) != ESP_OK) return false;
|
||||
s_secret_timer_started = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* OpenSSH text envelope only. The canonical database parser validates the SSH
|
||||
* blob (including the P256 point) on the dispatcher, not the HTTPD stack. */
|
||||
static bool parse_public_key(const char *text, size_t length, account_operation_t *operation)
|
||||
{
|
||||
size_t type_length = 0;
|
||||
while (type_length < length && text[type_length] != ' ' && text[type_length] != '\t') ++type_length;
|
||||
if (!((type_length == 11 && !memcmp(text, "ssh-ed25519", 11)) ||
|
||||
(type_length == 19 && !memcmp(text, "ecdsa-sha2-nistp256", 19)))) return false;
|
||||
size_t start = type_length;
|
||||
while (start < length && (text[start] == ' ' || text[start] == '\t')) ++start;
|
||||
size_t end = start;
|
||||
while (end < length && text[end] != ' ' && text[end] != '\t') ++end;
|
||||
size_t encoded_length = end - start;
|
||||
if (!encoded_length || encoded_length > 172 || encoded_length % 4) return false;
|
||||
for (size_t i = start; i < end; ++i) {
|
||||
unsigned char c = (unsigned char)text[i];
|
||||
if (!((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '+' || c == '/' ||
|
||||
(c == '=' && i >= end - 2))) return false;
|
||||
}
|
||||
for (size_t i = end; i < length; ++i)
|
||||
if ((text[i] < ' ' || text[i] > '~') && text[i] != '\t') return false;
|
||||
if (mbedtls_base64_decode(operation->key_blob, sizeof(operation->key_blob),
|
||||
&operation->key_blob_length, (const uint8_t *)text + start, encoded_length) != 0) return false;
|
||||
/* Round-trip rejects noncanonical padding and unused base64 bits. */
|
||||
unsigned char encoded[173];
|
||||
size_t written = 0;
|
||||
if (mbedtls_base64_encode(encoded, sizeof(encoded), &written, operation->key_blob,
|
||||
operation->key_blob_length) != 0 || written != encoded_length ||
|
||||
memcmp(encoded, text + start, written)) return false;
|
||||
memcpy(operation->key_type, text, type_length);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Exact flat schemas. Password/public_key accept JSON escapes; canonical database
|
||||
* policy validates the decoded bytes. No coercion/unknown/duplicate fields. */
|
||||
static bool parse_request(const char *body, size_t length, account_operation_t *operation, bool keys_only)
|
||||
{
|
||||
const char *keys[] = {"action", "username", "user_id", "auth_generation", "role", "password", "public_key", "key_index"};
|
||||
unsigned seen = 0;
|
||||
size_t pos = 0;
|
||||
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
|
||||
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
|
||||
TAKE('{');
|
||||
for (unsigned field = 0; field < 8; ++field) {
|
||||
if (field) { TAKE(','); }
|
||||
TAKE('"');
|
||||
size_t start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
unsigned key = 0;
|
||||
for (; key < 8; ++key)
|
||||
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
||||
if (key == 8 || (seen & (1U << key))) return false;
|
||||
++pos; TAKE(':'); SPACE();
|
||||
uint32_t number = 0;
|
||||
char value[USER_DATABASE_USERNAME_CAPACITY + 1] = {0};
|
||||
if (key == 2 || key == 3 || key == 7) {
|
||||
start = pos;
|
||||
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
|
||||
unsigned digit = (unsigned)(body[pos++] - '0');
|
||||
if (number > (UINT32_MAX - digit) / 10U) return false;
|
||||
number = number * 10U + digit;
|
||||
}
|
||||
if ((!number && key != 7) || pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
||||
if (key == 7 && number >= USER_DATABASE_MAX_SSH_KEYS_PER_USER) return false;
|
||||
} else if (key == 6) {
|
||||
char text[385] = {0};
|
||||
size_t text_length = 0;
|
||||
if (!web_auth_parse_json_string(body, length, &pos, (uint8_t *)text,
|
||||
sizeof(text), &text_length) || !parse_public_key(text, text_length, operation)) return false;
|
||||
} else if (key == 5) {
|
||||
if (!web_auth_parse_json_string(body, length, &pos, operation->password,
|
||||
sizeof(operation->password), &operation->password_length) ||
|
||||
!user_database_password_valid(operation->password, operation->password_length)) return false;
|
||||
} else {
|
||||
TAKE('"'); start = pos;
|
||||
while (pos < length && body[pos] != '"') {
|
||||
if (body[pos] < ' ' || body[pos] > '~' || body[pos] == '\\' || pos - start >= sizeof(value) - 1) return false;
|
||||
++pos;
|
||||
}
|
||||
if (pos == length) return false;
|
||||
memcpy(value, body + start, pos - start); ++pos;
|
||||
}
|
||||
switch (key) {
|
||||
case 0:
|
||||
{
|
||||
unsigned action = 0;
|
||||
for (; action < sizeof(s_actions) / sizeof(*s_actions); ++action)
|
||||
if (!strcmp(value, s_actions[action])) break;
|
||||
if (action == sizeof(s_actions) / sizeof(*s_actions)) return false;
|
||||
operation->action = (account_action_t)action;
|
||||
}
|
||||
break;
|
||||
case 1:
|
||||
if (!user_database_username_valid((const uint8_t *)value, strlen(value))) return false;
|
||||
memcpy(operation->target.username, value, sizeof(value)); break;
|
||||
case 2: operation->target.user_id = number; break;
|
||||
case 3: operation->target.auth_generation = number; break;
|
||||
case 4: if (!user_role_parse(value, &operation->role)) return false; break;
|
||||
case 7: operation->key_index = (uint8_t)number; break;
|
||||
}
|
||||
seen |= 1U << key;
|
||||
SPACE();
|
||||
if (pos < length && body[pos] == '}') break;
|
||||
}
|
||||
TAKE('}'); SPACE();
|
||||
#undef TAKE
|
||||
#undef SPACE
|
||||
const unsigned schemas[] = {31U, 15U, 51U, 47U, 79U, 143U, 15U};
|
||||
return pos == length && seen == (keys_only ? 14U : schemas[operation->action]);
|
||||
}
|
||||
|
||||
static bool parse(const char *body, size_t length, account_operation_t *operation)
|
||||
{
|
||||
return parse_request(body, length, operation, false);
|
||||
}
|
||||
|
||||
void web_account_settings_execute(uint32_t id)
|
||||
{
|
||||
account_operation_t operation = {0};
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool admitted = id && s_operation.id == id && s_operation.state == PENDING && !s_operation.executing;
|
||||
if (admitted) {
|
||||
s_operation.executing = true;
|
||||
operation = s_operation;
|
||||
wipe_input(&s_operation);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!admitted) return;
|
||||
bool current = false;
|
||||
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t);
|
||||
unsigned state = CANCELLED;
|
||||
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
||||
esp_timer_get_time() < operation.deadline) {
|
||||
/* CLI and typed mutations share this dispatcher. Target identity is also
|
||||
* compared under the database mutation lock, not just at HTTP admission. */
|
||||
switch (operation.action) {
|
||||
case ACTION_ROLE: error = user_database_set_role_current(&operation.target, operation.role); break;
|
||||
case ACTION_DELETE: error = user_database_delete_current(&operation.target); break;
|
||||
case ACTION_CREATE:
|
||||
error = user_database_create((const uint8_t *)operation.target.username,
|
||||
strlen(operation.target.username), operation.role, operation.password, operation.password_length);
|
||||
break;
|
||||
case ACTION_KEY_ADD:
|
||||
error = user_database_add_ssh_key_current(&operation.target,
|
||||
(const uint8_t *)operation.key_type, strlen(operation.key_type),
|
||||
operation.key_blob, operation.key_blob_length, &operation.key_index);
|
||||
break;
|
||||
case ACTION_KEY_DELETE:
|
||||
error = user_database_remove_ssh_key_current(&operation.target, operation.key_index);
|
||||
break;
|
||||
case ACTION_KEY_CLEAR:
|
||||
error = user_database_clear_ssh_keys_current(&operation.target);
|
||||
break;
|
||||
case ACTION_PASSWORD:
|
||||
error = user_database_set_password_current(&operation.target, operation.password, operation.password_length);
|
||||
break;
|
||||
}
|
||||
secure_wipe(operation.password, sizeof(operation.password));
|
||||
operation.password_length = 0;
|
||||
state = error == ESP_OK ? OK : error == ESP_ERR_NOT_FOUND ? STALE :
|
||||
error == ESP_ERR_INVALID_STATE ? (operation.action == ACTION_CREATE ? DUPLICATE :
|
||||
operation.action <= ACTION_PASSWORD ? PROTECTED : FAILED) :
|
||||
error == USER_DATABASE_ERR_DUPLICATE_SSH_KEY && operation.action == ACTION_KEY_ADD ? DUPLICATE :
|
||||
error == ESP_ERR_NO_MEM && (operation.action == ACTION_CREATE || operation.action == ACTION_KEY_ADD) ? FULL : FAILED;
|
||||
if (error == ESP_OK && operation.action != ACTION_CREATE) {
|
||||
size_t length = strlen(operation.target.username);
|
||||
(void)web_serial_transport_revoke_user((const uint8_t *)operation.target.username, length);
|
||||
(void)ssh_transport_revoke_user((const uint8_t *)operation.target.username, length);
|
||||
}
|
||||
}
|
||||
secure_wipe(operation.password, sizeof(operation.password));
|
||||
operation.password_length = 0;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.id == id && s_operation.state == PENDING && s_operation.executing) {
|
||||
s_operation.state = state;
|
||||
s_operation.executing = false;
|
||||
wipe_input(&s_operation);
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
}
|
||||
|
||||
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(request, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
static esp_err_t list_accounts(httpd_req_t *request)
|
||||
{
|
||||
user_database_accounts_t accounts;
|
||||
if (user_database_get_accounts(&accounts) != ESP_OK)
|
||||
return respond(request, "503 Service Unavailable", "{\"error\":\"accounts_unavailable\"}");
|
||||
char body[1024];
|
||||
size_t used = (size_t)snprintf(body, sizeof(body), "{\"users\":[");
|
||||
for (size_t i = 0; i < accounts.count; ++i) {
|
||||
const user_database_account_t *user = &accounts.users[i];
|
||||
/* Database username policy makes these ASCII strings JSON-safe. */
|
||||
int written = snprintf(body + used, sizeof(body) - used,
|
||||
"%s{\"username\":\"%s\",\"user_id\":%" PRIu32 ",\"auth_generation\":%" PRIu32 ",\"role\":\"%s\"}",
|
||||
i ? "," : "", user->username, user->user_id, user->auth_generation, user_role_to_string(user->role));
|
||||
if (written < 0 || (size_t)written >= sizeof(body) - used) return ESP_FAIL;
|
||||
used += (size_t)written;
|
||||
}
|
||||
if (used + 3 > sizeof(body)) return ESP_FAIL;
|
||||
memcpy(body + used, "]}", 3);
|
||||
return respond(request, "200 OK", body);
|
||||
}
|
||||
|
||||
static bool read_request(httpd_req_t *request, account_operation_t *operation, bool keys_only)
|
||||
{
|
||||
char type[40] = {0}, body[768];
|
||||
size_t received = 0;
|
||||
bool valid = request->content_len && request->content_len <= sizeof(body) &&
|
||||
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
|
||||
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
|
||||
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
|
||||
int count = httpd_req_recv(request, body + received, request->content_len - received);
|
||||
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
|
||||
else received += (size_t)count;
|
||||
}
|
||||
valid = valid && received == request->content_len &&
|
||||
(keys_only ? parse_request(body, received, operation, true) : parse(body, received, operation));
|
||||
secure_wipe(body, sizeof(body));
|
||||
return valid;
|
||||
}
|
||||
|
||||
esp_err_t web_account_keys_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
account_operation_t operation = {0};
|
||||
user_database_user_snapshot_t snapshot = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require_json(request, 768, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
if (strcmp(request->uri, "/api/settings/accounts/keys") || !read_request(request, &operation, true)) {
|
||||
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}");
|
||||
goto done;
|
||||
}
|
||||
error = user_database_get_account_keys(&operation.target, &snapshot);
|
||||
if (error != ESP_OK) {
|
||||
error = error == ESP_ERR_NOT_FOUND ? respond(request, "409 Conflict", "{\"error\":\"stale\"}") :
|
||||
respond(request, "503 Service Unavailable", "{\"error\":\"accounts_unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
char body[512];
|
||||
int written = snprintf(body, sizeof(body),
|
||||
"{\"username\":\"%s\",\"user_id\":%" PRIu32 ",\"auth_generation\":%" PRIu32 ",\"keys\":[",
|
||||
snapshot.username, snapshot.user_id, snapshot.auth_generation);
|
||||
if (written < 0 || (size_t)written >= sizeof(body)) { error = ESP_FAIL; goto done; }
|
||||
size_t used = (size_t)written;
|
||||
bool comma = false;
|
||||
for (size_t i = 0; i < USER_DATABASE_MAX_SSH_KEYS_PER_USER; ++i) {
|
||||
const user_database_key_snapshot_t *key = &snapshot.public_keys[i];
|
||||
if (!key->active) continue;
|
||||
unsigned char fingerprint[45];
|
||||
size_t length = 0;
|
||||
if (mbedtls_base64_encode(fingerprint, sizeof(fingerprint), &length,
|
||||
key->sha256_fingerprint, sizeof(key->sha256_fingerprint)) != 0 || length != 44) {
|
||||
error = ESP_FAIL; goto done;
|
||||
}
|
||||
fingerprint[43] = 0; /* OpenSSH SHA256 fingerprints omit base64 padding. */
|
||||
written = snprintf(body + used, sizeof(body) - used,
|
||||
"%s{\"index\":%u,\"type\":\"%s\",\"fingerprint\":\"SHA256:%s\"}",
|
||||
comma ? "," : "", key->index, key->key_type, (const char *)fingerprint);
|
||||
if (written < 0 || (size_t)written >= sizeof(body) - used) { error = ESP_FAIL; goto done; }
|
||||
used += (size_t)written;
|
||||
comma = true;
|
||||
}
|
||||
if (used + 3 > sizeof(body)) { error = ESP_FAIL; goto done; }
|
||||
memcpy(body + used, "]}", 3);
|
||||
error = respond(request, "200 OK", body);
|
||||
done:
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_account_generate_password_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
user_database_generated_password_t generated = {0};
|
||||
char response[96] = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
error = user_database_generate_password_value(&generated);
|
||||
if (error != ESP_OK) {
|
||||
secure_wipe(&generated, sizeof(generated));
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
bool current = false;
|
||||
error = web_session_store_check_principal(view.id, &view.principal, ¤t);
|
||||
if (error != ESP_OK || !current) {
|
||||
secure_wipe(&generated, sizeof(generated));
|
||||
error = respond(request, "401 Unauthorized", "{\"error\":\"authentication_required\"}");
|
||||
goto done;
|
||||
}
|
||||
int written = snprintf(response, sizeof(response), "{\"password\":\"%s\"}", (const char *)generated.password);
|
||||
secure_wipe(&generated, sizeof(generated));
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : respond(request, "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&generated, sizeof(generated));
|
||||
secure_wipe(response, sizeof(response));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_account_settings_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
account_operation_t operation = {0};
|
||||
bool allowed = false, mutation = request->method == HTTP_POST;
|
||||
esp_err_t error = mutation ? web_cookie_auth_require_json(request, 768, &view, &allowed) :
|
||||
web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
if (!strcmp(request->uri, "/api/settings/accounts")) {
|
||||
error = mutation ? respond(request, "400 Bad Request", "{\"error\":\"invalid_request\"}") : list_accounts(request);
|
||||
goto done;
|
||||
}
|
||||
if (mutation) {
|
||||
if (!read_request(request, &operation, false)) {
|
||||
wipe_input(&operation);
|
||||
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_account_request\"}");
|
||||
goto done;
|
||||
}
|
||||
if (credential_action(operation.action) && !ensure_secret_timer()) {
|
||||
wipe_input(&operation);
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
operation.session = view.id;
|
||||
operation.principal = view.principal;
|
||||
operation.deadline = esp_timer_get_time() + 30000000LL;
|
||||
operation.state = PENDING;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
|
||||
if (!busy) { operation.id = ++s_next_id; s_operation = operation; }
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (busy || admin_ssh_console_submit_account_settings(operation.id) != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!busy && s_operation.id == operation.id && !s_operation.executing)
|
||||
secure_wipe(&s_operation, sizeof(s_operation));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
wipe_input(&operation);
|
||||
error = httpd_resp_set_hdr(request, "Retry-After", "1");
|
||||
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
|
||||
goto done;
|
||||
}
|
||||
} else {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.session == view.id) {
|
||||
operation.id = s_operation.id;
|
||||
operation.action = s_operation.action;
|
||||
operation.state = s_operation.state;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
wipe_input(&operation);
|
||||
char response[96];
|
||||
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
|
||||
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
||||
respond(request, mutation ? "202 Accepted" : "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/* One session-bound pending/result slot. Dispatcher execution only; completed
|
||||
* results are replaceable, not durable history or an idempotent retry API. */
|
||||
esp_err_t web_account_settings_handler(httpd_req_t *request);
|
||||
void web_account_settings_execute(uint32_t id);
|
||||
/* POST /api/settings/accounts/keys: admin cookie + Origin/CSRF, JSON exactly
|
||||
* {username,user_id,auth_generation}. Read-only zero-wait snapshot, 512-byte
|
||||
* response bound: {username,user_id,auth_generation,keys:[{index,type,fingerprint}]}.
|
||||
* Fingerprints are OpenSSH SHA256: base64 without padding, never key blobs.
|
||||
* Stale/absent target: 409 {error:"stale"}; busy DB: 503 accounts_unavailable.
|
||||
* Register independently as an optional POST route.
|
||||
*
|
||||
* Existing account-operation POST adds key-add (+public_key, OpenSSH text <=384
|
||||
* decoded bytes), key-delete (+key_index integer 0..2), key-clear. All require
|
||||
* username/user_id/auth_generation. Exact schemas, <=768 body bytes/4 receives.
|
||||
* Text/base64 errors: 400; canonical SSH blob/curve validation runs on dispatcher
|
||||
* (failed result). Duplicate/full/stale use existing named result states.
|
||||
* Success target-revokes immediately, including self; lost response/401 remains
|
||||
* uncertain, never proof of cancellation. No automatic mutation retries. */
|
||||
esp_err_t web_account_keys_handler(httpd_req_t *request);
|
||||
/* POST /api/settings/accounts/generate-password; bodyless admin cookie +
|
||||
* Origin/CSRF. RNG only, no queued/account/persistent state or retrieval. */
|
||||
esp_err_t web_account_generate_password_handler(httpd_req_t *request);
|
||||
@@ -0,0 +1,300 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_admin_tickets.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <string.h>
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
typedef struct {
|
||||
uint64_t generation;
|
||||
web_session_id_t id;
|
||||
int64_t expires_at_us;
|
||||
user_principal_t principal;
|
||||
uint8_t digest[32];
|
||||
} ticket_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static struct {
|
||||
ticket_t tickets[WEB_ADMIN_TICKET_CAPACITY];
|
||||
uint64_t epoch;
|
||||
uint64_t generation;
|
||||
uint32_t issued, consumed, rejected, capacity_rejections;
|
||||
bool ready;
|
||||
} s_state;
|
||||
|
||||
static void increment(uint32_t *counter)
|
||||
{
|
||||
if (*counter != UINT32_MAX) ++*counter;
|
||||
}
|
||||
|
||||
static bool equal_digest(const uint8_t *a, const uint8_t *b)
|
||||
{
|
||||
volatile uint8_t difference = 0;
|
||||
for (size_t i = 0; i < 32; ++i) difference |= a[i] ^ b[i];
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
static bool admin(const user_principal_t *p)
|
||||
{
|
||||
return p != NULL && p->role == USER_ROLE_ADMIN &&
|
||||
p->method == USER_AUTH_METHOD_PASSWORD && p->user_id != 0 &&
|
||||
p->auth_generation != 0 && p->username_length != 0 &&
|
||||
p->username_length <= USER_DATABASE_USERNAME_CAPACITY;
|
||||
}
|
||||
|
||||
static bool same_principal(const user_principal_t *a, const user_principal_t *b)
|
||||
{
|
||||
return admin(b) && a->user_id == b->user_id &&
|
||||
a->auth_generation == b->auth_generation && a->role == b->role &&
|
||||
a->method == b->method && a->username_length == b->username_length &&
|
||||
memcmp(a->username, b->username, a->username_length) == 0;
|
||||
}
|
||||
|
||||
static bool current(web_session_id_t id, const user_principal_t *p)
|
||||
{
|
||||
bool valid = false;
|
||||
return id != 0 && admin(p) &&
|
||||
web_session_store_check_principal(id, p, &valid) == ESP_OK && valid;
|
||||
}
|
||||
|
||||
static void expire_locked(int64_t now)
|
||||
{
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t *t = &s_state.tickets[i];
|
||||
if (t->generation && t->expires_at_us <= now) secure_wipe(t, sizeof(*t));
|
||||
}
|
||||
}
|
||||
|
||||
/* Fixed two-slot walk. Generation prevents an external check from deleting a
|
||||
* replacement, including when RNG returns the same bytes on a later issue. */
|
||||
static void prune(void)
|
||||
{
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t copy = {0};
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
copy = s_state.tickets[i];
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (copy.generation && !current(copy.id, ©.principal)) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_state.tickets[i].generation == copy.generation)
|
||||
secure_wipe(&s_state.tickets[i], sizeof(ticket_t));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
secure_wipe(©, sizeof(copy));
|
||||
}
|
||||
}
|
||||
|
||||
static void advance_epoch_locked(void)
|
||||
{
|
||||
if (s_state.epoch != UINT64_MAX) ++s_state.epoch;
|
||||
if (s_state.epoch == UINT64_MAX) {
|
||||
s_state.ready = false;
|
||||
secure_wipe(s_state.tickets, sizeof(s_state.tickets));
|
||||
}
|
||||
}
|
||||
|
||||
void web_admin_tickets_start(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!s_state.ready && s_state.epoch != UINT64_MAX &&
|
||||
s_state.generation != UINT64_MAX) {
|
||||
advance_epoch_locked();
|
||||
s_state.ready = s_state.epoch != UINT64_MAX;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_admin_tickets_stop(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
s_state.ready = false;
|
||||
secure_wipe(s_state.tickets, sizeof(s_state.tickets));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static bool capture_epoch(uint64_t *epoch)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
*epoch = s_state.epoch;
|
||||
bool ready = s_state.ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ready;
|
||||
}
|
||||
|
||||
static esp_err_t result(esp_err_t error)
|
||||
{
|
||||
if (error != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
increment(&s_state.rejected);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_admin_tickets_issue(web_session_id_t id,
|
||||
const user_principal_t *principal, char token[WEB_ADMIN_TICKET_LENGTH + 1U])
|
||||
{
|
||||
ticket_t candidate = {0};
|
||||
uint8_t random[32] = {0};
|
||||
uint64_t epoch = 0;
|
||||
esp_err_t error = ESP_ERR_INVALID_ARG;
|
||||
if (token == NULL) return result(error);
|
||||
secure_wipe(token, WEB_ADMIN_TICKET_LENGTH + 1U);
|
||||
if (id == 0 || principal == NULL) goto done;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
if (!capture_epoch(&epoch) || !current(id, principal)) goto done;
|
||||
candidate.id = id;
|
||||
candidate.principal = *principal;
|
||||
prune();
|
||||
if (!current(id, &candidate.principal)) goto done;
|
||||
error = secure_random_fill(random, sizeof(random));
|
||||
/* Recheck even when crypto fails; never use an old authorization result. */
|
||||
bool valid = current(id, &candidate.principal);
|
||||
if (error != ESP_OK) goto done;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
if (!valid) goto done;
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < sizeof(random); ++i) {
|
||||
token[2 * i] = hex[random[i] >> 4];
|
||||
token[2 * i + 1] = hex[random[i] & 15];
|
||||
}
|
||||
int crypto = mbedtls_sha256(random, sizeof(random), candidate.digest, 0);
|
||||
valid = current(id, &candidate.principal);
|
||||
error = crypto == 0 ? ESP_ERR_INVALID_STATE : ESP_FAIL;
|
||||
if (crypto != 0 || !valid) goto done;
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
if (s_state.ready && epoch == s_state.epoch && now >= 0 &&
|
||||
now <= INT64_MAX - WEB_ADMIN_TICKET_LIFETIME_US &&
|
||||
s_state.generation != UINT64_MAX) {
|
||||
ticket_t *free_slot = NULL;
|
||||
bool duplicate = false;
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t *t = &s_state.tickets[i];
|
||||
if (!t->generation) free_slot = t;
|
||||
else if (equal_digest(t->digest, candidate.digest)) duplicate = true;
|
||||
}
|
||||
if (duplicate) error = ESP_FAIL;
|
||||
else if (free_slot == NULL) {
|
||||
increment(&s_state.capacity_rejections);
|
||||
error = ESP_ERR_NO_MEM;
|
||||
} else {
|
||||
candidate.generation = ++s_state.generation;
|
||||
candidate.expires_at_us = now + WEB_ADMIN_TICKET_LIFETIME_US;
|
||||
*free_slot = candidate;
|
||||
increment(&s_state.issued);
|
||||
error = ESP_OK;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
done:
|
||||
secure_wipe(random, sizeof(random));
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
if (error != ESP_OK) secure_wipe(token, WEB_ADMIN_TICKET_LENGTH + 1U);
|
||||
return result(error);
|
||||
}
|
||||
|
||||
static int unhex(char c)
|
||||
{
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
esp_err_t web_admin_tickets_consume(const char *token, web_session_id_t id,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
uint8_t bytes[32] = {0}, digest[32] = {0};
|
||||
ticket_t found = {0};
|
||||
uint64_t epoch = 0;
|
||||
esp_err_t error = ESP_ERR_INVALID_ARG;
|
||||
if (token == NULL) goto done;
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_LENGTH; ++i) {
|
||||
int n = unhex(token[i]);
|
||||
if (n < 0) goto done;
|
||||
bytes[i / 2] |= (uint8_t)(n << ((i % 2 == 0) ? 4 : 0));
|
||||
}
|
||||
if (token[WEB_ADMIN_TICKET_LENGTH] != '\0') goto done;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
if (!capture_epoch(&epoch)) goto done;
|
||||
bool before = current(id, principal);
|
||||
if (mbedtls_sha256(bytes, sizeof(bytes), digest, 0) != 0) {
|
||||
(void)current(id, principal);
|
||||
error = ESP_FAIL;
|
||||
goto done;
|
||||
}
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
if (s_state.ready && epoch == s_state.epoch) {
|
||||
error = ESP_ERR_NOT_FOUND;
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t *t = &s_state.tickets[i];
|
||||
if (t->generation && equal_digest(t->digest, digest)) {
|
||||
found = *t;
|
||||
secure_wipe(t, sizeof(*t));
|
||||
increment(&s_state.consumed);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
/* Burn precedes acting on either currentness result or identity binding. */
|
||||
bool after = current(id, principal);
|
||||
if (found.generation) {
|
||||
now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
error = before && after && found.id == id &&
|
||||
same_principal(&found.principal, principal) && s_state.ready &&
|
||||
epoch == s_state.epoch && now < found.expires_at_us ?
|
||||
ESP_OK : ESP_ERR_INVALID_STATE;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
done:
|
||||
secure_wipe(bytes, sizeof(bytes));
|
||||
secure_wipe(digest, sizeof(digest));
|
||||
secure_wipe(&found, sizeof(found));
|
||||
return result(error);
|
||||
}
|
||||
|
||||
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *username,
|
||||
size_t length)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
advance_epoch_locked();
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
|
||||
ticket_t *t = &s_state.tickets[i];
|
||||
bool match = id != 0 ? t->id == id : username == NULL ||
|
||||
(length == t->principal.username_length &&
|
||||
length <= USER_DATABASE_USERNAME_CAPACITY &&
|
||||
memcmp(username, t->principal.username, length) == 0);
|
||||
if (match) secure_wipe(t, sizeof(*t));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_admin_tickets_get_snapshot(web_admin_tickets_snapshot_t *snapshot)
|
||||
{
|
||||
if (snapshot == NULL) return;
|
||||
prune();
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
expire_locked(now);
|
||||
*snapshot = (web_admin_tickets_snapshot_t) {
|
||||
.issued = s_state.issued, .consumed = s_state.consumed,
|
||||
.rejected = s_state.rejected,
|
||||
.capacity_rejections = s_state.capacity_rejections,
|
||||
.storage_bytes = sizeof(s_state) + sizeof(s_lock), .ready = s_state.ready,
|
||||
};
|
||||
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i)
|
||||
if (s_state.tickets[i].generation) ++snapshot->active;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
|
||||
#include "web_session_store.h"
|
||||
|
||||
#define WEB_ADMIN_TICKET_LENGTH 64U
|
||||
#define WEB_ADMIN_TICKET_CAPACITY 2U
|
||||
#define WEB_ADMIN_TICKET_LIFETIME_US 30000000LL
|
||||
|
||||
typedef struct {
|
||||
uint32_t issued;
|
||||
uint32_t consumed;
|
||||
uint32_t rejected;
|
||||
uint32_t capacity_rejections;
|
||||
uint32_t active;
|
||||
size_t storage_bytes;
|
||||
bool ready;
|
||||
} web_admin_tickets_snapshot_t;
|
||||
|
||||
/* Trusted internal API, not HTTP authorization. Start is idempotent while ready;
|
||||
* stop wipes records. Neither lifecycle operation resets epochs or counters.
|
||||
* RNG must already be initialized. Exhausted generations fail closed. */
|
||||
void web_admin_tickets_start(void);
|
||||
void web_admin_tickets_stop(void);
|
||||
/* Only current password-authenticated administrators. No live eviction.
|
||||
* Output must not alias inputs; all 65 bytes are wiped on failure.
|
||||
* NO_MEM: capacity; INVALID_ARG: malformed input; INVALID_STATE: stopped,
|
||||
* stale, unauthorized or raced; FAIL: SHA failure; RNG errors propagate. */
|
||||
esp_err_t web_admin_tickets_issue(web_session_id_t id,
|
||||
const user_principal_t *principal, char token[WEB_ADMIN_TICKET_LENGTH + 1U]);
|
||||
/* Exact hex string (either case). Matching tickets are burned even for wrong
|
||||
* session/principal or failed currentness. NOT_FOUND means no live match.
|
||||
* Crypto failure cannot identify/burn a ticket. Success is not a session lease. */
|
||||
esp_err_t web_admin_tickets_consume(const char *token, web_session_id_t id,
|
||||
const user_principal_t *principal);
|
||||
/* Caller invalidates sessions FIRST. Nonzero ID takes precedence; otherwise
|
||||
* non-NULL username matches exact bytes/length; otherwise revoke all.
|
||||
* Every call cancels in-flight work, even when no record matches. */
|
||||
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *username,
|
||||
size_t length);
|
||||
/* Saturating lifetime counters; consumed counts burned matches, not admissions.
|
||||
* rejected counts failed issue/consume (including capacity). Snapshot prunes
|
||||
* expired/stale records; storage_bytes includes state and lock, no secrets. */
|
||||
void web_admin_tickets_get_snapshot(web_admin_tickets_snapshot_t *snapshot);
|
||||
@@ -0,0 +1,606 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* One optional admin socket. HTTPD owns IO; the canonical dispatcher owns commands. */
|
||||
#include "web_admin_transport.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_system.h"
|
||||
#include "web_server.h"
|
||||
#include "web_security.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "secure_random.h"
|
||||
#include "web_admin_tickets.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
|
||||
#define ADMIN_POLL_US 20000ULL
|
||||
#define ADMIN_INPUT_TIMEOUT_US 5000000LL
|
||||
#define ADMIN_DETACH_TIMEOUT_US 2000000LL
|
||||
|
||||
typedef struct {
|
||||
uint8_t rx[WEB_ADMIN_RX_CAPACITY];
|
||||
uint8_t tx[WEB_ADMIN_TX_CAPACITY];
|
||||
size_t rx_length, rx_offset;
|
||||
int64_t input_deadline;
|
||||
} admin_payload_t;
|
||||
|
||||
typedef struct {
|
||||
bool occupied, active, console_open, close_requested, close_triggered, sending;
|
||||
int fd;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
admin_ssh_console_token_t token;
|
||||
} admin_slot_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static admin_slot_t s_slot;
|
||||
static admin_payload_t *s_payload; /* PSRAM; touched only by HTTPD while attached. */
|
||||
static esp_timer_handle_t s_timer;
|
||||
static httpd_handle_t s_server;
|
||||
static bool s_initialized, s_accepting, s_queued;
|
||||
static unsigned s_submitting;
|
||||
static uint32_t s_generation; /* Never wrap/reuse within a boot. */
|
||||
static web_admin_transport_snapshot_t s_counts;
|
||||
|
||||
static void count(uint32_t *value, uint32_t amount)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
*value = UINT32_MAX - *value < amount ? UINT32_MAX : *value + amount;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static bool token_matches(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
return token && s_slot.occupied && s_slot.console_open &&
|
||||
token->transport == ADMIN_CONSOLE_TRANSPORT_WEB &&
|
||||
token->session_id == s_slot.token.session_id &&
|
||||
token->slot_generation == s_slot.token.slot_generation &&
|
||||
token->slot_index == s_slot.token.slot_index;
|
||||
}
|
||||
|
||||
static bool owner_current(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool valid = token_matches(token) && s_accepting && s_slot.active && !s_slot.close_requested;
|
||||
web_session_id_t id = valid ? s_slot.session : 0;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
bool current = false;
|
||||
if (!valid || !principal || principal->role != USER_ROLE_ADMIN ||
|
||||
web_session_store_check_principal(id, principal, ¤t) != ESP_OK || !current)
|
||||
return false;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
valid = token_matches(token) && s_accepting && s_slot.active &&
|
||||
!s_slot.close_requested && s_slot.session == id;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return valid;
|
||||
}
|
||||
|
||||
static bool owner_drained(const admin_ssh_console_token_t *token)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool drained = token_matches(token) && s_slot.active &&
|
||||
!s_slot.close_requested && !s_slot.sending;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return drained;
|
||||
}
|
||||
|
||||
static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
|
||||
admin_ssh_deferred_action_type_t action, uint32_t argument)
|
||||
{
|
||||
(void)argument;
|
||||
if (action != ADMIN_CONSOLE_DEFER_SELF_CLOSE && action != ADMIN_SSH_DEFER_REBOOT &&
|
||||
action != ADMIN_CONSOLE_DEFER_WEB_STOP &&
|
||||
action != ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE) return ESP_ERR_NOT_SUPPORTED;
|
||||
/* Drain is only a delivery heuristic, not an authorization lease. The
|
||||
* execution task must recheck cookie/account binding after delay/queueing. */
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
user_principal_t principal = s_slot.principal;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
bool current = owner_current(token, &principal);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
if (!current) return ESP_ERR_NOT_FOUND;
|
||||
if (action == ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE) {
|
||||
/* The owner mask routes this crypto/NVS path to the 12KiB dispatcher.
|
||||
* Commit before restart; a failed stop retains HTTPD ownership and must
|
||||
* not be followed by start. No socket IO or console handler calls here. */
|
||||
bool committed = false;
|
||||
return web_server_replace_identity(0, 0, false, &committed);
|
||||
}
|
||||
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
|
||||
if (action == ADMIN_SSH_DEFER_REBOOT) {
|
||||
esp_restart();
|
||||
return ESP_OK;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool valid = token_matches(token) && s_slot.active && s_accepting && !s_slot.close_requested;
|
||||
if (valid) s_slot.close_requested = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (valid) admin_ssh_console_close(token);
|
||||
return valid ? ESP_OK : ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
static const admin_console_owner_t s_owner = {
|
||||
.supported_actions = (1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE) |
|
||||
(1U << ADMIN_SSH_DEFER_REBOOT) | (1U << ADMIN_CONSOLE_DEFER_WEB_STOP) |
|
||||
(1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE),
|
||||
.dispatcher_actions = 1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
|
||||
.is_current = owner_current, .drained = owner_drained, .perform = owner_perform,
|
||||
};
|
||||
|
||||
/* No IO and no payload mutation: safe on console/revocation/lifecycle callers. */
|
||||
static void request_close(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool opened = s_slot.console_open;
|
||||
admin_ssh_console_token_t token = s_slot.token;
|
||||
if (s_slot.occupied) s_slot.close_requested = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (opened) admin_ssh_console_close(&token);
|
||||
}
|
||||
|
||||
/* HTTPD callback, or lifecycle caller ONLY after HTTPD has successfully stopped. */
|
||||
static void session_free(void *context)
|
||||
{
|
||||
if (context != &s_slot) return;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool occupied = s_slot.occupied;
|
||||
bool opened = s_slot.console_open;
|
||||
bool active = s_slot.active;
|
||||
admin_ssh_console_token_t token = s_slot.token;
|
||||
secure_wipe(&s_slot, sizeof(s_slot));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (opened) admin_ssh_console_close(&token);
|
||||
if (occupied && s_payload) secure_wipe(s_payload, sizeof(*s_payload));
|
||||
if (active) count(&s_counts.disconnections, 1);
|
||||
}
|
||||
|
||||
static bool capture(admin_ssh_console_token_t *token, user_principal_t *principal, int *fd)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool active = s_slot.active;
|
||||
*token = s_slot.token;
|
||||
*principal = s_slot.principal;
|
||||
*fd = s_slot.fd;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return active;
|
||||
}
|
||||
|
||||
static bool input_current(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
if (owner_current(token, principal)) return true;
|
||||
count(&s_counts.authorization_rejections, 1);
|
||||
request_close();
|
||||
return false;
|
||||
}
|
||||
|
||||
static void discard_pending_input(void)
|
||||
{
|
||||
secure_wipe(s_payload->rx, sizeof(s_payload->rx));
|
||||
s_payload->rx_offset = s_payload->rx_length = 0;
|
||||
s_payload->input_deadline = 0;
|
||||
count(&s_counts.input_backpressure, 1);
|
||||
}
|
||||
|
||||
static bool feed_pending(const admin_ssh_console_token_t *token,
|
||||
const user_principal_t *principal)
|
||||
{
|
||||
if (s_payload->rx_offset == s_payload->rx_length) return true;
|
||||
if (!input_current(token, principal)) return false;
|
||||
admin_ssh_console_session_snapshot_t console;
|
||||
if (admin_ssh_console_get_session_snapshot(token, &console) != ESP_OK || !console.active) {
|
||||
request_close();
|
||||
return false;
|
||||
}
|
||||
if (console.deferred_action_pending) {
|
||||
/* Never replay buffered keystrokes if a deferred action fails/cancels. */
|
||||
discard_pending_input();
|
||||
return true;
|
||||
}
|
||||
if (esp_timer_get_time() >= s_payload->input_deadline) {
|
||||
count(&s_counts.input_backpressure, 1);
|
||||
request_close();
|
||||
return false;
|
||||
}
|
||||
size_t consumed = 0;
|
||||
(void)admin_ssh_console_feed_input(token, s_payload->rx + s_payload->rx_offset,
|
||||
s_payload->rx_length - s_payload->rx_offset, &consumed);
|
||||
secure_wipe(s_payload->rx + s_payload->rx_offset, consumed);
|
||||
s_payload->rx_offset += consumed;
|
||||
if (s_payload->rx_offset == s_payload->rx_length) {
|
||||
s_payload->rx_offset = s_payload->rx_length = 0;
|
||||
s_payload->input_deadline = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Only this HTTPD work callback sends console output or requests idle closure. */
|
||||
static void poll_work(void *argument)
|
||||
{
|
||||
httpd_handle_t server = argument;
|
||||
admin_ssh_console_token_t token;
|
||||
user_principal_t principal;
|
||||
int fd;
|
||||
bool active = capture(&token, &principal, &fd);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool attached = s_accepting && server == s_server;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!active || !attached) goto done;
|
||||
if (!input_current(&token, &principal)) goto closing;
|
||||
if (httpd_sess_get_ctx(server, fd) != &s_slot ||
|
||||
httpd_ws_get_fd_info(server, fd) != HTTPD_WS_CLIENT_WEBSOCKET) {
|
||||
request_close();
|
||||
goto closing;
|
||||
}
|
||||
admin_ssh_console_session_snapshot_t console;
|
||||
if (admin_ssh_console_get_session_snapshot(&token, &console) != ESP_OK || !console.active) {
|
||||
request_close();
|
||||
goto closing;
|
||||
}
|
||||
if (!feed_pending(&token, &principal)) goto closing;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_slot.sending = true; /* Covers the gap between ring consumption and socket send. */
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
size_t length = 0;
|
||||
esp_err_t error = admin_ssh_console_read_output(&token, s_payload->tx,
|
||||
sizeof(s_payload->tx), &length);
|
||||
if (error == ESP_OK && length && input_current(&token, &principal)) {
|
||||
httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_BINARY,
|
||||
.payload = s_payload->tx, .len = length};
|
||||
error = httpd_ws_send_frame_async(server, fd, &frame);
|
||||
if (error == ESP_OK) count(&s_counts.tx_bytes, (uint32_t)length);
|
||||
}
|
||||
secure_wipe(s_payload->tx, sizeof(s_payload->tx));
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_slot.sending = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (error != ESP_OK) {
|
||||
count(&s_counts.send_failures, 1);
|
||||
request_close();
|
||||
}
|
||||
closing:
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool close = s_slot.active && s_slot.close_requested && !s_slot.close_triggered;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (close && httpd_sess_get_ctx(server, fd) == &s_slot) {
|
||||
/* IDF's queued close retains a reusable sock_db pointer. Shutdown on
|
||||
* HTTPD instead: its next read owns deletion, with no late close that
|
||||
* could evict a replacement (including a serial client). */
|
||||
if (shutdown(fd, SHUT_RDWR) == 0) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_slot.close_triggered = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
} else count(&s_counts.send_failures, 1); /* Retry on the next bounded poll. */
|
||||
}
|
||||
done:
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_queued = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
/* ESP timer task: no database/console/socket calls, no waits, one queue entry max.
|
||||
* Detach prevents new submissions and fences any submission already outside lock. */
|
||||
static void poll_timer(void *argument)
|
||||
{
|
||||
(void)argument;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
httpd_handle_t server = NULL;
|
||||
uint32_t generation = 0;
|
||||
if (s_accepting && s_slot.active && !s_queued) {
|
||||
server = s_server;
|
||||
generation = s_slot.token.slot_generation;
|
||||
s_queued = true;
|
||||
++s_submitting;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!server) return;
|
||||
esp_err_t error = httpd_queue_work(server, poll_work, server);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
--s_submitting;
|
||||
if (error != ESP_OK) {
|
||||
s_queued = false;
|
||||
if (s_slot.active && s_slot.token.slot_generation == generation)
|
||||
s_slot.close_requested = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (error != ESP_OK) count(&s_counts.queue_failures, 1);
|
||||
}
|
||||
|
||||
esp_err_t web_admin_transport_init(void)
|
||||
{
|
||||
#if defined(CONFIG_HTTPD_QUEUE_WORK_BLOCKING) && CONFIG_HTTPD_QUEUE_WORK_BLOCKING
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
#else
|
||||
if (s_initialized) return ESP_OK; /* Lifecycle caller serializes initialization. */
|
||||
admin_payload_t *payload = heap_caps_calloc(1, sizeof(*payload), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
|
||||
esp_err_t error = payload ? ESP_OK : ESP_ERR_NO_MEM;
|
||||
esp_timer_handle_t timer = NULL;
|
||||
const esp_timer_create_args_t args = {
|
||||
.callback = poll_timer, .name = "web_admin", .skip_unhandled_events = true,
|
||||
};
|
||||
if (error == ESP_OK) error = esp_timer_create(&args, &timer);
|
||||
if (error == ESP_OK) error = esp_timer_start_periodic(timer, ADMIN_POLL_US);
|
||||
if (error != ESP_OK) {
|
||||
if (timer) (void)esp_timer_delete(timer);
|
||||
if (payload) heap_caps_free(payload);
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (error == ESP_OK) {
|
||||
s_payload = payload;
|
||||
s_timer = timer;
|
||||
s_initialized = true;
|
||||
}
|
||||
s_counts.last_error = error;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return error;
|
||||
#endif
|
||||
}
|
||||
|
||||
esp_err_t web_admin_transport_attach(httpd_handle_t server)
|
||||
{
|
||||
if (!server) return ESP_ERR_INVALID_ARG;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool allowed = s_initialized && !s_server && !s_queued && !s_submitting && !s_slot.occupied;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!allowed) return ESP_ERR_INVALID_STATE;
|
||||
web_admin_tickets_start();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_server = server;
|
||||
s_accepting = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_admin_transport_detach(httpd_handle_t server)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool owned = server && server == s_server;
|
||||
if (owned) s_accepting = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!owned) return ESP_ERR_INVALID_STATE;
|
||||
web_admin_tickets_stop();
|
||||
request_close();
|
||||
int64_t deadline = esp_timer_get_time() + ADMIN_DETACH_TIMEOUT_US;
|
||||
for (;;) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool submitting = s_submitting != 0;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!submitting) return ESP_OK;
|
||||
if (esp_timer_get_time() >= deadline) return ESP_ERR_TIMEOUT;
|
||||
vTaskDelay(1);
|
||||
}
|
||||
}
|
||||
|
||||
void web_admin_transport_stopped(httpd_handle_t server)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool owned = server && s_server == server && !s_accepting && !s_submitting;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!owned) return;
|
||||
session_free(&s_slot);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_server = NULL;
|
||||
s_queued = false; /* HTTPD is gone; its queued callbacks can no longer execute. */
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_admin_transport_revoke(web_session_id_t id, const uint8_t *username, size_t length)
|
||||
{
|
||||
web_admin_tickets_revoke(id, username, length);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool match = s_slot.occupied && (id ? s_slot.session == id :
|
||||
!username || (length == s_slot.principal.username_length &&
|
||||
length <= USER_DATABASE_USERNAME_CAPACITY &&
|
||||
memcmp(username, s_slot.principal.username, length) == 0));
|
||||
admin_ssh_console_token_t token = s_slot.token;
|
||||
bool opened = match && s_slot.console_open;
|
||||
if (match) s_slot.close_requested = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (opened) admin_ssh_console_close(&token);
|
||||
}
|
||||
|
||||
static esp_err_t response(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(request, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t deny(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
if (!strcmp(status, "503 Service Unavailable") &&
|
||||
httpd_resp_set_hdr(request, "Retry-After", "5") != ESP_OK) return ESP_FAIL;
|
||||
(void)response(request, status, body);
|
||||
return ESP_FAIL; /* Close after rejection, never leave unread frames/body alive. */
|
||||
}
|
||||
|
||||
esp_err_t web_admin_transport_ticket_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
char ticket[WEB_ADMIN_TICKET_LENGTH + 1U] = {0}, body[128] = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto cleanup;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
count(&s_counts.authorization_rejections, 1);
|
||||
error = deny(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool attached = s_accepting && s_server == request->handle;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
error = attached ? web_admin_tickets_issue(view.id, &view.principal, ticket) : ESP_ERR_INVALID_STATE;
|
||||
if (error != ESP_OK) {
|
||||
if (error == ESP_ERR_NO_MEM) count(&s_counts.capacity_rejections, 1);
|
||||
error = deny(request, "503 Service Unavailable", "{\"error\":\"admin_unavailable_or_capacity\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
int n = snprintf(body, sizeof(body), "{\"ticket\":\"%s\",\"expires_in\":30}", ticket);
|
||||
error = n > 0 && (size_t)n < sizeof(body) ? response(request, "200 OK", body) : ESP_FAIL;
|
||||
cleanup:
|
||||
secure_wipe(ticket, sizeof(ticket));
|
||||
secure_wipe(body, sizeof(body));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
static esp_err_t frame_handler(httpd_req_t *request)
|
||||
{
|
||||
admin_ssh_console_token_t token;
|
||||
user_principal_t principal;
|
||||
int fd;
|
||||
bool active = capture(&token, &principal, &fd);
|
||||
bool valid = active && request->sess_ctx == &s_slot &&
|
||||
fd == httpd_req_to_sockfd(request) && input_current(&token, &principal);
|
||||
if (!valid) goto failure;
|
||||
httpd_ws_frame_t frame = {0};
|
||||
if (httpd_ws_recv_frame(request, &frame, 0) != ESP_OK || !frame.final ||
|
||||
frame.type != HTTPD_WS_TYPE_BINARY || frame.len > WEB_ADMIN_RX_CAPACITY) {
|
||||
count(&s_counts.protocol_errors, 1);
|
||||
goto failure;
|
||||
}
|
||||
admin_ssh_console_session_snapshot_t console;
|
||||
if (admin_ssh_console_get_session_snapshot(&token, &console) != ESP_OK || !console.active)
|
||||
goto failure;
|
||||
/* Latch before the potentially blocking receive: cancellation during receive
|
||||
* must not turn input observed during deferral into a new command. */
|
||||
bool discard_frame = console.deferred_action_pending;
|
||||
if (s_payload->rx_length != s_payload->rx_offset) {
|
||||
if (!discard_frame) {
|
||||
count(&s_counts.input_backpressure, 1);
|
||||
goto failure;
|
||||
}
|
||||
/* Deferral may start before the next poll discards buffered trailing input. */
|
||||
discard_pending_input();
|
||||
}
|
||||
frame.payload = s_payload->rx;
|
||||
/* IDF treats len==0 as another header probe, not an empty payload read. */
|
||||
if (frame.len && httpd_ws_recv_frame(request, &frame, sizeof(s_payload->rx)) != ESP_OK)
|
||||
goto failure;
|
||||
s_payload->rx_length = frame.len;
|
||||
s_payload->rx_offset = 0;
|
||||
s_payload->input_deadline = esp_timer_get_time() + ADMIN_INPUT_TIMEOUT_US;
|
||||
if (discard_frame) discard_pending_input();
|
||||
else if (!feed_pending(&token, &principal)) goto failure;
|
||||
count(&s_counts.rx_bytes, (uint32_t)frame.len);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
return ESP_OK;
|
||||
failure:
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
request_close();
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
esp_err_t web_admin_transport_upgrade_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
char ticket[WEB_ADMIN_TICKET_LENGTH + 1U] = {0};
|
||||
admin_ssh_console_token_t token = {0};
|
||||
bool allowed = false, reserved = false, opened = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, false, true, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto cleanup;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
count(&s_counts.authorization_rejections, 1);
|
||||
error = deny(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
static const char prefix[] = WEB_ADMIN_WS_URI "?ticket=";
|
||||
if (!web_httpd_upgrade_requested(request) ||
|
||||
strncmp(request->uri, prefix, sizeof(prefix) - 1U) ||
|
||||
strlen(request->uri) != sizeof(prefix) - 1U + WEB_ADMIN_TICKET_LENGTH) {
|
||||
error = deny(request, "400 Bad Request", "{\"error\":\"invalid_upgrade\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
memcpy(ticket, request->uri + sizeof(prefix) - 1U, WEB_ADMIN_TICKET_LENGTH);
|
||||
if (web_admin_tickets_consume(ticket, view.id, &view.principal) != ESP_OK) {
|
||||
count(&s_counts.authorization_rejections, 1);
|
||||
error = deny(request, "403 Forbidden", "{\"error\":\"invalid_ticket\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
int socket_fd = httpd_req_to_sockfd(request);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (socket_fd >= 0 && s_accepting && s_server == request->handle &&
|
||||
!s_slot.occupied && s_generation != UINT32_MAX) {
|
||||
++s_generation;
|
||||
token = (admin_ssh_console_token_t){.transport = ADMIN_CONSOLE_TRANSPORT_WEB,
|
||||
.session_id = s_generation, .slot_generation = s_generation};
|
||||
s_slot.occupied = true;
|
||||
s_slot.session = view.id;
|
||||
s_slot.principal = view.principal;
|
||||
s_slot.fd = socket_fd;
|
||||
s_slot.token = token;
|
||||
reserved = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!reserved) {
|
||||
count(&s_counts.capacity_rejections, 1);
|
||||
error = deny(request, "503 Service Unavailable", "{\"error\":\"admin_capacity\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
error = admin_ssh_console_open_available(&token, &view.principal, &s_owner);
|
||||
if (error != ESP_OK) {
|
||||
count(&s_counts.capacity_rejections, 1);
|
||||
error = deny(request, "503 Service Unavailable", "{\"error\":\"console_capacity_or_unavailable\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
opened = true;
|
||||
bool current = false;
|
||||
error = web_session_store_check_principal(view.id, &view.principal, ¤t);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_slot.token = token;
|
||||
s_slot.console_open = true;
|
||||
bool admitted = error == ESP_OK && current && s_accepting && !s_slot.close_requested;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!admitted) {
|
||||
error = deny(request, "403 Forbidden", "{\"error\":\"session_revoked\"}");
|
||||
goto cleanup;
|
||||
}
|
||||
error = web_httpd_upgrade(request, frame_handler);
|
||||
if (error != ESP_OK) goto cleanup;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
admitted = s_accepting && !s_slot.close_requested;
|
||||
if (admitted) s_slot.active = true;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!admitted) { error = ESP_FAIL; goto cleanup; }
|
||||
request->sess_ctx = &s_slot;
|
||||
request->free_ctx = session_free;
|
||||
count(&s_counts.connections, 1);
|
||||
reserved = false; /* HTTPD context now owns cleanup. */
|
||||
cleanup:
|
||||
if (reserved) {
|
||||
if (opened) admin_ssh_console_close(&token);
|
||||
session_free(&s_slot);
|
||||
}
|
||||
secure_wipe(ticket, sizeof(ticket));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_admin_transport_get_snapshot(web_admin_transport_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot) return;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
*snapshot = s_counts;
|
||||
snapshot->initialized = s_initialized;
|
||||
snapshot->attached = s_accepting;
|
||||
snapshot->active = s_slot.active;
|
||||
snapshot->closing = s_slot.close_requested;
|
||||
snapshot->payload_bytes = s_payload ? sizeof(*s_payload) : 0;
|
||||
snapshot->static_bytes = sizeof(s_lock) + sizeof(s_slot) + sizeof(s_payload) +
|
||||
sizeof(s_timer) + sizeof(s_server) + sizeof(s_initialized) + sizeof(s_accepting) +
|
||||
sizeof(s_queued) + sizeof(s_submitting) + sizeof(s_generation) + sizeof(s_counts);
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
|
||||
#include "esp_http_server.h"
|
||||
#include "web_session_store.h"
|
||||
|
||||
#define WEB_ADMIN_TICKET_URI "/api/admin/ws-ticket"
|
||||
#define WEB_ADMIN_WS_URI "/ws/admin"
|
||||
#define WEB_ADMIN_MAX_SESSIONS 1U
|
||||
#define WEB_ADMIN_RX_CAPACITY 512U
|
||||
#define WEB_ADMIN_TX_CAPACITY 1024U
|
||||
|
||||
typedef struct {
|
||||
bool initialized, attached, active, closing;
|
||||
uint32_t connections, disconnections, capacity_rejections, authorization_rejections;
|
||||
uint32_t protocol_errors, input_backpressure, send_failures, queue_failures;
|
||||
uint32_t rx_bytes, tx_bytes;
|
||||
size_t static_bytes, payload_bytes;
|
||||
esp_err_t last_error;
|
||||
} web_admin_transport_snapshot_t;
|
||||
|
||||
/* Lifecycle caller serializes init/attach/detach/stopped. Optional PSRAM-only
|
||||
* payload allocation; no internal fallback, new task, broker client or dispatcher.
|
||||
* Timer only queues at most one poll; HTTPD owns all payload/IO/session cleanup. */
|
||||
esp_err_t web_admin_transport_init(void);
|
||||
esp_err_t web_admin_transport_attach(httpd_handle_t server);
|
||||
/* Disable admission and console access, then wait a bounded time for timer
|
||||
* submissions to finish. On timeout do NOT stop/free HTTPD; retry detach first. */
|
||||
esp_err_t web_admin_transport_detach(httpd_handle_t server);
|
||||
/* Call ONLY after successful httpd_ssl_stop, including partial startup cleanup.
|
||||
* Retires any unexecuted queued poll before allowing reuse of its static storage. */
|
||||
void web_admin_transport_stopped(httpd_handle_t server);
|
||||
|
||||
/* Ordinary HTTP routes, never register is_websocket=true: admission before 101.
|
||||
* These handlers enforce cookie/Origin/CSRF/role themselves. Binary frames carry
|
||||
* console bytes, final/unfragmented, at most RX_CAPACITY; no serial controls. */
|
||||
esp_err_t web_admin_transport_ticket_handler(httpd_req_t *request);
|
||||
esp_err_t web_admin_transport_upgrade_handler(httpd_req_t *request);
|
||||
/* Notification after authoritative store invalidation. id wins; else exact
|
||||
* username; else all. Safe before init. No socket calls from notifier context. */
|
||||
void web_admin_transport_revoke(web_session_id_t id, const uint8_t *username, size_t length);
|
||||
void web_admin_transport_get_snapshot(web_admin_transport_snapshot_t *snapshot);
|
||||
@@ -5849,3 +5849,558 @@ const uint8_t web_asset_addon_fit_js_gz[] __attribute__((aligned(4))) = {
|
||||
};
|
||||
const size_t web_asset_addon_fit_js_gz_size = sizeof(web_asset_addon_fit_js_gz);
|
||||
|
||||
const uint8_t web_asset_logo_png[] __attribute__((aligned(4))) = {
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x40,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0xaa, 0x69, 0x71, 0xde, 0x00, 0x00, 0x19,
|
||||
0x93, 0x49, 0x44, 0x41, 0x54, 0x78, 0xda, 0xed, 0x9b, 0x79, 0x9c, 0x5d,
|
||||
0x55, 0x95, 0xef, 0xbf, 0x6b, 0x9f, 0x73, 0xee, 0x54, 0xb7, 0xe6, 0x4a,
|
||||
0x8d, 0x99, 0x93, 0x22, 0x95, 0x90, 0x40, 0x20, 0x0c, 0x32, 0x84, 0xc9,
|
||||
0x01, 0x15, 0x9e, 0x76, 0x63, 0x47, 0xc5, 0x19, 0x15, 0x5a, 0x05, 0x44,
|
||||
0x05, 0xc1, 0x46, 0xed, 0x98, 0x16, 0x05, 0x87, 0xc7, 0xe0, 0x43, 0x1b,
|
||||
0x6c, 0x40, 0x9f, 0x2d, 0x0f, 0x25, 0x3c, 0x41, 0x50, 0x90, 0xa0, 0x60,
|
||||
0x00, 0x19, 0x93, 0x00, 0x26, 0x64, 0x9e, 0x53, 0xa9, 0x79, 0xbe, 0xf3,
|
||||
0x19, 0xf6, 0xea, 0x3f, 0x6e, 0x65, 0x20, 0x86, 0x41, 0x81, 0x7e, 0xf0,
|
||||
0x3e, 0xae, 0xcf, 0xe7, 0x7c, 0x6e, 0xdd, 0x7b, 0x6a, 0x4f, 0x6b, 0xaf,
|
||||
0xbd, 0xd7, 0x6f, 0xfd, 0xd6, 0xde, 0xf0, 0xfa, 0x8b, 0xc0, 0x22, 0x07,
|
||||
0x55, 0x39, 0xc8, 0x3b, 0x53, 0x7e, 0x7f, 0x30, 0x39, 0xd9, 0x7d, 0xf1,
|
||||
0x77, 0xaf, 0x69, 0xe7, 0x5e, 0x57, 0x31, 0x80, 0xdd, 0xef, 0xfb, 0x3c,
|
||||
0xa8, 0x39, 0x89, 0xe6, 0xc9, 0x47, 0x51, 0x5b, 0x3f, 0x8d, 0xaa, 0xaa,
|
||||
0x2a, 0x04, 0xc8, 0x64, 0x32, 0x0c, 0xf5, 0x6f, 0xa7, 0x7b, 0xc7, 0xd3,
|
||||
0x30, 0xf6, 0x08, 0xf0, 0xdc, 0x4b, 0xd4, 0xf1, 0xa6, 0x51, 0x80, 0x03,
|
||||
0x44, 0xe5, 0x3f, 0xbd, 0xb3, 0x65, 0xda, 0xd1, 0x9f, 0xe1, 0x2d, 0xc7,
|
||||
0x2d, 0xe4, 0xd8, 0x93, 0x85, 0xf6, 0x0e, 0xb4, 0xae, 0x11, 0x52, 0x29,
|
||||
0x30, 0x02, 0x85, 0x3c, 0x32, 0xd8, 0x8f, 0x6e, 0x5c, 0x87, 0x3c, 0xf5,
|
||||
0x30, 0xfa, 0xd4, 0xe3, 0x8f, 0xb1, 0xe9, 0xc9, 0x1b, 0x21, 0xb8, 0x75,
|
||||
0xbc, 0x8e, 0xfd, 0xea, 0x7a, 0x73, 0x88, 0x19, 0xff, 0x3c, 0x56, 0x26,
|
||||
0x1d, 0xf5, 0xa8, 0x5c, 0x7c, 0xad, 0xca, 0x43, 0x9d, 0xca, 0x0e, 0x0d,
|
||||
0x19, 0xd2, 0x02, 0xdd, 0xea, 0xb3, 0x49, 0x95, 0x35, 0x91, 0xb2, 0x3a,
|
||||
0x52, 0x36, 0xa9, 0xd2, 0xa3, 0x01, 0x23, 0x5a, 0xa0, 0x53, 0x03, 0x1e,
|
||||
0xe9, 0x51, 0xf9, 0xca, 0x0f, 0x55, 0xa6, 0x1e, 0xf7, 0x24, 0xb0, 0xf0,
|
||||
0x80, 0x3a, 0xdf, 0xd0, 0x16, 0x20, 0xe3, 0x8f, 0x85, 0xc4, 0x97, 0xe4,
|
||||
0x5d, 0x67, 0x5f, 0xc5, 0x97, 0xbe, 0xea, 0xe9, 0xdc, 0x19, 0x3e, 0x00,
|
||||
0x9b, 0x06, 0x62, 0xf2, 0xf4, 0x63, 0xb0, 0xfa, 0x69, 0xe8, 0xdd, 0xdd,
|
||||
0x45, 0x3e, 0x3f, 0x88, 0x62, 0x35, 0x99, 0xaa, 0x93, 0xa6, 0xa6, 0x49,
|
||||
0x1c, 0x7e, 0x2c, 0x1c, 0x75, 0x1c, 0x3a, 0xb3, 0x29, 0xc0, 0x41, 0x65,
|
||||
0xed, 0x8e, 0x18, 0x57, 0x5f, 0x19, 0xe9, 0x6f, 0x6e, 0xfd, 0x1a, 0x64,
|
||||
0xaf, 0x1a, 0x57, 0x82, 0x8e, 0x3f, 0x6f, 0x44, 0x59, 0xe4, 0x00, 0x50,
|
||||
0x31, 0xe5, 0xdf, 0xe4, 0xfc, 0xef, 0x28, 0xeb, 0xa3, 0x88, 0x6e, 0x2d,
|
||||
0xca, 0x23, 0x7d, 0x2a, 0x17, 0x7e, 0x57, 0x99, 0x7d, 0xea, 0xa3, 0x50,
|
||||
0x7b, 0x2e, 0x30, 0x17, 0x98, 0x0c, 0xcc, 0x18, 0x7f, 0xa6, 0x00, 0x73,
|
||||
0xa0, 0xe6, 0xe3, 0xcc, 0x3a, 0xe9, 0x0f, 0xf2, 0xcf, 0x57, 0xa8, 0x2c,
|
||||
0xef, 0x56, 0xba, 0xb5, 0xc8, 0x46, 0x0d, 0xf9, 0xc2, 0x35, 0x4a, 0x7a,
|
||||
0xda, 0x77, 0x5f, 0x4f, 0x4b, 0x78, 0x2d, 0xad, 0xa9, 0x42, 0x8e, 0x3f,
|
||||
0x6b, 0x98, 0x3e, 0x2d, 0xd1, 0xad, 0x05, 0xb9, 0xf9, 0x21, 0x95, 0xb9,
|
||||
0xa7, 0x6d, 0x00, 0xde, 0x05, 0xcc, 0xc0, 0x34, 0x7f, 0x96, 0x39, 0x6f,
|
||||
0xbd, 0x9b, 0x77, 0x7c, 0x6c, 0x3b, 0xff, 0x74, 0xc1, 0x98, 0x2c, 0xba,
|
||||
0x70, 0x8c, 0xd3, 0x3f, 0xd1, 0xc9, 0x61, 0x6f, 0x5b, 0x46, 0xac, 0xf5,
|
||||
0x4b, 0x40, 0x07, 0x70, 0x9a, 0x74, 0x9c, 0xfc, 0x67, 0xf9, 0xd1, 0x7d,
|
||||
0x4a, 0xa7, 0x16, 0x19, 0xd4, 0x92, 0x9c, 0x72, 0x76, 0x1e, 0xa8, 0xfd,
|
||||
0x6f, 0xda, 0xbc, 0xff, 0x46, 0x59, 0xbc, 0xd8, 0x00, 0x8e, 0x4c, 0x3e,
|
||||
0xfa, 0x5e, 0xf9, 0x5f, 0xbf, 0x55, 0xf9, 0xea, 0x8f, 0x55, 0x2a, 0x67,
|
||||
0xfc, 0x1c, 0x68, 0x23, 0x35, 0xf9, 0xeb, 0xf2, 0xae, 0x4f, 0x8d, 0xc9,
|
||||
0xf5, 0xf7, 0x2a, 0x8f, 0x0d, 0x2a, 0x5b, 0x54, 0xe9, 0xd2, 0x88, 0x6e,
|
||||
0x8d, 0xd8, 0xa6, 0xca, 0x93, 0x23, 0xca, 0x8d, 0x0f, 0xa8, 0xbc, 0xf7,
|
||||
0x33, 0x45, 0x2a, 0x67, 0x7c, 0x07, 0x98, 0x48, 0x6a, 0xe2, 0x8d, 0xf2,
|
||||
0xe5, 0xeb, 0x55, 0xfe, 0xfd, 0x77, 0xca, 0xf4, 0xb7, 0x3c, 0x02, 0xc4,
|
||||
0x5e, 0xc4, 0x9d, 0xbe, 0xa1, 0xf6, 0x00, 0x05, 0xea, 0xa0, 0xee, 0x33,
|
||||
0x10, 0xf5, 0xc0, 0xe8, 0x23, 0xd2, 0x7e, 0xe2, 0x2f, 0xf8, 0xfc, 0x57,
|
||||
0x8e, 0xd4, 0x77, 0x9e, 0x01, 0x13, 0x28, 0xd1, 0xa9, 0x71, 0xb6, 0x6e,
|
||||
0x82, 0xfe, 0x9e, 0x72, 0xa9, 0x86, 0x26, 0x98, 0x36, 0x13, 0x26, 0x39,
|
||||
0x25, 0x46, 0xf1, 0xe4, 0x81, 0xdf, 0x1b, 0xae, 0xb9, 0x6a, 0xbd, 0xae,
|
||||
0x7d, 0xe4, 0x7d, 0x10, 0x5f, 0x00, 0xf1, 0x69, 0x30, 0x70, 0x23, 0xd0,
|
||||
0xbb, 0x5f, 0x1b, 0x6f, 0x12, 0x69, 0x3f, 0xe1, 0x29, 0x59, 0xb6, 0x43,
|
||||
0x19, 0xd5, 0x2c, 0x4f, 0x67, 0x54, 0xbe, 0xf2, 0xef, 0x2a, 0xc7, 0xbd,
|
||||
0x77, 0x2d, 0xcd, 0x87, 0xff, 0x00, 0x1a, 0xbe, 0x00, 0x0d, 0x5f, 0xa4,
|
||||
0xe9, 0xb0, 0x6b, 0x39, 0xe6, 0x3d, 0xcf, 0xc9, 0xc5, 0xd7, 0xa9, 0x3c,
|
||||
0x31, 0xac, 0x8c, 0x69, 0x8e, 0xe5, 0xbd, 0xca, 0xdc, 0xb7, 0x6d, 0xe0,
|
||||
0x4d, 0x2a, 0x86, 0xf3, 0xce, 0xf3, 0x60, 0x66, 0x9c, 0x43, 0x4f, 0xdd,
|
||||
0xc0, 0x13, 0x23, 0xca, 0xd2, 0x67, 0x54, 0x8e, 0x3a, 0x73, 0x17, 0x70,
|
||||
0x36, 0x70, 0x3c, 0xd4, 0x9c, 0x4f, 0xcb, 0xfc, 0xff, 0x49, 0xeb, 0xfc,
|
||||
0x6b, 0xa0, 0xe6, 0x42, 0xe0, 0x24, 0xe0, 0x7d, 0x72, 0xc4, 0xe9, 0x1b,
|
||||
0xf9, 0xf9, 0xe3, 0x2a, 0x2b, 0x72, 0xca, 0x82, 0x33, 0x76, 0x43, 0x7d,
|
||||
0x25, 0x8b, 0x17, 0xbb, 0x6f, 0xe4, 0xcd, 0xef, 0x65, 0xb0, 0x40, 0xfd,
|
||||
0x69, 0xb4, 0x9f, 0xf8, 0x14, 0x2d, 0xf3, 0xff, 0x2f, 0x30, 0x97, 0x9a,
|
||||
0x43, 0xbe, 0x27, 0x8b, 0x2e, 0x2a, 0xc9, 0x8d, 0xcb, 0x54, 0x96, 0xed,
|
||||
0x54, 0x79, 0x60, 0xa7, 0x72, 0xd3, 0x43, 0x2a, 0x1f, 0xfa, 0xb2, 0xa5,
|
||||
0x71, 0xde, 0x4d, 0xc0, 0x7c, 0x9a, 0x0e, 0xff, 0x99, 0x74, 0x9c, 0xbc,
|
||||
0x0a, 0xa7, 0xe9, 0x8c, 0x37, 0xfa, 0xce, 0xff, 0xd7, 0xc9, 0xcc, 0x13,
|
||||
0x7e, 0x2f, 0x3f, 0x7e, 0x50, 0xe9, 0xd5, 0x80, 0x6e, 0xf5, 0x59, 0x95,
|
||||
0xf7, 0x79, 0x2a, 0xeb, 0xb3, 0x4b, 0x03, 0xfa, 0xd4, 0x97, 0xdb, 0x9e,
|
||||
0x56, 0x0e, 0x7f, 0xc7, 0x6a, 0x20, 0xc5, 0xff, 0x47, 0xe2, 0x02, 0x10,
|
||||
0x8b, 0x1d, 0x22, 0xef, 0xbb, 0x50, 0x19, 0x56, 0xcb, 0x6d, 0x2b, 0x54,
|
||||
0x4e, 0xfb, 0xb0, 0x2f, 0x75, 0xb3, 0x22, 0xa9, 0x9d, 0x15, 0x71, 0xe2,
|
||||
0xa2, 0x3c, 0x3f, 0x7e, 0x48, 0x19, 0xd3, 0x50, 0xce, 0xff, 0xae, 0xe2,
|
||||
0x38, 0x6f, 0x2d, 0x17, 0x9d, 0x13, 0x7b, 0x33, 0x0f, 0x5c, 0xc6, 0x4d,
|
||||
0xd7, 0x19, 0x8f, 0xec, 0xe2, 0x4c, 0x5a, 0xf0, 0x33, 0x39, 0xf3, 0xd3,
|
||||
0x45, 0x5a, 0xe6, 0xdf, 0x83, 0xd7, 0xf2, 0x98, 0x5c, 0xf2, 0x03, 0x2b,
|
||||
0xff, 0x72, 0xa3, 0x92, 0x98, 0xfc, 0x38, 0xd5, 0xed, 0xb7, 0xca, 0x99,
|
||||
0x9f, 0x2e, 0x32, 0xfd, 0x84, 0xdf, 0x41, 0x75, 0xcd, 0x78, 0x19, 0xe7,
|
||||
0xa5, 0x23, 0xc6, 0x37, 0x7e, 0x2c, 0x70, 0x30, 0xa9, 0x03, 0x92, 0xbc,
|
||||
0xe7, 0xb3, 0xbd, 0xf4, 0xaa, 0xd2, 0xa7, 0x2a, 0x1f, 0xb8, 0x38, 0x33,
|
||||
0x0e, 0x72, 0xea, 0xff, 0xc6, 0x3a, 0x5f, 0xa5, 0x79, 0xbe, 0xf6, 0x33,
|
||||
0x6f, 0xeb, 0xeb, 0xeb, 0x2b, 0x07, 0x07, 0x2b, 0xa7, 0x93, 0xca, 0x09,
|
||||
0x79, 0x1f, 0x3c, 0x4f, 0xaa, 0xab, 0x53, 0xe2, 0x34, 0xd6, 0x15, 0xc3,
|
||||
0xde, 0x81, 0x8a, 0xcc, 0xea, 0x55, 0x39, 0xe7, 0xc1, 0x27, 0xb0, 0x5e,
|
||||
0x0c, 0x9e, 0x7d, 0x2a, 0x57, 0xd1, 0xd0, 0x3c, 0x2f, 0x56, 0x5b, 0xdf,
|
||||
0x1f, 0x0d, 0x67, 0x27, 0x8f, 0x8e, 0xe6, 0x84, 0x20, 0x50, 0x2f, 0x55,
|
||||
0x41, 0x90, 0xaf, 0xd0, 0xda, 0xda, 0x68, 0xdb, 0xf0, 0xf0, 0xd6, 0xd1,
|
||||
0xd7, 0x03, 0x03, 0xc8, 0xeb, 0x12, 0xff, 0xa7, 0x3a, 0xde, 0x41, 0xcc,
|
||||
0xb9, 0xa1, 0xa9, 0xc1, 0x9d, 0xd2, 0x5f, 0xaa, 0x24, 0x91, 0x4c, 0x88,
|
||||
0xda, 0x88, 0xc2, 0x98, 0x2f, 0x58, 0x01, 0x07, 0xeb, 0x98, 0x28, 0x8a,
|
||||
0x22, 0xd7, 0xc5, 0x13, 0xa5, 0x54, 0x8c, 0x50, 0xd7, 0x41, 0x8c, 0x41,
|
||||
0x94, 0x58, 0xda, 0xc3, 0xf5, 0x5c, 0x8a, 0x05, 0x5f, 0x27, 0xc4, 0x46,
|
||||
0xb5, 0x77, 0x20, 0xda, 0x8d, 0x0d, 0xce, 0x27, 0xb3, 0xe1, 0x9e, 0x57,
|
||||
0xc0, 0x0f, 0x08, 0x9c, 0xec, 0x40, 0xa3, 0xc2, 0x52, 0x7d, 0xb9, 0xe0,
|
||||
0xc9, 0x79, 0xad, 0x95, 0x39, 0xab, 0x7e, 0x56, 0xe5, 0x90, 0x9a, 0x65,
|
||||
0xe7, 0xbd, 0xbb, 0x6a, 0xca, 0x27, 0x4f, 0x94, 0xa8, 0x21, 0xed, 0xc8,
|
||||
0xf3, 0xa5, 0xe9, 0xb8, 0x41, 0x86, 0x8f, 0x1c, 0x99, 0xd3, 0x33, 0xe7,
|
||||
0xab, 0xce, 0x69, 0xb6, 0x32, 0x90, 0x09, 0xcd, 0x79, 0x0b, 0x23, 0x4d,
|
||||
0x4a, 0x89, 0xd6, 0x3a, 0x71, 0x3e, 0xbe, 0xd0, 0xc8, 0xf1, 0x33, 0xe1,
|
||||
0x98, 0xc9, 0xbe, 0x6e, 0x1e, 0x49, 0x68, 0x31, 0x3d, 0x45, 0x3f, 0x76,
|
||||
0xe4, 0x28, 0x9f, 0x3e, 0x21, 0xb2, 0x2d, 0x69, 0x6a, 0x57, 0x6d, 0xb5,
|
||||
0xa7, 0x37, 0xd5, 0xd5, 0xfc, 0x24, 0x9b, 0x1d, 0xca, 0xbd, 0xfc, 0xc4,
|
||||
0xed, 0xb0, 0xb0, 0x76, 0xef, 0xc0, 0x17, 0x2f, 0x5e, 0x6c, 0x96, 0x83,
|
||||
0xc3, 0x8e, 0xa9, 0x06, 0x76, 0xb0, 0xbf, 0x42, 0xcc, 0x6b, 0x0d, 0x83,
|
||||
0xb7, 0x16, 0x83, 0x29, 0xf1, 0x04, 0xad, 0xa7, 0x4d, 0x2d, 0x46, 0xb7,
|
||||
0xdc, 0x9f, 0x31, 0x27, 0x36, 0x8f, 0x48, 0x98, 0x1d, 0xa1, 0x92, 0x8c,
|
||||
0x7e, 0x60, 0x5e, 0xc4, 0xea, 0x0d, 0x63, 0x9c, 0x32, 0xd5, 0xb7, 0xc7,
|
||||
0xb6, 0xe5, 0xf5, 0xcc, 0xd9, 0x11, 0x27, 0x4d, 0x0d, 0xe4, 0x8c, 0x0e,
|
||||
0x5f, 0xc3, 0x5c, 0x56, 0xd7, 0x6e, 0x1e, 0xb5, 0x9b, 0x76, 0x65, 0x29,
|
||||
0x64, 0xb3, 0x98, 0x20, 0xcb, 0xa9, 0x8d, 0xfd, 0xdc, 0x72, 0xff, 0xa8,
|
||||
0x39, 0x65, 0xa6, 0x0d, 0xe3, 0x49, 0xa9, 0xeb, 0xc9, 0x39, 0xed, 0x2f,
|
||||
0xd1, 0xef, 0xb2, 0x52, 0x1a, 0xe7, 0x36, 0x41, 0xc3, 0x79, 0xc0, 0x19,
|
||||
0x40, 0x3b, 0x90, 0x58, 0xb2, 0x64, 0x89, 0x65, 0xf9, 0xf2, 0x10, 0x96,
|
||||
0x87, 0x07, 0x5a, 0xcf, 0x6b, 0xbe, 0x07, 0xc4, 0xd2, 0x90, 0xcb, 0x59,
|
||||
0x7b, 0xdd, 0x03, 0xa1, 0xf3, 0xb1, 0x13, 0xe2, 0xfa, 0x93, 0x55, 0x71,
|
||||
0x02, 0xeb, 0x10, 0x73, 0x90, 0x4d, 0x7d, 0x2a, 0x77, 0xfd, 0x29, 0xd2,
|
||||
0x13, 0x66, 0x1b, 0xf9, 0xc7, 0x23, 0x93, 0xac, 0xdb, 0xad, 0x4c, 0xaa,
|
||||
0xf3, 0x68, 0xaa, 0x36, 0x9c, 0x7f, 0x5b, 0x9e, 0x6d, 0xdb, 0xac, 0xe0,
|
||||
0x59, 0xf1, 0xea, 0x51, 0x07, 0xc3, 0x0d, 0x4f, 0x25, 0xf9, 0xd0, 0x09,
|
||||
0x86, 0xeb, 0x96, 0x23, 0x85, 0xa2, 0x68, 0xc2, 0x0d, 0x29, 0xbe, 0x68,
|
||||
0xcb, 0x27, 0x3b, 0xb0, 0x3c, 0x94, 0x42, 0xfe, 0xc2, 0x4b, 0xbe, 0x7e,
|
||||
0xc9, 0x57, 0x5d, 0x23, 0x6c, 0xdc, 0xbc, 0xd5, 0xef, 0xea, 0xe9, 0xdb,
|
||||
0xdd, 0xd5, 0xdd, 0xbf, 0xb9, 0xab, 0x77, 0xf0, 0xf9, 0x60, 0xb0, 0x67,
|
||||
0x03, 0x44, 0x2b, 0x61, 0xec, 0xe9, 0xd7, 0x4d, 0x01, 0x39, 0x5f, 0x14,
|
||||
0x4f, 0xe8, 0x1a, 0x55, 0x3e, 0xff, 0x4b, 0x21, 0x48, 0x54, 0x6b, 0xa2,
|
||||
0x32, 0x40, 0xad, 0x8a, 0x6b, 0x0b, 0x60, 0x8a, 0xdc, 0xfa, 0xa7, 0x88,
|
||||
0xb7, 0xcc, 0x34, 0x3c, 0xbc, 0x3e, 0xa2, 0xbd, 0xc9, 0xd1, 0x74, 0x4c,
|
||||
0xd9, 0xd1, 0x1b, 0x80, 0x27, 0xa0, 0xa1, 0xa2, 0x55, 0x78, 0x5a, 0xe0,
|
||||
0xd1, 0xae, 0x3a, 0x1e, 0xdf, 0xd2, 0xcf, 0xa4, 0x4a, 0x1f, 0x71, 0x55,
|
||||
0x8a, 0xa1, 0xfb, 0x12, 0x81, 0xe8, 0x29, 0x76, 0xc9, 0x92, 0xe5, 0x34,
|
||||
0x37, 0x4d, 0x98, 0x7f, 0xd6, 0x99, 0x6f, 0x0b, 0x13, 0x89, 0x58, 0x58,
|
||||
0x2c, 0x06, 0x09, 0x6b, 0x75, 0x5a, 0x36, 0x9b, 0x9b, 0xd6, 0x37, 0x30,
|
||||
0xf8, 0xf6, 0x81, 0xc1, 0x21, 0x6e, 0xbd, 0xfd, 0x1e, 0x56, 0x3c, 0xb6,
|
||||
0xec, 0x68, 0xc2, 0xc2, 0x0a, 0xc0, 0x79, 0xed, 0xbd, 0x40, 0xd6, 0x93,
|
||||
0xfa, 0xfa, 0x48, 0xb6, 0xdf, 0x32, 0x83, 0x6f, 0xfc, 0xb4, 0x47, 0xbf,
|
||||
0xf9, 0x80, 0x91, 0x62, 0x29, 0x60, 0xc0, 0x37, 0xac, 0x2d, 0xce, 0xd4,
|
||||
0xa3, 0x17, 0x7a, 0xe4, 0x7d, 0xcb, 0xb2, 0x2e, 0x48, 0x36, 0x28, 0x6b,
|
||||
0x4b, 0xa0, 0x25, 0x61, 0xfe, 0x7c, 0x70, 0x44, 0xf0, 0x08, 0xd8, 0xd1,
|
||||
0x35, 0x40, 0x4f, 0x29, 0xc2, 0xa8, 0xc3, 0x17, 0xde, 0x0a, 0xdf, 0xbf,
|
||||
0x60, 0x32, 0x6d, 0xef, 0xdf, 0x4c, 0x57, 0xf7, 0x8b, 0xae, 0x7d, 0x59,
|
||||
0xb2, 0x64, 0x89, 0x05, 0x12, 0x53, 0xa7, 0xb4, 0xce, 0x11, 0x31, 0x6e,
|
||||
0x77, 0xff, 0x18, 0x46, 0x43, 0xab, 0xa0, 0xa9, 0x44, 0x42, 0x67, 0xce,
|
||||
0x98, 0x1c, 0xb5, 0xb7, 0x4f, 0x33, 0x4b, 0xef, 0x79, 0x00, 0xc2, 0x42,
|
||||
0x69, 0xbc, 0x9c, 0xbe, 0xe6, 0x0a, 0xf0, 0xd2, 0x30, 0x5c, 0x10, 0xce,
|
||||
0xb9, 0x6a, 0x07, 0x4f, 0x6c, 0x53, 0xd4, 0x08, 0x87, 0xd6, 0xe5, 0x38,
|
||||
0x7c, 0x4a, 0x8b, 0x4e, 0x5b, 0x78, 0x36, 0x8d, 0xf3, 0x87, 0x48, 0xc4,
|
||||
0x3c, 0x54, 0x15, 0x05, 0x1c, 0x23, 0x08, 0x10, 0x8c, 0xaf, 0x4c, 0xc7,
|
||||
0x4b, 0xf0, 0xf0, 0x6f, 0x7e, 0xce, 0xb6, 0xde, 0x41, 0x56, 0x87, 0x13,
|
||||
0xf8, 0xf5, 0xca, 0x80, 0xd1, 0x2b, 0xb6, 0xd3, 0x9b, 0x15, 0xa8, 0x54,
|
||||
0x18, 0x7e, 0xc9, 0x30, 0x7c, 0xda, 0x21, 0xed, 0x33, 0x26, 0xae, 0x5b,
|
||||
0xbf, 0x51, 0x7f, 0xfe, 0xc3, 0x6b, 0xe4, 0xdc, 0x4b, 0x2e, 0xa7, 0xa9,
|
||||
0x69, 0x82, 0xd9, 0xbe, 0xb3, 0x53, 0x53, 0xc9, 0x18, 0x05, 0x5f, 0xdd,
|
||||
0x0d, 0x9b, 0xb6, 0x6f, 0x06, 0x36, 0xbc, 0x6e, 0x0a, 0x08, 0xb2, 0x50,
|
||||
0x53, 0xa7, 0x7c, 0xe3, 0x53, 0x6d, 0x5c, 0x7b, 0xc7, 0x80, 0xac, 0x5f,
|
||||
0x16, 0x30, 0xad, 0x3e, 0x62, 0x56, 0xa3, 0x61, 0x60, 0x78, 0x94, 0xdd,
|
||||
0x9d, 0x5d, 0xb8, 0x9e, 0x5b, 0xee, 0xb0, 0x1a, 0x54, 0x14, 0xd9, 0x33,
|
||||
0xb1, 0x6a, 0x89, 0x57, 0xd4, 0x30, 0xb1, 0x2a, 0x12, 0x29, 0x14, 0xf4,
|
||||
0xcf, 0xbb, 0x2d, 0xa7, 0x1e, 0xe7, 0xc9, 0x37, 0x3e, 0xd2, 0xaa, 0x77,
|
||||
0x3f, 0xb3, 0x8d, 0xbe, 0xbe, 0x17, 0xa5, 0xe2, 0x44, 0x75, 0x8e, 0x11,
|
||||
0xb9, 0x72, 0xf6, 0x8c, 0xa9, 0x93, 0xbd, 0xaa, 0x74, 0x2a, 0x98, 0x37,
|
||||
0x67, 0x96, 0x93, 0x48, 0xc4, 0x89, 0xc4, 0xd3, 0x5f, 0xdd, 0x72, 0x3d,
|
||||
0x13, 0x5b, 0x9a, 0xb4, 0xfd, 0xd4, 0x7f, 0x62, 0x78, 0x70, 0x68, 0x1d,
|
||||
0xe0, 0xb3, 0x78, 0xb1, 0x61, 0xc9, 0x12, 0xeb, 0xbe, 0x0a, 0xec, 0x70,
|
||||
0x70, 0xdf, 0x9a, 0x86, 0x9c, 0x0f, 0x3f, 0xbd, 0x6f, 0x80, 0xa7, 0x36,
|
||||
0xfb, 0x48, 0xcc, 0x50, 0x0c, 0x3d, 0xc9, 0xfb, 0x90, 0x74, 0x8c, 0xc6,
|
||||
0xe3, 0x71, 0xe2, 0x71, 0x07, 0x11, 0x11, 0x1b, 0x59, 0x22, 0xab, 0x88,
|
||||
0x18, 0x45, 0x0c, 0x68, 0x44, 0xcc, 0x33, 0x32, 0x16, 0xa0, 0xf9, 0xc0,
|
||||
0x60, 0x62, 0xc2, 0xb3, 0xdb, 0xad, 0xfe, 0xe4, 0x37, 0x7d, 0x32, 0xe6,
|
||||
0x0b, 0x24, 0x14, 0xb2, 0x07, 0x6b, 0x74, 0xab, 0x11, 0x59, 0x1a, 0x98,
|
||||
0xd4, 0xd4, 0xb9, 0x93, 0x27, 0xb5, 0xd2, 0x36, 0x71, 0x92, 0xbe, 0xe7,
|
||||
0x53, 0x17, 0xe0, 0x17, 0x72, 0xf8, 0x85, 0x9c, 0xbc, 0xff, 0x13, 0xe7,
|
||||
0x6a, 0x6d, 0x5d, 0xad, 0xde, 0xfb, 0xe8, 0x73, 0xd8, 0xb1, 0xb1, 0x55,
|
||||
0x00, 0x2c, 0xf9, 0xa3, 0x01, 0xac, 0x79, 0x79, 0x2c, 0xbf, 0x68, 0x1c,
|
||||
0xcb, 0x2f, 0x72, 0x40, 0x65, 0x3f, 0x60, 0x71, 0x70, 0x8c, 0x9e, 0x85,
|
||||
0x98, 0x2b, 0x2c, 0x3c, 0x2c, 0xcd, 0x8c, 0x26, 0x47, 0x89, 0x40, 0x44,
|
||||
0xc6, 0x0d, 0x5e, 0xc5, 0x98, 0x32, 0x71, 0x1c, 0x86, 0xa8, 0x55, 0x45,
|
||||
0x55, 0xb1, 0x56, 0x51, 0x6b, 0x51, 0x15, 0xac, 0x8a, 0xaa, 0xb5, 0x62,
|
||||
0x44, 0xd1, 0x50, 0x99, 0xd2, 0x60, 0x58, 0x78, 0x44, 0x15, 0x09, 0x0f,
|
||||
0x08, 0x45, 0x0f, 0xde, 0xcf, 0x95, 0x01, 0xd4, 0x4d, 0xac, 0x9d, 0x50,
|
||||
0x7d, 0x7a, 0x73, 0x7d, 0x95, 0x86, 0x41, 0xc9, 0xb8, 0x1a, 0x91, 0x4e,
|
||||
0xa7, 0x49, 0x26, 0x12, 0xb4, 0x4e, 0x9d, 0x49, 0x65, 0x5d, 0xa3, 0xd9,
|
||||
0xb8, 0x71, 0x0b, 0x68, 0x7e, 0x65, 0xb9, 0x58, 0xa3, 0x1e, 0xcc, 0x0b,
|
||||
0x08, 0x2c, 0x32, 0x70, 0xbb, 0x05, 0x19, 0x1f, 0xe8, 0xd2, 0x03, 0x27,
|
||||
0xbf, 0x0a, 0x2a, 0x3d, 0xc8, 0x0c, 0xee, 0x07, 0xa6, 0xf6, 0x75, 0x2c,
|
||||
0x06, 0xd6, 0x42, 0xf7, 0x40, 0x89, 0xb1, 0xa2, 0x88, 0x3a, 0x06, 0x34,
|
||||
0x40, 0x8c, 0x83, 0x88, 0x20, 0x52, 0x56, 0x80, 0x08, 0xa8, 0x3a, 0xea,
|
||||
0xba, 0x06, 0xb0, 0x52, 0x1e, 0xb8, 0x22, 0xc6, 0xe0, 0x7a, 0x9e, 0x8a,
|
||||
0x29, 0xab, 0x3b, 0xeb, 0x2b, 0xdd, 0x83, 0xbe, 0x46, 0xf6, 0xa0, 0x28,
|
||||
0x78, 0x7c, 0x02, 0xd2, 0x13, 0xdc, 0xd6, 0x19, 0x4f, 0x44, 0x35, 0x13,
|
||||
0xdb, 0x2e, 0xfe, 0xce, 0xcf, 0xa2, 0xd6, 0x86, 0xb4, 0x99, 0xd4, 0x58,
|
||||
0xcb, 0xe4, 0xd6, 0x06, 0x5a, 0x9a, 0x1a, 0x68, 0x69, 0x6c, 0x50, 0xe3,
|
||||
0xc5, 0xdc, 0xce, 0x5d, 0x9d, 0x19, 0xc8, 0x8c, 0xbb, 0xc0, 0xa5, 0x07,
|
||||
0x55, 0x80, 0xc2, 0xd2, 0x68, 0xbc, 0xde, 0x0a, 0xa8, 0x9f, 0x44, 0xaa,
|
||||
0x7a, 0x9a, 0x49, 0xa5, 0x66, 0x1b, 0x37, 0x36, 0xc7, 0x78, 0xb1, 0x76,
|
||||
0x31, 0xee, 0x74, 0x8d, 0x82, 0xa4, 0x3f, 0x3a, 0xf8, 0x0b, 0x32, 0x9b,
|
||||
0x2f, 0xdc, 0x2f, 0x63, 0x63, 0x14, 0x44, 0x00, 0x55, 0x65, 0x2c, 0x6f,
|
||||
0x29, 0xf8, 0x16, 0xa1, 0xfc, 0xab, 0xa0, 0xa8, 0x82, 0xb5, 0x16, 0x55,
|
||||
0x47, 0x8d, 0xb1, 0x02, 0x02, 0xa2, 0xa8, 0x1d, 0x4f, 0x28, 0x18, 0x03,
|
||||
0x22, 0xd8, 0xc8, 0x62, 0xc3, 0x08, 0x01, 0x8a, 0x41, 0xb9, 0x2e, 0x6b,
|
||||
0x01, 0xf7, 0x2f, 0x08, 0x51, 0x03, 0x44, 0x38, 0x89, 0xa3, 0x53, 0x13,
|
||||
0xa6, 0xb4, 0x11, 0x8f, 0x07, 0x5b, 0xfa, 0xc6, 0x9c, 0xcd, 0xfd, 0x79,
|
||||
0x74, 0x5d, 0x1f, 0x46, 0xd7, 0x91, 0xf4, 0x8c, 0xd6, 0x55, 0xc4, 0xa9,
|
||||
0x4a, 0xb9, 0x76, 0x7b, 0xd7, 0xa0, 0x25, 0x35, 0xa3, 0x95, 0xfc, 0x96,
|
||||
0x9e, 0x03, 0x71, 0x40, 0xb9, 0xe2, 0x74, 0x73, 0x83, 0x93, 0x6c, 0xfe,
|
||||
0x17, 0x27, 0x99, 0x5a, 0xe0, 0x18, 0x77, 0x2a, 0xc6, 0x69, 0x31, 0x5e,
|
||||
0xcc, 0x43, 0x0c, 0xc6, 0x29, 0xa3, 0x66, 0x23, 0x82, 0xe3, 0xc5, 0x28,
|
||||
0x25, 0xd2, 0xe7, 0x97, 0x1c, 0xe9, 0x09, 0x8b, 0xa3, 0x7f, 0x48, 0x78,
|
||||
0x09, 0x29, 0x66, 0x76, 0x3e, 0x26, 0x00, 0x7e, 0xe4, 0xc4, 0x2a, 0xe0,
|
||||
0xd8, 0x8e, 0xa4, 0x3e, 0xb3, 0x25, 0x50, 0x5d, 0xaf, 0x82, 0x5a, 0x6c,
|
||||
0x14, 0x8d, 0x9b, 0xbc, 0xa0, 0xaa, 0xe3, 0x16, 0xa0, 0xa0, 0x60, 0x55,
|
||||
0x54, 0xf6, 0xe4, 0x53, 0x54, 0xb1, 0x6a, 0x05, 0x71, 0xd0, 0x28, 0xd2,
|
||||
0xd6, 0x1a, 0xc3, 0xd1, 0xed, 0x49, 0x49, 0xb8, 0xa3, 0xe4, 0xc2, 0xbf,
|
||||
0x00, 0x3f, 0x02, 0xcb, 0x21, 0x5d, 0x7b, 0x8c, 0x13, 0x4f, 0x69, 0x29,
|
||||
0xd3, 0xaf, 0xc6, 0x06, 0x88, 0x11, 0xb0, 0x16, 0x13, 0x4b, 0x10, 0xa8,
|
||||
0x47, 0xf7, 0x58, 0x5e, 0x76, 0xe7, 0xe2, 0x21, 0xc9, 0xda, 0x6a, 0x9c,
|
||||
0xa1, 0x4f, 0x03, 0x9f, 0x83, 0x93, 0x0d, 0x2c, 0xdf, 0xb3, 0x09, 0x2e,
|
||||
0x70, 0xe1, 0xcc, 0x48, 0xe2, 0xb7, 0x7f, 0x37, 0x5e, 0xdf, 0xf6, 0x09,
|
||||
0xc7, 0x73, 0x70, 0x44, 0x10, 0xe3, 0xa8, 0x71, 0xbd, 0xd0, 0x78, 0x71,
|
||||
0xab, 0xe2, 0xa1, 0xc6, 0x95, 0xd0, 0x62, 0xfc, 0x08, 0x82, 0x54, 0xcc,
|
||||
0x86, 0xb6, 0xfb, 0x9b, 0xde, 0xa4, 0x05, 0xdf, 0x2c, 0xe6, 0xb3, 0x90,
|
||||
0xb5, 0x0f, 0xa1, 0xc3, 0x67, 0xc7, 0xaa, 0xdd, 0x7c, 0xde, 0x8f, 0xcc,
|
||||
0x6d, 0x0f, 0x8e, 0xb2, 0x6a, 0x7b, 0x04, 0xae, 0x20, 0xec, 0x53, 0x20,
|
||||
0x02, 0x22, 0x52, 0xde, 0x11, 0x54, 0x54, 0xf7, 0x33, 0x66, 0xa1, 0x6c,
|
||||
0x21, 0x06, 0xc1, 0x88, 0x2a, 0x31, 0x97, 0x35, 0xbb, 0x43, 0xb9, 0x7d,
|
||||
0xf9, 0x88, 0x1d, 0xf3, 0x05, 0x92, 0xf0, 0x42, 0x28, 0x78, 0x8a, 0x85,
|
||||
0xe5, 0xb8, 0xa9, 0xaa, 0x63, 0xc5, 0x75, 0x45, 0x35, 0x14, 0x71, 0x5c,
|
||||
0xac, 0x8d, 0x48, 0xcd, 0x7c, 0x0b, 0x7e, 0xff, 0x76, 0xa2, 0x91, 0x2e,
|
||||
0xc4, 0xb8, 0x98, 0x20, 0x4f, 0x71, 0x6c, 0x00, 0x30, 0xeb, 0x0f, 0x02,
|
||||
0x85, 0x57, 0x06, 0xb0, 0x12, 0x2d, 0x4e, 0x3d, 0xa4, 0xa2, 0xba, 0x2e,
|
||||
0x74, 0x2b, 0x6a, 0xc2, 0x42, 0xd1, 0x77, 0x43, 0x45, 0xc2, 0xc0, 0x37,
|
||||
0xc6, 0x0f, 0x4d, 0x85, 0xe7, 0x53, 0x97, 0x54, 0x69, 0xa9, 0x4b, 0x33,
|
||||
0xa5, 0xa5, 0x4e, 0xa7, 0x4f, 0x3a, 0xdc, 0x59, 0xb3, 0xae, 0x39, 0xea,
|
||||
0xec, 0xcf, 0x32, 0xbb, 0x7d, 0xb2, 0xc5, 0x2f, 0x9e, 0x7a, 0xf7, 0x6f,
|
||||
0xef, 0xff, 0x3f, 0xfd, 0xdb, 0x7a, 0xbe, 0x50, 0xdb, 0x94, 0xb4, 0x97,
|
||||
0xbe, 0xbf, 0xd1, 0x61, 0xe9, 0x80, 0xae, 0xdc, 0x15, 0x11, 0x45, 0x01,
|
||||
0x7e, 0xc9, 0x27, 0xe5, 0xba, 0xb8, 0x8e, 0xa0, 0x08, 0x6a, 0x51, 0xa1,
|
||||
0xbc, 0x27, 0xd8, 0x71, 0x4b, 0x40, 0x0c, 0x8e, 0xe3, 0x50, 0xf2, 0x03,
|
||||
0x2d, 0x96, 0x42, 0xc4, 0x8f, 0x38, 0xb1, 0xdd, 0xd5, 0x4b, 0x3e, 0xd0,
|
||||
0xc0, 0xcd, 0x0f, 0xed, 0xa0, 0xaf, 0xe7, 0xc0, 0xf5, 0xbf, 0xc4, 0x02,
|
||||
0x15, 0x4e, 0xbc, 0xf2, 0x50, 0xb5, 0x21, 0x28, 0x62, 0xfd, 0x12, 0xe2,
|
||||
0xb8, 0x78, 0x35, 0xcd, 0x94, 0xba, 0x37, 0x80, 0x0a, 0x62, 0x5c, 0xa2,
|
||||
0x30, 0x70, 0x34, 0x0a, 0x14, 0xec, 0x8a, 0x72, 0xf1, 0xe5, 0x76, 0x9f,
|
||||
0x02, 0x52, 0x93, 0x17, 0x24, 0x6a, 0x5b, 0xae, 0xb0, 0x98, 0x43, 0x8b,
|
||||
0x25, 0xdf, 0x78, 0x26, 0xeb, 0xb5, 0xd7, 0x39, 0x32, 0x73, 0x72, 0xa3,
|
||||
0x4c, 0x6e, 0x69, 0xd0, 0xc9, 0x4d, 0xb5, 0x52, 0x5d, 0x59, 0x01, 0x62,
|
||||
0x28, 0x16, 0x4b, 0x6c, 0xef, 0x1a, 0x66, 0xdb, 0xd6, 0x9d, 0x1c, 0x73,
|
||||
0xd8, 0x2c, 0xf9, 0xe0, 0x8c, 0xa9, 0x74, 0xf6, 0x0e, 0x3b, 0x6e, 0x2c,
|
||||
0x11, 0x6d, 0xda, 0xb6, 0x63, 0x61, 0xff, 0xb6, 0xad, 0x47, 0x8e, 0x96,
|
||||
0x2a, 0x73, 0x97, 0xdc, 0xb0, 0xbb, 0xea, 0x89, 0xed, 0x82, 0xa9, 0xf1,
|
||||
0x58, 0xbe, 0xb1, 0x48, 0xc9, 0x2a, 0x6f, 0x3f, 0xd4, 0xc7, 0xb7, 0x0e,
|
||||
0x13, 0xaa, 0xe2, 0x65, 0x20, 0xa4, 0x80, 0x8d, 0x04, 0xc7, 0x51, 0x03,
|
||||
0x58, 0x15, 0x92, 0x31, 0xcb, 0x3d, 0xbb, 0x63, 0x6c, 0x1b, 0x69, 0xc2,
|
||||
0x49, 0x0b, 0xf7, 0x3e, 0x1b, 0x52, 0x18, 0xeb, 0x64, 0x20, 0x67, 0xa0,
|
||||
0x32, 0xda, 0x1f, 0x08, 0x8d, 0xef, 0x8a, 0xa9, 0x59, 0x5e, 0x2a, 0xdd,
|
||||
0x66, 0x43, 0xdf, 0xaa, 0x0d, 0x4c, 0xa2, 0x75, 0x16, 0x6a, 0x43, 0xfc,
|
||||
0x9e, 0xcd, 0xc4, 0x9b, 0xdb, 0x29, 0x06, 0xcf, 0xa3, 0x61, 0x49, 0x35,
|
||||
0x0a, 0x1d, 0x0d, 0xc3, 0x01, 0xa4, 0xb4, 0x7e, 0x7f, 0x37, 0xee, 0x02,
|
||||
0xae, 0x53, 0x3d, 0xe1, 0x06, 0xa7, 0x79, 0xd6, 0x51, 0xf5, 0x09, 0xac,
|
||||
0x8c, 0xec, 0xe6, 0xc2, 0x8f, 0x9e, 0x65, 0xe6, 0xce, 0x69, 0xd7, 0x15,
|
||||
0xcf, 0x6e, 0xd4, 0xde, 0x81, 0x21, 0x96, 0xae, 0x78, 0x9e, 0x5d, 0xbb,
|
||||
0xba, 0x34, 0x9f, 0x1b, 0xa4, 0xbb, 0x6b, 0x88, 0x43, 0x26, 0x1a, 0x7a,
|
||||
0xfa, 0x32, 0xfc, 0xa7, 0xa9, 0xe7, 0x5f, 0x2e, 0xfd, 0x1c, 0xa5, 0x92,
|
||||
0x4f, 0x53, 0x5d, 0xb5, 0x44, 0x16, 0x03, 0xc9, 0xda, 0xba, 0x54, 0x14,
|
||||
0xdd, 0x70, 0xf1, 0x34, 0xbe, 0x75, 0xdb, 0x20, 0xdf, 0x5a, 0xa6, 0x58,
|
||||
0x2f, 0xc6, 0xa6, 0xee, 0x11, 0x66, 0x3c, 0x71, 0x37, 0xa5, 0xc0, 0xf2,
|
||||
0xeb, 0xf5, 0x39, 0x30, 0x5a, 0x36, 0x4d, 0x63, 0x74, 0xcf, 0x70, 0xac,
|
||||
0x15, 0xa2, 0x52, 0x9e, 0xbc, 0x5b, 0x49, 0x55, 0x5d, 0x03, 0xc3, 0x9d,
|
||||
0xfd, 0xfc, 0xc3, 0xdb, 0x92, 0x7c, 0xef, 0xdc, 0x7a, 0x7e, 0xf7, 0xe1,
|
||||
0xad, 0x74, 0x77, 0xbf, 0x60, 0xfd, 0x1b, 0x58, 0x6e, 0x49, 0x35, 0x1c,
|
||||
0x65, 0x92, 0x95, 0xc6, 0x86, 0x45, 0x1f, 0xc4, 0x8d, 0x35, 0x4e, 0xc3,
|
||||
0xe6, 0x86, 0x50, 0x55, 0xdc, 0x74, 0x3d, 0x1a, 0x85, 0x20, 0x46, 0x6d,
|
||||
0x18, 0x82, 0xda, 0x4d, 0x8c, 0x75, 0x0e, 0xed, 0x4f, 0xac, 0xb8, 0x55,
|
||||
0x13, 0xe7, 0x54, 0xe5, 0xf2, 0xa5, 0x89, 0x1f, 0x3e, 0x69, 0x7a, 0x74,
|
||||
0xd4, 0xbc, 0x99, 0x54, 0xd7, 0x35, 0x88, 0x2b, 0xe8, 0xd9, 0xe7, 0x5c,
|
||||
0x4a, 0x4b, 0x6d, 0x9e, 0x2d, 0x9d, 0x45, 0xce, 0x3a, 0x31, 0xad, 0xbb,
|
||||
0x47, 0x8b, 0x7c, 0xe4, 0xa4, 0x06, 0x9e, 0x7c, 0x3e, 0xc1, 0xe4, 0x09,
|
||||
0xae, 0xf4, 0x54, 0x3b, 0xfa, 0x48, 0x67, 0x8a, 0x8a, 0x98, 0x81, 0x52,
|
||||
0xc8, 0xea, 0x3f, 0xaf, 0xd1, 0x64, 0xcc, 0x75, 0xc0, 0xd6, 0x0f, 0xe5,
|
||||
0x8d, 0x9e, 0x73, 0xd5, 0x16, 0x56, 0x76, 0x25, 0x91, 0x30, 0x43, 0xc7,
|
||||
0x94, 0x46, 0xb6, 0x75, 0x8d, 0x70, 0x68, 0xab, 0x47, 0xa9, 0x58, 0xe0,
|
||||
0x17, 0xcb, 0x47, 0x20, 0xee, 0x82, 0xeb, 0xe1, 0xa6, 0xaa, 0xca, 0xe3,
|
||||
0x17, 0x21, 0x0a, 0x8a, 0x58, 0xeb, 0x91, 0x4e, 0xa7, 0x29, 0xe5, 0x32,
|
||||
0x78, 0x35, 0x95, 0xdc, 0xf9, 0xf4, 0x30, 0xfd, 0xdd, 0x5b, 0xe9, 0xc9,
|
||||
0x08, 0x54, 0x7a, 0xba, 0xcf, 0x02, 0xca, 0x7e, 0x5c, 0x92, 0x55, 0x47,
|
||||
0x3b, 0x5e, 0x9c, 0x52, 0x69, 0x14, 0x71, 0x5c, 0xfc, 0xfe, 0x1d, 0xb8,
|
||||
0x95, 0xf5, 0x38, 0x89, 0x2a, 0x72, 0xeb, 0x1f, 0x41, 0xa3, 0x00, 0x71,
|
||||
0x5d, 0x1b, 0x05, 0x01, 0xc0, 0xca, 0xfd, 0x23, 0x47, 0x00, 0x33, 0xd6,
|
||||
0xb9, 0x36, 0x5b, 0x5b, 0x9d, 0x1c, 0x3a, 0xea, 0x88, 0x43, 0x9d, 0x6d,
|
||||
0xfd, 0x39, 0xdd, 0xb0, 0xbd, 0x8b, 0x15, 0x7f, 0x5e, 0xcb, 0xd8, 0xc0,
|
||||
0x28, 0x67, 0x2f, 0xac, 0x95, 0x8e, 0x89, 0x95, 0xb2, 0xab, 0xd7, 0x10,
|
||||
0x99, 0x04, 0xd7, 0xdc, 0x39, 0xcc, 0xa6, 0x7e, 0xcb, 0xbd, 0xab, 0x03,
|
||||
0x7e, 0xbf, 0xaa, 0x28, 0x93, 0x1b, 0x2b, 0x11, 0x71, 0xe9, 0xec, 0xee,
|
||||
0x23, 0x08, 0x23, 0x75, 0x3d, 0xc7, 0x22, 0x5e, 0x43, 0x5d, 0x2a, 0xb2,
|
||||
0x37, 0x7f, 0x79, 0x0a, 0x8b, 0x8e, 0xf1, 0xd0, 0x4c, 0x91, 0x99, 0xcd,
|
||||
0x35, 0x34, 0x54, 0x18, 0x2e, 0xbd, 0x65, 0x33, 0x5f, 0xbf, 0x7b, 0x90,
|
||||
0xcb, 0x3f, 0xff, 0x41, 0x1a, 0xaa, 0x92, 0x72, 0xc9, 0x47, 0xdf, 0x4d,
|
||||
0xbd, 0x93, 0x27, 0xec, 0x1e, 0xe0, 0x33, 0xc7, 0x66, 0xf8, 0xe5, 0x47,
|
||||
0x0b, 0x48, 0x00, 0x1a, 0x94, 0x30, 0x46, 0x08, 0xc6, 0x0a, 0xbc, 0x67,
|
||||
0xbe, 0xc3, 0x4d, 0x97, 0x4d, 0x95, 0xe6, 0xb4, 0x85, 0xcc, 0xfe, 0x6e,
|
||||
0x60, 0x8e, 0x8e, 0xc7, 0x0e, 0x93, 0xc4, 0x38, 0x68, 0x18, 0x89, 0x00,
|
||||
0xe1, 0x48, 0x37, 0x4e, 0x45, 0x2d, 0xb9, 0x8d, 0x8f, 0x12, 0x95, 0x72,
|
||||
0xe0, 0x38, 0x68, 0x64, 0x45, 0x43, 0x1f, 0x6c, 0xf4, 0xf4, 0xc1, 0x28,
|
||||
0x2c, 0x3f, 0x88, 0xa2, 0xae, 0xed, 0xbb, 0xba, 0x69, 0x4c, 0xbb, 0x1a,
|
||||
0xe4, 0x46, 0x49, 0xa5, 0xd2, 0x4c, 0x9f, 0x3b, 0x95, 0x6f, 0xfc, 0xb8,
|
||||
0x5b, 0xab, 0xea, 0x92, 0xba, 0x7d, 0x40, 0x99, 0x33, 0xbd, 0x82, 0x7f,
|
||||
0x38, 0xb6, 0x82, 0xd6, 0x5a, 0x91, 0x2f, 0xbe, 0xdd, 0x51, 0x82, 0x8c,
|
||||
0x36, 0xd4, 0x55, 0xab, 0x97, 0xaa, 0x08, 0xab, 0xea, 0x9b, 0xec, 0xd4,
|
||||
0xf6, 0x76, 0x67, 0x2c, 0x57, 0x32, 0xb8, 0x0c, 0x0f, 0x15, 0x8c, 0x5c,
|
||||
0x74, 0x7d, 0x17, 0xbf, 0x7a, 0xba, 0xa4, 0xb1, 0x9a, 0x04, 0x33, 0x66,
|
||||
0xce, 0x60, 0x42, 0x7d, 0x2d, 0x26, 0xe5, 0x11, 0x4f, 0xb8, 0xfc, 0xfa,
|
||||
0xf7, 0x8f, 0x4b, 0xa8, 0xaa, 0xf7, 0xfd, 0xf1, 0x49, 0x42, 0x35, 0xe0,
|
||||
0x08, 0xa1, 0xd4, 0x91, 0x0b, 0xdb, 0x10, 0xa7, 0x8c, 0x07, 0xac, 0x55,
|
||||
0x9c, 0x94, 0xcb, 0xbd, 0xcf, 0xf9, 0x5c, 0x74, 0xdd, 0x4e, 0xed, 0xcb,
|
||||
0x19, 0xa8, 0xdc, 0x1f, 0x07, 0xfc, 0xd1, 0x00, 0x44, 0x41, 0xa9, 0x4b,
|
||||
0x15, 0x15, 0xc7, 0xa8, 0x8d, 0x2c, 0x12, 0x4b, 0xe1, 0x24, 0xd2, 0x10,
|
||||
0x85, 0x65, 0xf0, 0x05, 0xd8, 0x28, 0x70, 0x34, 0x2c, 0x5a, 0xac, 0x3e,
|
||||
0xb3, 0xff, 0x06, 0xb8, 0x97, 0x59, 0x19, 0x1d, 0x1e, 0xdb, 0xee, 0x6a,
|
||||
0xc0, 0xba, 0xf5, 0x5b, 0x78, 0xec, 0xf1, 0x15, 0x78, 0xae, 0x61, 0x6e,
|
||||
0x47, 0x3b, 0x50, 0x62, 0x4a, 0x55, 0x91, 0x69, 0xb5, 0x05, 0xfe, 0xb4,
|
||||
0xaa, 0x8f, 0x67, 0x36, 0x67, 0x58, 0xbd, 0x03, 0x6e, 0x78, 0xb4, 0x82,
|
||||
0x05, 0x27, 0x1c, 0xc7, 0xd1, 0x47, 0x1d, 0xe1, 0x3c, 0xfe, 0xc4, 0xaa,
|
||||
0xd8, 0x8a, 0x95, 0xcf, 0xba, 0x77, 0xdc, 0x75, 0x5f, 0xf0, 0xec, 0x33,
|
||||
0xcf, 0xdd, 0x0a, 0xde, 0x1d, 0x55, 0x31, 0x5b, 0xf5, 0xf5, 0x8f, 0xb7,
|
||||
0xf2, 0xf6, 0xc3, 0xe2, 0x12, 0x14, 0x7c, 0x86, 0x46, 0x33, 0x8c, 0x65,
|
||||
0xf3, 0x58, 0x84, 0xd0, 0x0f, 0x69, 0xa8, 0x4a, 0x72, 0xca, 0x91, 0x1d,
|
||||
0x4c, 0x6f, 0x9b, 0xc0, 0x61, 0x33, 0xda, 0x30, 0x09, 0xf8, 0x8f, 0x3f,
|
||||
0x8e, 0xf2, 0x89, 0x9f, 0x74, 0x63, 0x83, 0x3c, 0xa5, 0x7c, 0x0e, 0x41,
|
||||
0x89, 0x4a, 0x96, 0x53, 0x66, 0xc7, 0xf8, 0xd7, 0x4f, 0x4f, 0x94, 0xfa,
|
||||
0x0a, 0x85, 0xcc, 0xfe, 0x50, 0xb8, 0xbc, 0x04, 0x74, 0xa4, 0xe7, 0xf6,
|
||||
0x20, 0x33, 0x28, 0xb1, 0x8a, 0x3a, 0x23, 0x1a, 0x21, 0xc6, 0xe0, 0x0f,
|
||||
0xef, 0x46, 0xed, 0xf8, 0x18, 0x45, 0xc2, 0x20, 0x9f, 0x37, 0x44, 0xd1,
|
||||
0x5a, 0x72, 0x89, 0x0d, 0x07, 0x12, 0xab, 0x2e, 0x80, 0x89, 0xc2, 0x5d,
|
||||
0xbd, 0x83, 0x19, 0xdb, 0xdc, 0xd6, 0x12, 0xd4, 0xd4, 0xd6, 0xea, 0x48,
|
||||
0xae, 0x28, 0x27, 0x1c, 0x7f, 0xac, 0xb3, 0x61, 0xd3, 0x36, 0x1e, 0xdc,
|
||||
0xe2, 0x33, 0xb1, 0xb5, 0x83, 0x63, 0x66, 0xd6, 0x90, 0x4c, 0xa6, 0x38,
|
||||
0x2e, 0xe1, 0x10, 0x46, 0x96, 0x5c, 0xb1, 0xc4, 0x5d, 0x77, 0x3f, 0x30,
|
||||
0xbc, 0x73, 0xdd, 0xc6, 0x6b, 0xa1, 0xf4, 0x14, 0x0c, 0x6f, 0x01, 0x36,
|
||||
0x91, 0x9e, 0xdb, 0x91, 0xf3, 0x03, 0xe7, 0x27, 0xbf, 0xed, 0xd3, 0xa7,
|
||||
0xb7, 0x28, 0x1a, 0x4b, 0x90, 0x19, 0x1b, 0x25, 0x08, 0x23, 0x3c, 0xd7,
|
||||
0x70, 0xea, 0x82, 0x99, 0xac, 0xdb, 0xb2, 0x5b, 0x8d, 0x29, 0xc3, 0xe1,
|
||||
0x42, 0xd1, 0xe7, 0x84, 0x79, 0x53, 0x78, 0x74, 0xf5, 0x36, 0xe6, 0xb6,
|
||||
0x4f, 0xe1, 0xa4, 0x63, 0xe6, 0x71, 0xdf, 0xf2, 0x15, 0xec, 0xe8, 0xcf,
|
||||
0xe2, 0xc6, 0x2b, 0x59, 0xb5, 0xcd, 0xe7, 0xe6, 0xbb, 0x07, 0x74, 0xb4,
|
||||
0x28, 0x90, 0xb0, 0xb2, 0x2f, 0x18, 0x5a, 0x1a, 0xc1, 0x62, 0x43, 0xb4,
|
||||
0xe4, 0xfe, 0x7c, 0xf7, 0xe6, 0xdf, 0x55, 0xcd, 0x38, 0xe2, 0x9d, 0x2a,
|
||||
0x4e, 0x50, 0x1a, 0xd8, 0xe5, 0x04, 0x43, 0xbb, 0x31, 0xb1, 0x24, 0x36,
|
||||
0x0a, 0x43, 0x7f, 0x74, 0x38, 0x66, 0x0b, 0xa3, 0x16, 0xb5, 0x17, 0xc3,
|
||||
0x5a, 0xff, 0xc0, 0xf3, 0x46, 0x2e, 0x80, 0xcd, 0x67, 0x1e, 0x7d, 0xee,
|
||||
0xf9, 0x0d, 0xe6, 0xe8, 0xd4, 0x91, 0xa9, 0xbe, 0xfe, 0x41, 0x06, 0x07,
|
||||
0x87, 0xf0, 0x4b, 0x45, 0x7b, 0xf4, 0x91, 0x73, 0x29, 0x15, 0x8b, 0x0c,
|
||||
0x8e, 0x64, 0xe9, 0xea, 0xed, 0x67, 0x70, 0x34, 0xcb, 0x48, 0xb6, 0xa4,
|
||||
0xc5, 0x50, 0x23, 0x6b, 0xf1, 0x18, 0xe9, 0x5d, 0x0b, 0x3d, 0xff, 0xb6,
|
||||
0x1f, 0x8e, 0x16, 0xd1, 0xc0, 0x8b, 0xb9, 0xaa, 0x27, 0x1f, 0x51, 0x25,
|
||||
0x9b, 0xfb, 0x33, 0x3c, 0xb9, 0xcb, 0xe3, 0xce, 0xbb, 0x1e, 0xa6, 0x65,
|
||||
0xfa, 0x44, 0xa6, 0x4f, 0x6a, 0xc4, 0x71, 0x3c, 0x44, 0xca, 0x80, 0x47,
|
||||
0xa4, 0x8c, 0x0a, 0xbd, 0x58, 0x8c, 0xb8, 0xe7, 0x31, 0x3a, 0x32, 0xc2,
|
||||
0xaa, 0xd5, 0x1b, 0xc8, 0x8c, 0x8e, 0x62, 0xc4, 0x60, 0x43, 0x65, 0x6a,
|
||||
0x83, 0xe1, 0xd4, 0x23, 0xab, 0xb8, 0xe6, 0xde, 0x3c, 0xa5, 0xbf, 0x40,
|
||||
0x82, 0x4b, 0xca, 0x41, 0x74, 0x6e, 0xf0, 0x9f, 0x33, 0xbb, 0xd6, 0xae,
|
||||
0x48, 0xd6, 0xb6, 0xd4, 0x47, 0xc5, 0x8c, 0x86, 0xc5, 0x82, 0x50, 0x2a,
|
||||
0xd8, 0xc8, 0x0f, 0x62, 0xb6, 0x98, 0xeb, 0x73, 0x08, 0xce, 0x8d, 0xc6,
|
||||
0xb6, 0x2c, 0xdb, 0x0b, 0x9d, 0x0f, 0x00, 0x42, 0x02, 0x83, 0x0f, 0x3e,
|
||||
0xf1, 0xd0, 0xa3, 0xe7, 0x3c, 0xfb, 0xdc, 0xf3, 0x67, 0x14, 0x8b, 0x85,
|
||||
0xcd, 0x8c, 0x0e, 0xf7, 0x53, 0xdb, 0x7c, 0x15, 0xe9, 0x6a, 0x21, 0x0a,
|
||||
0xc1, 0xf1, 0x8c, 0x18, 0x83, 0x63, 0xc0, 0x11, 0x47, 0x63, 0xae, 0x15,
|
||||
0x5b, 0x2a, 0x0e, 0x85, 0x71, 0xf7, 0xc7, 0x36, 0x77, 0xb2, 0x0b, 0xfd,
|
||||
0x06, 0xd6, 0x5a, 0x81, 0xd0, 0x43, 0xd4, 0x2a, 0xec, 0xee, 0x0f, 0x18,
|
||||
0xcb, 0x59, 0x25, 0x88, 0xe4, 0xad, 0xa7, 0xce, 0xa5, 0xb1, 0xc2, 0xe5,
|
||||
0xce, 0x3f, 0x6d, 0xe1, 0x90, 0x49, 0x13, 0x40, 0x04, 0x23, 0x7b, 0x59,
|
||||
0x00, 0xc4, 0x38, 0x38, 0x02, 0x82, 0xd0, 0xd3, 0xd3, 0x87, 0x0d, 0x7c,
|
||||
0x54, 0x63, 0x20, 0x30, 0x56, 0x84, 0xdd, 0x7d, 0x79, 0xc2, 0x83, 0x13,
|
||||
0xe1, 0x65, 0x53, 0x2e, 0x76, 0xef, 0x44, 0x5b, 0xfa, 0xc4, 0x98, 0x09,
|
||||
0xe2, 0x7a, 0x61, 0xcc, 0x8b, 0x69, 0x29, 0x33, 0xe2, 0xda, 0xfc, 0xd8,
|
||||
0x6f, 0x30, 0xee, 0x05, 0xd1, 0xc8, 0x96, 0x1d, 0x2f, 0x76, 0xd2, 0xcc,
|
||||
0xdd, 0x5b, 0x49, 0xb0, 0xeb, 0xa7, 0xc5, 0xde, 0x5d, 0x3f, 0xdd, 0xcb,
|
||||
0xec, 0xc4, 0x27, 0x9e, 0x16, 0xab, 0xa8, 0x3c, 0xc3, 0x06, 0x05, 0x34,
|
||||
0x28, 0x59, 0x70, 0xca, 0x7c, 0xaa, 0xe3, 0x68, 0x50, 0xc8, 0xb9, 0xd1,
|
||||
0x60, 0xff, 0xe7, 0x08, 0x76, 0xfd, 0x12, 0xd6, 0x3b, 0x80, 0xbf, 0x8f,
|
||||
0xa9, 0x55, 0xb1, 0x56, 0xc9, 0x14, 0x42, 0x42, 0x44, 0x24, 0x1f, 0xc8,
|
||||
0xff, 0x98, 0xeb, 0xe8, 0x61, 0x4d, 0xf0, 0xcb, 0x07, 0x43, 0xc2, 0x30,
|
||||
0xa0, 0x58, 0x28, 0xe2, 0xb8, 0x0e, 0x82, 0x62, 0xa3, 0x90, 0x20, 0xf0,
|
||||
0x11, 0x51, 0xe2, 0xae, 0xc1, 0x88, 0xc5, 0x71, 0x04, 0xc7, 0x0a, 0xd6,
|
||||
0x08, 0x7e, 0xa8, 0x8c, 0xe5, 0xca, 0x31, 0x04, 0xae, 0xf7, 0x62, 0x4c,
|
||||
0x50, 0xb3, 0x13, 0x4b, 0xb5, 0x18, 0xd7, 0xc3, 0x86, 0x86, 0xb0, 0x98,
|
||||
0x37, 0xd6, 0x2f, 0x15, 0x10, 0x73, 0x21, 0x23, 0xeb, 0x76, 0x00, 0x1e,
|
||||
0x10, 0xbc, 0x4c, 0x66, 0x68, 0x91, 0xc3, 0x22, 0x60, 0xe9, 0x56, 0x03,
|
||||
0xd3, 0x6d, 0x90, 0x59, 0xf9, 0xc9, 0x20, 0x3b, 0xfa, 0x1e, 0xc4, 0x7c,
|
||||
0x30, 0x5e, 0xdf, 0x7a, 0x9a, 0x71, 0x9c, 0xd0, 0x86, 0xbe, 0x83, 0xb5,
|
||||
0x2a, 0xc6, 0x85, 0x54, 0x3c, 0x64, 0x14, 0x43, 0xb9, 0xd0, 0x3e, 0x46,
|
||||
0xc8, 0x87, 0xea, 0x34, 0x72, 0xda, 0xfc, 0x94, 0x3e, 0xdf, 0x99, 0x51,
|
||||
0xd5, 0x88, 0x2b, 0xee, 0x1a, 0xc0, 0x73, 0x1d, 0x6c, 0x31, 0xe2, 0xbe,
|
||||
0x3f, 0xac, 0xa1, 0xa3, 0x63, 0x32, 0x9e, 0xeb, 0x00, 0x42, 0xc9, 0xf7,
|
||||
0x79, 0xf8, 0xd1, 0x75, 0x20, 0x86, 0x9d, 0xa5, 0x51, 0x44, 0x94, 0x62,
|
||||
0x31, 0x00, 0x53, 0x9e, 0xf2, 0xe9, 0x73, 0x3d, 0x39, 0x6d, 0x7e, 0x85,
|
||||
0xa6, 0x6e, 0xcb, 0x90, 0xcb, 0x04, 0x07, 0xe7, 0x04, 0x63, 0xf5, 0x35,
|
||||
0x1a, 0x85, 0x95, 0x41, 0x7e, 0x4c, 0x6d, 0x50, 0x54, 0xe3, 0xc6, 0x8c,
|
||||
0x6a, 0xb8, 0x91, 0xd1, 0x0d, 0x3b, 0xc7, 0x27, 0x26, 0x7c, 0x05, 0xa9,
|
||||
0xb1, 0xa5, 0xd1, 0xf8, 0x38, 0x22, 0x58, 0x09, 0x39, 0xfa, 0x80, 0x9b,
|
||||
0xa8, 0xeb, 0x58, 0x13, 0xe6, 0xc7, 0xfe, 0xe4, 0xa5, 0xab, 0x8d, 0x38,
|
||||
0x4e, 0x19, 0xbe, 0x8a, 0x40, 0xa4, 0xb5, 0xe5, 0xf0, 0xad, 0xef, 0x05,
|
||||
0x21, 0xb5, 0x57, 0x27, 0x3a, 0x9a, 0x43, 0x17, 0x7c, 0xb1, 0xdb, 0xe4,
|
||||
0x7c, 0x47, 0x6b, 0x5a, 0x92, 0xcc, 0x6c, 0x18, 0xc6, 0x60, 0xe9, 0x68,
|
||||
0x8a, 0x83, 0x2a, 0x15, 0xa9, 0x91, 0xbd, 0x61, 0xb2, 0x55, 0x68, 0xab,
|
||||
0x4e, 0x21, 0x40, 0x64, 0x43, 0x44, 0x45, 0x8c, 0x89, 0x69, 0x88, 0x61,
|
||||
0xcd, 0xf6, 0x3c, 0xff, 0xfb, 0xa1, 0x92, 0xfe, 0xea, 0xb1, 0x0c, 0x99,
|
||||
0x82, 0x83, 0x5b, 0xa9, 0x36, 0x2c, 0xbe, 0x90, 0x0a, 0x2b, 0x2b, 0x3f,
|
||||
0xd6, 0x11, 0xaf, 0x9e, 0xe0, 0x19, 0xcf, 0x0b, 0xac, 0xe7, 0x69, 0x69,
|
||||
0x74, 0xd0, 0x6a, 0xc9, 0x5f, 0x56, 0xee, 0xdf, 0xc9, 0xee, 0x1e, 0xd0,
|
||||
0xf3, 0xd7, 0xe6, 0x06, 0x0d, 0x2c, 0x70, 0x18, 0x5a, 0xb9, 0x52, 0xeb,
|
||||
0x0f, 0x5d, 0xa3, 0x51, 0x78, 0x28, 0x82, 0x15, 0xe3, 0xa9, 0x18, 0x07,
|
||||
0x1c, 0xa9, 0x3b, 0x38, 0x2b, 0x6c, 0x4d, 0x6d, 0x95, 0xe8, 0x39, 0x27,
|
||||
0xb8, 0xf4, 0x47, 0x29, 0xf3, 0xbd, 0x73, 0xd2, 0xda, 0x54, 0x55, 0x82,
|
||||
0x20, 0xdc, 0x67, 0xb5, 0x51, 0xae, 0xcc, 0x76, 0x88, 0x96, 0x3f, 0x1d,
|
||||
0x19, 0x0f, 0x86, 0x9c, 0x7d, 0xeb, 0xda, 0x83, 0xfe, 0xb1, 0x0a, 0x2e,
|
||||
0xfb, 0x99, 0x27, 0x6d, 0xf1, 0x9c, 0xde, 0xfc, 0x88, 0xb5, 0xdd, 0xfd,
|
||||
0xce, 0x01, 0x0c, 0x56, 0x99, 0xd4, 0xa0, 0xa2, 0x6a, 0x4b, 0xbe, 0x7f,
|
||||
0xc7, 0xa0, 0x9b, 0x48, 0xd5, 0x6a, 0x14, 0x86, 0x2a, 0xae, 0x01, 0x7d,
|
||||
0xec, 0xd5, 0x26, 0x47, 0x2d, 0xa4, 0x0d, 0x10, 0x5a, 0x3f, 0xff, 0x53,
|
||||
0xd5, 0xda, 0xab, 0x8d, 0x91, 0x92, 0x0d, 0x7d, 0xcf, 0x16, 0x73, 0x23,
|
||||
0x88, 0xdc, 0x7f, 0x20, 0xa8, 0x58, 0x0c, 0xe6, 0x4a, 0xc1, 0xc7, 0xe2,
|
||||
0x3e, 0xdd, 0xe9, 0x72, 0xc7, 0xa5, 0xf1, 0x28, 0xec, 0xde, 0x2d, 0x67,
|
||||
0x7f, 0x5f, 0xe9, 0xca, 0x19, 0x1c, 0xd7, 0xec, 0xf3, 0xcf, 0x7b, 0x02,
|
||||
0x61, 0x1d, 0xa7, 0x51, 0x54, 0xf7, 0x86, 0xc4, 0x91, 0x0a, 0xcd, 0x29,
|
||||
0xcb, 0xf7, 0x3f, 0x0c, 0xdf, 0x7d, 0x7f, 0xb5, 0x7e, 0xe4, 0x7a, 0x31,
|
||||
0x56, 0x11, 0x57, 0xa2, 0xe2, 0x57, 0xc1, 0x2c, 0x79, 0x41, 0x1f, 0x81,
|
||||
0xe1, 0x0d, 0xab, 0x9d, 0xb6, 0xb7, 0x6c, 0x4f, 0xb7, 0xb5, 0xd7, 0x67,
|
||||
0x76, 0x6f, 0x8c, 0x85, 0x83, 0x7d, 0xd7, 0xd3, 0x6a, 0xee, 0x63, 0x33,
|
||||
0xe6, 0xa5, 0x66, 0xff, 0x15, 0x24, 0x46, 0x96, 0x47, 0x80, 0xe0, 0xd6,
|
||||
0xdd, 0xe2, 0x0f, 0xf5, 0xb4, 0x99, 0x64, 0xf2, 0x8b, 0x95, 0x6d, 0x33,
|
||||
0xa5, 0x34, 0x32, 0x64, 0xa2, 0xed, 0x7f, 0xee, 0x2d, 0xf3, 0x84, 0x4b,
|
||||
0xc7, 0x69, 0xb1, 0xc5, 0xba, 0x84, 0x25, 0x96, 0xec, 0xc6, 0xf5, 0x7e,
|
||||
0x76, 0xfa, 0xd5, 0xb1, 0xd9, 0xc9, 0xcf, 0x37, 0x26, 0x47, 0xe5, 0xb4,
|
||||
0xff, 0x10, 0x1e, 0x5a, 0x93, 0x86, 0x64, 0x04, 0x05, 0x1f, 0x2a, 0xaa,
|
||||
0x5e, 0xc8, 0xa9, 0xee, 0x65, 0x04, 0xca, 0x8b, 0x0b, 0xa4, 0xbc, 0x6a,
|
||||
0x33, 0x96, 0xee, 0x5c, 0x91, 0x87, 0xff, 0x75, 0x54, 0x4d, 0xe4, 0x69,
|
||||
0x6f, 0xa7, 0xbd, 0x05, 0x36, 0xae, 0x28, 0x0f, 0x7e, 0xb1, 0x81, 0xb5,
|
||||
0xce, 0xbe, 0xbd, 0x27, 0x31, 0xd1, 0x4d, 0x54, 0xcc, 0xd6, 0x28, 0x44,
|
||||
0xa3, 0xc0, 0x12, 0x46, 0x3f, 0x62, 0xf3, 0xd6, 0xd2, 0x2b, 0xc9, 0x7d,
|
||||
0xbe, 0x5c, 0x6e, 0xb0, 0xdc, 0xbb, 0xe1, 0x95, 0xa3, 0x64, 0x37, 0x5f,
|
||||
0x02, 0xec, 0x50, 0xb5, 0x9a, 0xa8, 0x6b, 0xae, 0x22, 0x3d, 0xed, 0xf2,
|
||||
0x32, 0x18, 0x61, 0xfc, 0x59, 0x62, 0xa1, 0x7e, 0x56, 0x45, 0xf3, 0x9c,
|
||||
0x9b, 0x5a, 0x26, 0xc5, 0xe7, 0x34, 0xc4, 0xfd, 0x88, 0x61, 0xdf, 0xec,
|
||||
0x18, 0x36, 0x38, 0xf5, 0x1e, 0xb1, 0xab, 0xae, 0xc2, 0x7d, 0xef, 0x3b,
|
||||
0xf1, 0x82, 0x11, 0x3c, 0xd7, 0x27, 0x56, 0x21, 0xc4, 0x12, 0x4a, 0x2c,
|
||||
0xa9, 0xc4, 0x12, 0x96, 0x58, 0x52, 0x25, 0x96, 0x84, 0x58, 0xd2, 0x12,
|
||||
0x8f, 0x5b, 0x9c, 0x6a, 0x61, 0xeb, 0xb0, 0x21, 0x1a, 0xb1, 0xa6, 0x21,
|
||||
0x65, 0x6d, 0xf3, 0xe4, 0xd8, 0x94, 0x8a, 0xe6, 0x39, 0x37, 0x43, 0x4b,
|
||||
0x47, 0xb9, 0xad, 0xfd, 0xda, 0xae, 0x68, 0xfa, 0x6c, 0xbc, 0xba, 0x21,
|
||||
0x15, 0x14, 0xb3, 0x1a, 0x95, 0x4a, 0x3b, 0xf0, 0x8b, 0x3b, 0xf7, 0x1d,
|
||||
0xd9, 0x7d, 0x75, 0x0a, 0x18, 0x57, 0xc2, 0x02, 0x0f, 0x10, 0xeb, 0x17,
|
||||
0x6f, 0x2f, 0x8d, 0x0d, 0x1b, 0xe3, 0x79, 0xa5, 0xf4, 0xc4, 0x8e, 0x0b,
|
||||
0x48, 0xb4, 0x7e, 0x0d, 0x52, 0x2d, 0x54, 0x4e, 0x39, 0xc7, 0x4c, 0x38,
|
||||
0xe2, 0xde, 0x74, 0xdb, 0xe4, 0xa7, 0xee, 0xbc, 0xbc, 0xe1, 0x53, 0x2b,
|
||||
0x7f, 0xd0, 0xfc, 0xce, 0xc3, 0x27, 0x9a, 0x58, 0xc9, 0x0f, 0x34, 0xee,
|
||||
0x19, 0x22, 0xdf, 0x27, 0x9a, 0x3e, 0x5b, 0xc2, 0xeb, 0xae, 0x91, 0x70,
|
||||
0xf1, 0x77, 0x88, 0xaa, 0xab, 0x09, 0x87, 0x86, 0x09, 0xad, 0x25, 0xb4,
|
||||
0x65, 0x93, 0x8f, 0xac, 0x10, 0x59, 0xf6, 0x7b, 0x94, 0xb8, 0xab, 0x94,
|
||||
0x4a, 0x91, 0xce, 0x9b, 0xe8, 0xb8, 0x2b, 0xae, 0x6d, 0x7a, 0xc7, 0x3d,
|
||||
0x97, 0x37, 0x7c, 0xb2, 0x6a, 0x62, 0xf3, 0x93, 0xd2, 0x70, 0xf8, 0xef,
|
||||
0x48, 0x4f, 0x3d, 0x17, 0x2a, 0x1a, 0x71, 0x1b, 0x2f, 0xaa, 0x68, 0xeb,
|
||||
0xb8, 0xc4, 0xc4, 0x62, 0x25, 0x3f, 0x33, 0x2c, 0x1a, 0xf8, 0x77, 0x42,
|
||||
0x6f, 0xae, 0x4c, 0xee, 0xbe, 0xfc, 0x59, 0x82, 0x57, 0x98, 0x17, 0x58,
|
||||
0x19, 0x8d, 0xfb, 0xb8, 0x6b, 0x83, 0xb1, 0xe1, 0x4f, 0x14, 0x63, 0xb1,
|
||||
0xa6, 0x8a, 0xfa, 0x36, 0xdf, 0x4d, 0x2d, 0xfc, 0x66, 0xb6, 0x6b, 0xd3,
|
||||
0x97, 0xe2, 0x95, 0xb5, 0xb5, 0xa1, 0x57, 0x45, 0x73, 0x45, 0x96, 0xb7,
|
||||
0xd6, 0x6f, 0xf7, 0x4d, 0x51, 0x24, 0x11, 0xa9, 0xa3, 0x91, 0x62, 0x15,
|
||||
0x08, 0x2d, 0xf2, 0xb5, 0x8b, 0x54, 0x3e, 0xf7, 0x65, 0xd1, 0x7f, 0x3c,
|
||||
0x4b, 0x58, 0x70, 0x0c, 0xfc, 0xe8, 0x6a, 0x95, 0x87, 0x1f, 0x04, 0x81,
|
||||
0x3d, 0xa9, 0x11, 0x3d, 0x80, 0x67, 0x2f, 0x67, 0x8f, 0x94, 0xa4, 0x06,
|
||||
0xda, 0x98, 0xdb, 0x15, 0xb5, 0x4d, 0xb0, 0xb6, 0xb5, 0x6d, 0x6a, 0xd5,
|
||||
0xd6, 0x91, 0xe4, 0xe9, 0x9e, 0xcd, 0x9d, 0x5e, 0x1a, 0xab, 0xfd, 0x76,
|
||||
0xba, 0xb5, 0xbd, 0xc1, 0x49, 0xa6, 0xfc, 0xfc, 0x60, 0x67, 0x3c, 0xcc,
|
||||
0x8e, 0x0c, 0x12, 0x14, 0xae, 0x2e, 0x57, 0xb3, 0x54, 0x5f, 0xed, 0x51,
|
||||
0x96, 0x03, 0x36, 0x44, 0x84, 0xfc, 0x8e, 0x1e, 0x5b, 0xca, 0x2e, 0x2a,
|
||||
0x0e, 0xf7, 0x8e, 0x64, 0xfb, 0x77, 0xc6, 0xc4, 0x68, 0x18, 0xaf, 0x6d,
|
||||
0xaa, 0x51, 0x25, 0x0c, 0x8b, 0x63, 0x41, 0x90, 0xcf, 0x44, 0xa3, 0xd9,
|
||||
0xc8, 0xcd, 0x07, 0x38, 0xd6, 0x5a, 0x34, 0x8a, 0xca, 0xee, 0x2e, 0x16,
|
||||
0x87, 0x35, 0xab, 0x91, 0x2f, 0x9d, 0xab, 0xf2, 0xd8, 0x63, 0xaa, 0xd3,
|
||||
0x26, 0xa2, 0xdf, 0xba, 0x1a, 0xda, 0x3b, 0x20, 0x0c, 0xc1, 0x75, 0xc1,
|
||||
0x18, 0x70, 0x0c, 0x2a, 0x66, 0x9c, 0x22, 0x1e, 0xa7, 0xcf, 0xb5, 0x9c,
|
||||
0x3c, 0x29, 0x85, 0x98, 0xb1, 0x5c, 0xe4, 0xfa, 0xb9, 0x4c, 0x14, 0x95,
|
||||
0x72, 0x81, 0x88, 0x09, 0x62, 0x35, 0x13, 0x1a, 0xf0, 0x24, 0xc8, 0xf6,
|
||||
0xed, 0x88, 0x95, 0x86, 0xfa, 0xf2, 0x36, 0x9f, 0xfd, 0x10, 0x85, 0xce,
|
||||
0xdd, 0xaf, 0xd4, 0xfc, 0xff, 0xda, 0xec, 0xb0, 0x05, 0x0c, 0xd9, 0x6d,
|
||||
0x8f, 0x58, 0x9d, 0x74, 0x62, 0x31, 0x08, 0xbf, 0x1d, 0x64, 0x46, 0x4e,
|
||||
0x57, 0x6b, 0x5d, 0xaf, 0xa2, 0xd2, 0x2a, 0x31, 0xd9, 0x03, 0x71, 0xb1,
|
||||
0x5a, 0xb6, 0x63, 0xab, 0x20, 0x06, 0xfc, 0x12, 0x1c, 0x3a, 0x0f, 0xbd,
|
||||
0xe0, 0x32, 0xf4, 0xf8, 0xe3, 0x91, 0x4d, 0x3b, 0x55, 0xae, 0xbb, 0x12,
|
||||
0x9e, 0x5d, 0x59, 0x9e, 0xf7, 0x62, 0xa1, 0x6c, 0xab, 0x3a, 0xae, 0xac,
|
||||
0x44, 0x62, 0xef, 0xea, 0x53, 0xab, 0xe3, 0x19, 0x0a, 0x5b, 0xce, 0xd4,
|
||||
0x18, 0x83, 0xa2, 0x44, 0xa1, 0xaf, 0x7e, 0x2e, 0x83, 0x3f, 0xda, 0x67,
|
||||
0xc2, 0x52, 0xf1, 0x0f, 0x94, 0xf2, 0x97, 0x91, 0xdf, 0xb9, 0xf2, 0xaf,
|
||||
0xbd, 0x61, 0xf2, 0xd7, 0xa6, 0xc6, 0xca, 0x4a, 0xc8, 0xed, 0x7a, 0x5e,
|
||||
0x73, 0xbc, 0x37, 0x8c, 0xb5, 0x1e, 0x42, 0xb2, 0xe2, 0x93, 0xc6, 0xf5,
|
||||
0x2e, 0xb3, 0x06, 0x6c, 0x10, 0x92, 0xd0, 0xc0, 0x37, 0xe2, 0x19, 0x23,
|
||||
0x62, 0xb0, 0x0a, 0x6a, 0xc1, 0x89, 0xa1, 0xdf, 0xbc, 0x16, 0x9d, 0x3f,
|
||||
0x0d, 0xf9, 0xcf, 0x5f, 0x21, 0x37, 0x5c, 0x0d, 0xbb, 0x77, 0xa1, 0xe7,
|
||||
0x5d, 0x04, 0xb3, 0x3a, 0xca, 0xde, 0x41, 0x81, 0x54, 0x0c, 0x59, 0x76,
|
||||
0x2f, 0x2c, 0xff, 0x3d, 0xc4, 0xd2, 0x7b, 0x53, 0x33, 0xa8, 0xe2, 0x81,
|
||||
0x35, 0xa1, 0x6f, 0x23, 0xbf, 0x14, 0xd3, 0xc0, 0x38, 0xa1, 0xfa, 0x44,
|
||||
0xc5, 0xdc, 0x0d, 0xe4, 0xf3, 0x3f, 0x24, 0xe8, 0x5c, 0xf3, 0xb7, 0x5e,
|
||||
0xaf, 0xf9, 0x5b, 0x72, 0x83, 0x76, 0xef, 0xd2, 0xf1, 0xbb, 0x36, 0xe2,
|
||||
0xf3, 0x95, 0x80, 0xf6, 0x2d, 0x38, 0x85, 0xb3, 0xfa, 0x73, 0xd1, 0x89,
|
||||
0x77, 0x3d, 0x98, 0x4f, 0xcf, 0xac, 0x09, 0xc8, 0x8e, 0x56, 0x5a, 0xc1,
|
||||
0xa2, 0x91, 0x22, 0x31, 0x17, 0xe9, 0xdc, 0x86, 0xf3, 0xb3, 0x1b, 0xe1,
|
||||
0xee, 0x3b, 0x20, 0x55, 0x01, 0xe9, 0x4a, 0x98, 0x77, 0x84, 0xe8, 0x31,
|
||||
0xc7, 0x29, 0xb9, 0x71, 0x78, 0x57, 0x99, 0x40, 0x36, 0x6c, 0x40, 0xa2,
|
||||
0xfb, 0x11, 0x63, 0xd0, 0x32, 0x51, 0x48, 0x3e, 0x13, 0xb2, 0xfa, 0x89,
|
||||
0x51, 0x77, 0xfb, 0xa8, 0x4b, 0x6f, 0x5f, 0x2e, 0xa7, 0xb6, 0xf0, 0x64,
|
||||
0x14, 0x85, 0xbf, 0x26, 0x33, 0xff, 0x87, 0xe3, 0xde, 0xc0, 0xbc, 0x00,
|
||||
0x17, 0xfc, 0x37, 0x1e, 0x92, 0x7a, 0xe1, 0x0d, 0x8e, 0x74, 0xfb, 0xec,
|
||||
0x74, 0xc2, 0x5c, 0x9e, 0x0c, 0xa2, 0xf6, 0x13, 0xdb, 0x83, 0x23, 0x7e,
|
||||
0x75, 0x5e, 0xc1, 0xed, 0xf8, 0x7e, 0x03, 0x1b, 0xfa, 0x92, 0x98, 0x44,
|
||||
0x84, 0xcd, 0x17, 0xa1, 0xa2, 0x82, 0xf1, 0x14, 0x11, 0x78, 0x6e, 0x79,
|
||||
0xfd, 0xab, 0xdd, 0xd7, 0x9d, 0x20, 0xc0, 0xa8, 0x62, 0x8b, 0xc2, 0x8c,
|
||||
0xfa, 0x3c, 0x9b, 0x2f, 0x1d, 0x66, 0xd1, 0x4d, 0xa9, 0x68, 0xf9, 0x7a,
|
||||
0xf3, 0x4c, 0xc1, 0x73, 0x36, 0x67, 0x4b, 0xe6, 0x4a, 0x72, 0xeb, 0xd7,
|
||||
0xbc, 0x56, 0x97, 0xaa, 0x5e, 0x6d, 0x7a, 0xdc, 0xee, 0xbb, 0xe2, 0xb6,
|
||||
0x3c, 0x22, 0xbb, 0x69, 0x5d, 0x36, 0xcb, 0x47, 0xb3, 0xdc, 0xee, 0x6c,
|
||||
0xe8, 0xbb, 0x6c, 0x67, 0x38, 0xe6, 0x37, 0x5d, 0xf9, 0xee, 0x82, 0xfd,
|
||||
0xcc, 0x1d, 0xae, 0x33, 0x66, 0x05, 0xaa, 0xe2, 0xa8, 0x0d, 0x50, 0xb4,
|
||||
0x8c, 0x7a, 0xc3, 0x00, 0x02, 0xdd, 0xb3, 0xda, 0xcb, 0xbe, 0xc0, 0x94,
|
||||
0x73, 0x05, 0x95, 0x29, 0xf8, 0xf6, 0xbb, 0x8b, 0xd6, 0x66, 0x4a, 0xb2,
|
||||
0xae, 0x27, 0x31, 0xd4, 0x9f, 0xf9, 0xd8, 0x42, 0x58, 0xe2, 0xbf, 0xf0,
|
||||
0x24, 0xd8, 0xf2, 0xe8, 0xd5, 0xde, 0x28, 0x7b, 0xcd, 0x8f, 0xc9, 0xcd,
|
||||
0x61, 0x8e, 0xbb, 0x96, 0xb5, 0x3e, 0xa9, 0x39, 0x8b, 0xbf, 0x7c, 0xf4,
|
||||
0xd8, 0x37, 0xbe, 0xf5, 0x8e, 0x11, 0x1d, 0x93, 0xa4, 0x1d, 0xce, 0x1b,
|
||||
0x11, 0x8d, 0xca, 0x79, 0xc2, 0xbd, 0xcc, 0x96, 0xec, 0x97, 0x16, 0x2a,
|
||||
0xc7, 0x05, 0x22, 0x4a, 0x14, 0x29, 0xb5, 0x29, 0xd5, 0x5a, 0x29, 0x39,
|
||||
0x5f, 0x5b, 0x56, 0xcd, 0x95, 0x4f, 0x56, 0x5f, 0x41, 0x61, 0xed, 0xd7,
|
||||
0xe7, 0x30, 0x27, 0xb6, 0x96, 0xb5, 0xe1, 0x6b, 0x79, 0x8d, 0xee, 0xf5,
|
||||
0x38, 0x82, 0x2a, 0x80, 0x2c, 0x02, 0x59, 0x5a, 0x31, 0xfb, 0x8a, 0x63,
|
||||
0x1a, 0xf3, 0xe7, 0xce, 0x6b, 0x28, 0xd5, 0xbb, 0xa6, 0x6c, 0xf5, 0x98,
|
||||
0x72, 0xa2, 0x74, 0xcf, 0xac, 0xb3, 0x07, 0x03, 0x94, 0x49, 0xee, 0xf1,
|
||||
0x28, 0x11, 0xc2, 0x48, 0x59, 0x37, 0x14, 0x1f, 0x7c, 0xbc, 0x37, 0x7d,
|
||||
0x33, 0xb9, 0x43, 0x2f, 0x7f, 0x25, 0x67, 0xfe, 0xde, 0x88, 0x17, 0x27,
|
||||
0x81, 0x63, 0x9b, 0xc0, 0x4e, 0x82, 0x68, 0x3c, 0xf4, 0x7b, 0x29, 0x71,
|
||||
0xa4, 0xfc, 0x7f, 0x7b, 0x4c, 0xc4, 0xeb, 0x84, 0x27, 0x7b, 0xdf, 0xcc,
|
||||
0x07, 0xa6, 0x9d, 0x37, 0x48, 0x1d, 0xff, 0x2f, 0x2d, 0x60, 0xef, 0x5d,
|
||||
0xc2, 0x17, 0x6b, 0x53, 0x0f, 0x42, 0x71, 0xed, 0xff, 0x4e, 0xf9, 0xbb,
|
||||
0xfc, 0x5d, 0xfe, 0x2e, 0xaf, 0x97, 0xfc, 0x17, 0xef, 0xaf, 0xd4, 0xe8,
|
||||
0xa6, 0x24, 0x11, 0x12, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44,
|
||||
0xae, 0x42, 0x60, 0x82,
|
||||
};
|
||||
const size_t web_asset_logo_png_size = sizeof(web_asset_logo_png);
|
||||
|
||||
|
||||
@@ -15,3 +15,6 @@ 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,261 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_auth_parse.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static void wipe(void *buffer, size_t length)
|
||||
{
|
||||
volatile uint8_t *p = buffer;
|
||||
while (length--) *p++ = 0;
|
||||
}
|
||||
|
||||
static bool alnum_ascii(unsigned char c)
|
||||
{
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9');
|
||||
}
|
||||
|
||||
static bool authority(const char *text, size_t length, char *out)
|
||||
{
|
||||
if (!text || !length || length > WEB_AUTH_ORIGIN_CAPACITY - 5U) return false;
|
||||
if (length >= 4U && memcmp(text + length - 4U, ":443", 4U) == 0) length -= 4U;
|
||||
if (!length || length > WEB_AUTH_ORIGIN_CAPACITY - 9U) return false;
|
||||
size_t label = 0;
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
unsigned char c = (unsigned char)text[i];
|
||||
if (c == '.') {
|
||||
if (!label || text[i - 1U] == '-') return false;
|
||||
label = 0;
|
||||
} else {
|
||||
if (!alnum_ascii(c) && c != '-') return false;
|
||||
if ((!label && c == '-') || ++label > 63U) return false;
|
||||
}
|
||||
out[i] = c >= 'A' && c <= 'Z' ? (char)(c + ('a' - 'A')) : (char)c;
|
||||
}
|
||||
if (!label || text[length - 1U] == '-') return false;
|
||||
out[length] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_origin(const char *host, size_t host_length,
|
||||
const char *origin, size_t origin_length,
|
||||
char canonical[WEB_AUTH_ORIGIN_CAPACITY])
|
||||
{
|
||||
if (!canonical) return false;
|
||||
memset(canonical, 0, WEB_AUTH_ORIGIN_CAPACITY);
|
||||
char other[WEB_AUTH_ORIGIN_CAPACITY] = {0};
|
||||
if (!origin || origin_length < 9U || origin_length > WEB_AUTH_ORIGIN_CAPACITY + 3U ||
|
||||
memcmp(origin, "https://", 8U) != 0 ||
|
||||
!authority(host, host_length, canonical + 8U) ||
|
||||
!authority(origin + 8U, origin_length - 8U, other) ||
|
||||
strcmp(canonical + 8U, other) != 0) {
|
||||
memset(canonical, 0, WEB_AUTH_ORIGIN_CAPACITY);
|
||||
return false;
|
||||
}
|
||||
memcpy(canonical, "https://", 8U);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool cookie_name_char(unsigned char c)
|
||||
{
|
||||
return alnum_ascii(c) || (c && strchr("!#$%&'*+-.^_`|~", c));
|
||||
}
|
||||
|
||||
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present)
|
||||
{
|
||||
if (!present) return false;
|
||||
*present = false;
|
||||
if (!token) return false;
|
||||
memset(token, 0, WEB_AUTH_TOKEN_LENGTH + 1U);
|
||||
if (!header || !name || !*name || !length || length > WEB_AUTH_COOKIE_HEADER_MAX)
|
||||
return false;
|
||||
size_t pos = 0, selected = 0, name_length = strlen(name);
|
||||
bool found = false;
|
||||
while (pos < length) {
|
||||
while (pos < length && header[pos] == ' ') ++pos;
|
||||
size_t start = pos;
|
||||
while (pos < length && cookie_name_char((unsigned char)header[pos])) ++pos;
|
||||
size_t key_length = pos - start;
|
||||
if (!key_length || pos == length || header[pos++] != '=') return false;
|
||||
size_t value = pos;
|
||||
while (pos < length && header[pos] != ';') {
|
||||
unsigned char c = (unsigned char)header[pos++];
|
||||
if (c < 0x21 || c > 0x7e || c == '"' || c == ',' || c == '\\') return false;
|
||||
}
|
||||
if (key_length == name_length && memcmp(header + start, name, key_length) == 0) {
|
||||
if (found || pos - value != WEB_AUTH_TOKEN_LENGTH) return false;
|
||||
for (size_t i = value; i < pos; ++i)
|
||||
if (!((header[i] >= '0' && header[i] <= '9') ||
|
||||
(header[i] >= 'a' && header[i] <= 'f'))) return false;
|
||||
found = true;
|
||||
selected = value;
|
||||
}
|
||||
if (pos < length && ++pos == length) return false;
|
||||
}
|
||||
if (found) memcpy(token, header + selected, WEB_AUTH_TOKEN_LENGTH);
|
||||
*present = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U])
|
||||
{
|
||||
bool present = false;
|
||||
return web_auth_parse_optional_cookie(header, length, name, token, &present) && present;
|
||||
}
|
||||
|
||||
typedef struct { const uint8_t *data; size_t length; size_t pos; } json_cursor_t;
|
||||
|
||||
static void whitespace(json_cursor_t *c)
|
||||
{
|
||||
while (c->pos < c->length) {
|
||||
uint8_t b = c->data[c->pos];
|
||||
if (b != ' ' && b != '\t' && b != '\r' && b != '\n') break;
|
||||
++c->pos;
|
||||
}
|
||||
}
|
||||
|
||||
static bool take(json_cursor_t *c, uint8_t byte)
|
||||
{
|
||||
whitespace(c);
|
||||
if (c->pos == c->length || c->data[c->pos] != byte) return false;
|
||||
++c->pos;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool hex4(json_cursor_t *c, uint32_t *value)
|
||||
{
|
||||
*value = 0;
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
if (c->pos == c->length) return false;
|
||||
uint8_t b = c->data[c->pos++];
|
||||
unsigned digit;
|
||||
if (b >= '0' && b <= '9') digit = b - '0';
|
||||
else if (b >= 'a' && b <= 'f') digit = b - 'a' + 10U;
|
||||
else if (b >= 'A' && b <= 'F') digit = b - 'A' + 10U;
|
||||
else return false;
|
||||
*value = (*value << 4) | digit;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool codepoint(json_cursor_t *c, uint32_t *value)
|
||||
{
|
||||
if (c->pos == c->length) return false;
|
||||
uint8_t b = c->data[c->pos++];
|
||||
if (b == '\\') {
|
||||
if (c->pos == c->length) return false;
|
||||
b = c->data[c->pos++];
|
||||
switch (b) {
|
||||
case '"': case '\\': case '/': *value = b; return true;
|
||||
case 'b': *value = 8; return true;
|
||||
case 'f': *value = 12; return true;
|
||||
case 'n': *value = 10; return true;
|
||||
case 'r': *value = 13; return true;
|
||||
case 't': *value = 9; return true;
|
||||
case 'u': break;
|
||||
default: return false;
|
||||
}
|
||||
if (!hex4(c, value)) return false;
|
||||
if (*value >= 0xd800 && *value <= 0xdbff) {
|
||||
uint32_t low;
|
||||
if (c->length - c->pos < 2U || c->data[c->pos++] != '\\' ||
|
||||
c->data[c->pos++] != 'u' || !hex4(c, &low) ||
|
||||
low < 0xdc00 || low > 0xdfff) return false;
|
||||
*value = 0x10000 + ((*value - 0xd800) << 10) + low - 0xdc00;
|
||||
}
|
||||
return *value && !(*value >= 0xd800 && *value <= 0xdfff);
|
||||
}
|
||||
if (b < 0x20) return false;
|
||||
if (b < 0x80) { *value = b; return true; }
|
||||
unsigned extra;
|
||||
uint32_t minimum;
|
||||
if (b >= 0xc2 && b <= 0xdf) { extra = 1; minimum = 0x80; *value = b & 0x1f; }
|
||||
else if (b >= 0xe0 && b <= 0xef) { extra = 2; minimum = 0x800; *value = b & 0x0f; }
|
||||
else if (b >= 0xf0 && b <= 0xf4) { extra = 3; minimum = 0x10000; *value = b & 7; }
|
||||
else return false;
|
||||
while (extra--) {
|
||||
if (c->pos == c->length) return false;
|
||||
b = c->data[c->pos++];
|
||||
if ((b & 0xc0) != 0x80) return false;
|
||||
*value = (*value << 6) | (b & 0x3f);
|
||||
}
|
||||
return *value >= minimum && *value <= 0x10ffff &&
|
||||
!(*value >= 0xd800 && *value <= 0xdfff);
|
||||
}
|
||||
|
||||
static bool string(json_cursor_t *c, uint8_t *out, size_t capacity, size_t *length)
|
||||
{
|
||||
*length = 0;
|
||||
if (!take(c, '"')) return false;
|
||||
while (c->pos < c->length && c->data[c->pos] != '"') {
|
||||
uint32_t cp;
|
||||
if (!codepoint(c, &cp)) return false;
|
||||
size_t bytes = cp < 0x80 ? 1U : cp < 0x800 ? 2U : cp < 0x10000 ? 3U : 4U;
|
||||
if (bytes > capacity - *length) return false;
|
||||
if (bytes == 1U) out[(*length)++] = (uint8_t)cp;
|
||||
else {
|
||||
out[(*length)++] = (uint8_t)((bytes == 2U ? 0xc0 : bytes == 3U ? 0xe0 : 0xf0) |
|
||||
(cp >> (6U * (bytes - 1U))));
|
||||
for (size_t i = bytes - 1U; i > 0; --i)
|
||||
out[(*length)++] = (uint8_t)(0x80 | ((cp >> (6U * (i - 1U))) & 0x3f));
|
||||
}
|
||||
}
|
||||
if (c->pos == c->length) return false;
|
||||
++c->pos;
|
||||
out[*length] = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_json_string(const char *body, size_t length, size_t *position,
|
||||
uint8_t *output, size_t capacity, size_t *decoded_length)
|
||||
{
|
||||
if (output && capacity) wipe(output, capacity);
|
||||
if (decoded_length) *decoded_length = 0;
|
||||
if (!body || !position || *position > length || !output || !capacity || !decoded_length)
|
||||
return false;
|
||||
json_cursor_t c = { (const uint8_t *)body, length, *position };
|
||||
if (!string(&c, output, capacity - 1U, decoded_length)) {
|
||||
wipe(output, capacity);
|
||||
*decoded_length = 0;
|
||||
return false;
|
||||
}
|
||||
*position = c.pos;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_auth_parse_login(const char *body, size_t length,
|
||||
web_auth_credentials_t *credentials)
|
||||
{
|
||||
if (!credentials) return false;
|
||||
wipe(credentials, sizeof(*credentials));
|
||||
if (!body || !length || length > WEB_AUTH_LOGIN_BODY_MAX) return false;
|
||||
json_cursor_t c = { (const uint8_t *)body, length, 0 };
|
||||
unsigned seen = 0;
|
||||
if (!take(&c, '{')) return false;
|
||||
for (unsigned field = 0; field < 2; ++field) {
|
||||
uint8_t key[9] = {0};
|
||||
size_t key_length;
|
||||
if ((field && !take(&c, ',')) || !string(&c, key, 8U, &key_length) ||
|
||||
!take(&c, ':')) goto invalid;
|
||||
unsigned bit;
|
||||
uint8_t *output;
|
||||
size_t *output_length, capacity;
|
||||
if (key_length == 8U && memcmp(key, "username", 8U) == 0) {
|
||||
bit = 1; output = credentials->username;
|
||||
output_length = &credentials->username_length; capacity = WEB_AUTH_USERNAME_MAX;
|
||||
} else if (key_length == 8U && memcmp(key, "password", 8U) == 0) {
|
||||
bit = 2; output = credentials->password;
|
||||
output_length = &credentials->password_length; capacity = WEB_AUTH_PASSWORD_MAX;
|
||||
} else goto invalid;
|
||||
if ((seen & bit) || !string(&c, output, capacity, output_length)) goto invalid;
|
||||
seen |= bit;
|
||||
}
|
||||
if (!take(&c, '}')) goto invalid;
|
||||
whitespace(&c);
|
||||
if (c.pos == c.length && seen == 3U) return true;
|
||||
invalid:
|
||||
wipe(credentials, sizeof(*credentials));
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Private, allocation-free parsing only: these helpers do not authorize requests. */
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define WEB_AUTH_ORIGIN_CAPACITY 129U
|
||||
#define WEB_AUTH_COOKIE_HEADER_MAX 1024U
|
||||
#define WEB_AUTH_TOKEN_LENGTH 64U
|
||||
#define WEB_AUTH_LOGIN_BODY_MAX 512U
|
||||
#define WEB_AUTH_USERNAME_MAX 16U
|
||||
#define WEB_AUTH_PASSWORD_MAX 64U
|
||||
|
||||
typedef struct {
|
||||
size_t username_length;
|
||||
size_t password_length;
|
||||
uint8_t username[WEB_AUTH_USERNAME_MAX + 1U];
|
||||
uint8_t password[WEB_AUTH_PASSWORD_MAX + 1U];
|
||||
} web_auth_credentials_t;
|
||||
|
||||
/* Exact byte spans, not necessarily NUL-terminated. Inputs and output must not
|
||||
* alias. Failures clear output. Host supports ASCII DNS/IPv4 authorities only;
|
||||
* IPv6 literals are deliberately rejected until the device supports that route.
|
||||
* Only optional :443 is accepted. Origin is mandatory and must match Host.
|
||||
* HTTP callers must separately reject duplicate header lines, enforce methods,
|
||||
* body/content-type limits, Fetch Metadata and CSRF/session policy. */
|
||||
bool web_auth_parse_origin(const char *host, size_t host_length,
|
||||
const char *origin, size_t origin_length,
|
||||
char canonical[WEB_AUTH_ORIGIN_CAPACITY]);
|
||||
/* Extract exactly one named lowercase-hex token; malformed/duplicate or missing
|
||||
* selected cookie fails. Other cookies are syntax-checked but not retained.
|
||||
* This deliberately accepts only unquoted cookie values, including unrelated
|
||||
* cookies; quoted values fail closed. No whitespace inside a cookie pair.
|
||||
* name is a trusted, nonempty C string. Output is sensitive: wipe after use. */
|
||||
bool web_auth_parse_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U]);
|
||||
/* As above, but a missing selected cookie is valid with present=false. This
|
||||
* lets HTTP policy distinguish absence from malformed/ambiguous cookies. */
|
||||
bool web_auth_parse_optional_cookie(const char *header, size_t length, const char *name,
|
||||
char token[WEB_AUTH_TOKEN_LENGTH + 1U], bool *present);
|
||||
/* Decode one string at *position (including optional JSON whitespace). Capacity
|
||||
* includes the terminator. Failure wipes output and leaves position unchanged.
|
||||
* Success output is sensitive; caller must wipe it. Same strict decoder as login. */
|
||||
bool web_auth_parse_json_string(const char *body, size_t length, size_t *position,
|
||||
uint8_t *output, size_t capacity, size_t *decoded_length);
|
||||
/* Exactly username/password string fields, either order. JSON escapes and valid
|
||||
* UTF-8 accepted; unknown/duplicate fields, NUL and malformed Unicode rejected.
|
||||
* Database credential policy remains authoritative. Caller must wipe BOTH the
|
||||
* original request body and successful credentials using secure_wipe(). */
|
||||
bool web_auth_parse_login(const char *body, size_t length,
|
||||
web_auth_credentials_t *credentials);
|
||||
@@ -0,0 +1,234 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_broker_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "secure_random.h"
|
||||
#include "session_broker.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
|
||||
enum { IDLE, PENDING, OK, FAILED, CANCELLED, CONFLICT };
|
||||
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "conflict"};
|
||||
typedef struct {
|
||||
uint32_t id;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
int64_t deadline;
|
||||
uint32_t generation, target;
|
||||
unsigned state;
|
||||
} broker_operation_t;
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static broker_operation_t s_operation;
|
||||
static uint32_t s_next_id;
|
||||
|
||||
/* Narrow flat JSON: exact action plus two unsigned decimal integers, no
|
||||
* escapes, duplicates, unknown fields, nesting, fractions or exponents. */
|
||||
static bool parse(const char *body, size_t length, broker_operation_t *operation)
|
||||
{
|
||||
const char *keys[] = {"action", "generation", "target"};
|
||||
unsigned seen = 0;
|
||||
size_t pos = 0;
|
||||
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
|
||||
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
|
||||
TAKE('{');
|
||||
for (unsigned field = 0; field < 3; ++field) {
|
||||
if (field) { TAKE(','); }
|
||||
TAKE('"');
|
||||
size_t start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
unsigned key = 0;
|
||||
for (; key < 3; ++key)
|
||||
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
||||
if (key == 3 || (seen & (1U << key))) return false;
|
||||
++pos; TAKE(':'); SPACE();
|
||||
if (key == 0) {
|
||||
const char action[] = "\"assign\"";
|
||||
if (length - pos < sizeof(action) - 1 || memcmp(body + pos, action, sizeof(action) - 1)) return false;
|
||||
pos += sizeof(action) - 1;
|
||||
} else {
|
||||
uint32_t number = 0;
|
||||
start = pos;
|
||||
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
|
||||
unsigned digit = (unsigned)(body[pos++] - '0');
|
||||
if (number > (UINT32_MAX - digit) / 10U) return false;
|
||||
number = number * 10U + digit;
|
||||
}
|
||||
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
||||
if (key == 1) operation->generation = number;
|
||||
else operation->target = number;
|
||||
}
|
||||
seen |= 1U << key;
|
||||
}
|
||||
TAKE('}'); SPACE();
|
||||
#undef TAKE
|
||||
#undef SPACE
|
||||
return pos == length && seen == 7 && operation->target &&
|
||||
operation->generation && operation->generation != UINT32_MAX;
|
||||
}
|
||||
|
||||
void web_broker_settings_execute(uint32_t id)
|
||||
{
|
||||
broker_operation_t operation;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
operation = s_operation;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!id || operation.id != id || operation.state != PENDING) {
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
return;
|
||||
}
|
||||
bool current = false;
|
||||
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t);
|
||||
unsigned state = CANCELLED;
|
||||
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
||||
esp_timer_get_time() < operation.deadline) {
|
||||
error = session_broker_assign_writer_current(operation.target, operation.generation);
|
||||
state = error == ESP_OK ? OK :
|
||||
(error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.id == id && s_operation.state == PENDING) {
|
||||
s_operation.state = state;
|
||||
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
}
|
||||
|
||||
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(request, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
esp_err_t web_broker_operation_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
bool mutation = request->method == HTTP_POST;
|
||||
esp_err_t error = mutation
|
||||
? web_cookie_auth_require_json(request, 256, &view, &allowed)
|
||||
: web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
broker_operation_t operation = {0};
|
||||
if (mutation) {
|
||||
char type[40] = {0}, body[256];
|
||||
size_t received = 0;
|
||||
bool valid = request->content_len && request->content_len <= sizeof(body) &&
|
||||
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
|
||||
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
|
||||
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
|
||||
int count = httpd_req_recv(request, body + received, request->content_len - received);
|
||||
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
|
||||
else received += (size_t)count;
|
||||
}
|
||||
valid = valid && received == request->content_len && parse(body, received, &operation);
|
||||
secure_wipe(body, sizeof(body));
|
||||
if (!valid) {
|
||||
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_broker_request\"}");
|
||||
goto done;
|
||||
}
|
||||
operation.session = view.id;
|
||||
operation.principal = view.principal;
|
||||
operation.deadline = esp_timer_get_time() + 30000000LL;
|
||||
operation.state = PENDING;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
|
||||
if (!busy) {
|
||||
operation.id = ++s_next_id;
|
||||
s_operation = operation;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (busy || admin_ssh_console_submit_broker_settings(operation.id) != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
error = httpd_resp_set_hdr(request, "Retry-After", "1");
|
||||
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
goto done;
|
||||
}
|
||||
} else {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.session == view.id) {
|
||||
operation.id = s_operation.id;
|
||||
operation.state = s_operation.state;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
char response[96];
|
||||
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
|
||||
operation.id, operation.id ? "assign" : "none", s_states[operation.state]);
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
||||
respond(request, mutation ? "202 Accepted" : "200 OK", response);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
done:
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_broker_settings_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
session_broker_management_snapshot_t snapshot;
|
||||
error = session_broker_get_management_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"broker_unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
/* Eight rows; names are exact bounded bytes as hex, never unescaped JSON.
|
||||
* Decimal-string drop counters retain all 64 bits in the browser. */
|
||||
char response[2048];
|
||||
int written = snprintf(response, sizeof(response),
|
||||
"{\"generation\":%" PRIu32 ",\"writer\":%" PRIu32 ",\"clients\":[",
|
||||
snapshot.generation, snapshot.writer_id);
|
||||
size_t used = 0;
|
||||
if (written < 0 || (size_t)written >= sizeof(response)) { error = ESP_FAIL; goto done; }
|
||||
used = (size_t)written;
|
||||
for (size_t i = 0; i < snapshot.count; ++i) {
|
||||
const session_broker_management_client_t *client = &snapshot.clients[i];
|
||||
char name[SESSION_BROKER_CLIENT_NAME_MAX * 2 + 1];
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
size_t n = 0;
|
||||
for (; n < SESSION_BROKER_CLIENT_NAME_MAX && client->name[n]; ++n) {
|
||||
unsigned byte = (unsigned char)client->name[n];
|
||||
name[n * 2] = hex[byte >> 4]; name[n * 2 + 1] = hex[byte & 15];
|
||||
}
|
||||
name[n * 2] = 0;
|
||||
written = snprintf(response + used, sizeof(response) - used,
|
||||
"%s{\"id\":%" PRIu32 ",\"type\":%u,\"name_hex\":\"%s\",\"pending\":%u,\"high_water\":%u,\"dropped\":\"%" PRIu64 "\"}",
|
||||
i ? "," : "", client->id, (unsigned)client->type, name,
|
||||
(unsigned)client->pending, (unsigned)client->high_water, client->dropped);
|
||||
if (written < 0 || (size_t)written >= sizeof(response) - used) { error = ESP_FAIL; goto done; }
|
||||
used += (size_t)written;
|
||||
}
|
||||
written = snprintf(response + used, sizeof(response) - used, "]}");
|
||||
error = written < 0 || (size_t)written >= sizeof(response) - used ? ESP_FAIL :
|
||||
respond(request, "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/* Optional admin-only snapshot and login-isolated typed assignment/results. */
|
||||
esp_err_t web_broker_settings_handler(httpd_req_t *request);
|
||||
esp_err_t web_broker_operation_handler(httpd_req_t *request);
|
||||
void web_broker_settings_execute(uint32_t id);
|
||||
+142
-136
@@ -1,29 +1,33 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* UART0 HTTPS lifecycle, shared credentials, certificate, and recovery commands. */
|
||||
/* HTTPS lifecycle and TLS certificate commands. */
|
||||
|
||||
#include "web_console.h"
|
||||
#include "admin_ssh_console.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_console.h"
|
||||
#include "secure_random.h"
|
||||
#include "ssh_transport.h"
|
||||
#include "user_database.h"
|
||||
#include "web_security.h"
|
||||
#include "web_serial_transport.h"
|
||||
#include "web_server.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_admin_transport.h"
|
||||
#include "web_admin_tickets.h"
|
||||
#include "web_diagnostics.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 diagnostics enable|disable|show|clear\n");
|
||||
printf(" web performance enable|disable|show|clear\n");
|
||||
printf(" web certificate info\n");
|
||||
printf(" web certificate rotate --force\n");
|
||||
printf(" web reset --force\n");
|
||||
printf(" web reset --force (TLS certificate and private key only)\n");
|
||||
}
|
||||
|
||||
static void print_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LENGTH])
|
||||
@@ -33,6 +37,30 @@ static void print_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LENG
|
||||
}
|
||||
}
|
||||
|
||||
static void show_admin_transport(void)
|
||||
{
|
||||
web_admin_transport_snapshot_t admin;
|
||||
web_admin_tickets_snapshot_t tickets;
|
||||
web_admin_transport_get_snapshot(&admin);
|
||||
web_admin_tickets_get_snapshot(&tickets);
|
||||
printf("WebSocket admin: initialized=%s attached=%s active=%s/1 closing=%s init-error=%s\n",
|
||||
admin.initialized ? "yes" : "no", admin.attached ? "yes" : "no",
|
||||
admin.active ? "yes" : "no", admin.closing ? "yes" : "no", esp_err_to_name(admin.last_error));
|
||||
printf(" tickets=%" PRIu32 "/%u issued=%" PRIu32 " consumed=%" PRIu32
|
||||
" rejected=%" PRIu32 " capacity=%" PRIu32 "\n",
|
||||
tickets.active, WEB_ADMIN_TICKET_CAPACITY, tickets.issued, tickets.consumed,
|
||||
tickets.rejected, tickets.capacity_rejections);
|
||||
printf(" connected=%" PRIu32 " disconnected=%" PRIu32 " capacity=%" PRIu32
|
||||
" authorization=%" PRIu32 " protocol=%" PRIu32 " input-backpressure=%" PRIu32 "\n",
|
||||
admin.connections, admin.disconnections, admin.capacity_rejections,
|
||||
admin.authorization_rejections, admin.protocol_errors, admin.input_backpressure);
|
||||
printf(" rx-bytes=%" PRIu32 " tx-bytes=%" PRIu32 " send-failures=%" PRIu32
|
||||
" queue-failures=%" PRIu32 " static=%u ticket-storage=%u PSRAM-payload=%u bytes\n",
|
||||
admin.rx_bytes, admin.tx_bytes, admin.send_failures, admin.queue_failures,
|
||||
(unsigned)admin.static_bytes, (unsigned)tickets.storage_bytes, (unsigned)admin.payload_bytes);
|
||||
printf(" Admin counters are saturating lifetime counts (not reset by web clear-counters).\n");
|
||||
}
|
||||
|
||||
static int show_status(void)
|
||||
{
|
||||
web_server_snapshot_t snapshot;
|
||||
@@ -42,24 +70,35 @@ static int show_status(void)
|
||||
return 1;
|
||||
}
|
||||
|
||||
char username[WEB_SECURITY_USERNAME_CAPACITY + 1U] = {0};
|
||||
size_t username_length = 0U;
|
||||
esp_err_t security_error = web_security_copy_username(
|
||||
username, sizeof(username), &username_length);
|
||||
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 (security_error == ESP_OK) {
|
||||
printf("Authentication: HTTP Basic over TLS, username=%.*s, shared with SSH\n",
|
||||
(int)username_length, username);
|
||||
if (users_error == ESP_OK) {
|
||||
printf("Authentication: HTTPS cookie sessions via user database, users=%u admins=%u\n",
|
||||
(unsigned int)users.user_count, (unsigned int)users.admin_count);
|
||||
} else {
|
||||
printf("Authentication material unavailable: %s; use 'web reset --force' to replace it.\n",
|
||||
esp_err_to_name(security_error));
|
||||
printf("Authentication database unavailable: %s; use 'user recover --force'.\n",
|
||||
esp_err_to_name(users_error));
|
||||
}
|
||||
printf("Endpoints: GET /, GET /api/status, POST /api/ws-ticket, WSS /ws/serial\n");
|
||||
printf("Authentication routes: GET /login, GET /api/login-challenge, POST /api/login, GET /api/session, POST /api/logout\n");
|
||||
printf("Admin-only backend: POST /api/admin/ws-ticket, WSS /ws/admin (no normal UI entry)\n");
|
||||
show_admin_transport();
|
||||
web_cookie_auth_snapshot_t auth;
|
||||
web_cookie_auth_get_snapshot(&auth);
|
||||
web_session_store_snapshot_t sessions;
|
||||
if (web_session_store_get_snapshot(&sessions) == ESP_OK)
|
||||
printf("Cookie authentication: ready=%s sessions=%" PRIu32 "/4 challenges=%" PRIu32 "/4\n",
|
||||
auth.ready ? "yes" : "no", sessions.active, auth.active_challenges);
|
||||
printf("Login attempts=%" PRIu32 " invalid-credentials=%" PRIu32 " throttled=%" PRIu32
|
||||
" auth-capacity-rejections=%" PRIu32 " CSRF/origin-rejections=%" PRIu32 " logouts=%" PRIu32 "\n",
|
||||
auth.login_attempts, auth.login_failures, auth.throttled, auth.capacity_rejections,
|
||||
auth.security_rejections, auth.logouts);
|
||||
|
||||
web_serial_transport_snapshot_t transport;
|
||||
esp_err_t transport_error = web_serial_transport_get_snapshot(&transport);
|
||||
@@ -80,11 +119,20 @@ static int show_status(void)
|
||||
if (!session->active) {
|
||||
continue;
|
||||
}
|
||||
printf(" slot=%u fd=%d generation=%" PRIu32 " broker=%" PRIu32
|
||||
" role=%s tx-pending=%s closing=%s\n",
|
||||
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",
|
||||
@@ -102,6 +150,7 @@ static int show_counters(void)
|
||||
return 1;
|
||||
}
|
||||
|
||||
show_admin_transport();
|
||||
const web_server_counters_t *counter = &snapshot.counters;
|
||||
printf("Lifecycle: starts=%" PRIu64 " start-failures=%" PRIu64
|
||||
" stops=%" PRIu64 "\n",
|
||||
@@ -155,24 +204,6 @@ static int show_counters(void)
|
||||
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("These credentials protect HTTPS and SSH. Keep them private.\n");
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int show_certificate(void)
|
||||
{
|
||||
web_security_certificate_metadata_t metadata;
|
||||
@@ -203,107 +234,65 @@ 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 restart_if_running(bool was_running)
|
||||
static int replace_material(bool reset)
|
||||
{
|
||||
if (!was_running) {
|
||||
return 0;
|
||||
}
|
||||
esp_err_t error = web_server_stop();
|
||||
bool committed = false;
|
||||
esp_err_t error = web_server_replace_identity(0, 0, reset, &committed);
|
||||
if (error != ESP_OK) {
|
||||
printf("Material changed, but the old TLS server could not stop: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
error = web_server_start();
|
||||
if (error != ESP_OK) {
|
||||
printf("Material changed, but HTTPS could not restart: %s\n",
|
||||
esp_err_to_name(error));
|
||||
printf("%s: %s\n", committed
|
||||
? "New HTTPS identity persisted, but stop/start failed; no rollback. Inspect via UART0 before retrying"
|
||||
: "HTTPS identity replacement rejected or failed before publication",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS certificate and private key replaced and persisted; user accounts unchanged.\n");
|
||||
printf("Verify the new fingerprint via trusted UART0, renew browser trust, and sign in again.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
static int rotate_certificate(void) { return replace_material(false); }
|
||||
static int reset_material(void) { return replace_material(true); }
|
||||
|
||||
esp_err_t web_revoke_error = web_serial_transport_revoke_sessions();
|
||||
esp_err_t ssh_revoke_error = ssh_transport_revoke_sessions();
|
||||
printf("Administrative credentials rotated and persisted. Existing HTTPS and SSH credentials are now invalid.\n");
|
||||
if (web_revoke_error != ESP_OK && web_revoke_error != ESP_ERR_INVALID_STATE) {
|
||||
printf("Warning: existing WebSocket sessions could not be revoked: %s\n",
|
||||
esp_err_to_name(web_revoke_error));
|
||||
}
|
||||
if (ssh_revoke_error != ESP_OK && ssh_revoke_error != ESP_ERR_INVALID_STATE) {
|
||||
printf("Warning: existing SSH sessions could not be revoked: %s\n",
|
||||
esp_err_to_name(ssh_revoke_error));
|
||||
}
|
||||
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 void print_performance_time(const char *name, const web_serial_performance_timing_t *t)
|
||||
{
|
||||
printf(" %s: count=%" PRIu64 " sum_us=%" PRIu64 " avg_us_est=%" PRIu64 " max_us=%" PRIu64 "\n",
|
||||
name, t->count, t->sum_us, t->count ? t->sum_us / t->count : 0, t->max_us);
|
||||
}
|
||||
|
||||
static int rotate_certificate(void)
|
||||
static int performance_command(const char *action)
|
||||
{
|
||||
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));
|
||||
esp_err_t result = ESP_OK;
|
||||
if (!strcmp(action, "enable")) result = web_serial_performance_enable(true);
|
||||
else if (!strcmp(action, "disable")) result = web_serial_performance_enable(false);
|
||||
else if (!strcmp(action, "clear")) result = web_serial_performance_clear();
|
||||
else if (strcmp(action, "show")) return 1;
|
||||
if (result != ESP_OK) {
|
||||
printf("Web performance: %s\n", esp_err_to_name(result));
|
||||
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;
|
||||
web_serial_performance_snapshot_t s;
|
||||
web_serial_performance_snapshot(&s);
|
||||
printf("Web performance: enabled=%u epoch=%" PRIu32 " epoch_exhausted=%u; binary TX only\n",
|
||||
s.enabled, s.epoch, s.epoch_exhausted);
|
||||
printf("Send-call return is synchronous HTTPD-owner bytes send, not peer receipt. Timings are instrumented estimates; saturated=1 invalidates averages/count totals.\n");
|
||||
printf("Completion->nonempty includes idle gaps; first-nonempty excludes observed empty attempts, not proof of backlog.\n");
|
||||
for (unsigned i = 0; i < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++i) {
|
||||
const web_serial_performance_session_t *r = &s.sessions[i];
|
||||
if (!r->active) continue;
|
||||
printf("slot=%u fd=%d generation=%" PRIu32 " broker=%" PRIu32
|
||||
" pending=%u measured_pending=%u executing=%u pending_age_us=%" PRIu64 " saturated=%u\n",
|
||||
i, r->socket_fd, r->generation, (uint32_t)r->broker_client_id,
|
||||
r->pending, r->measured_pending, r->executing, r->pending_age_us, r->saturated);
|
||||
printf(" queued_frames=%" PRIu64 " queued_bytes=%" PRIu64 " queue_errors=%" PRIu64
|
||||
" sent_frames=%" PRIu64 " sent_bytes=%" PRIu64 " send_errors=%" PRIu64 " retired=%" PRIu64 "\n",
|
||||
r->queued_frames, r->queued_bytes, r->queue_errors, r->sent_frames,
|
||||
r->sent_bytes, r->send_errors, r->retired);
|
||||
print_performance_time("queue->callback-entry", &r->queue_wait);
|
||||
print_performance_time("send-call", &r->send_call);
|
||||
print_performance_time("completion->first-drain-attempt-return", &r->completion_attempt);
|
||||
print_performance_time("completion->next-nonempty-drain-return (includes idle)", &r->completion_nonempty);
|
||||
print_performance_time("completion->first-attempt-nonempty-return", &r->completion_first_nonempty);
|
||||
}
|
||||
printf("Web certificate and private key rotated and persisted.\n");
|
||||
return restart_if_running(snapshot.running);
|
||||
}
|
||||
|
||||
static int reset_material(void)
|
||||
{
|
||||
web_server_snapshot_t snapshot;
|
||||
bool was_running = web_server_get_snapshot(&snapshot) == ESP_OK && snapshot.running;
|
||||
web_security_credentials_t credentials;
|
||||
esp_err_t 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;
|
||||
}
|
||||
|
||||
esp_err_t web_revoke_error = web_serial_transport_revoke_sessions();
|
||||
esp_err_t ssh_revoke_error = ssh_transport_revoke_sessions();
|
||||
printf("Administrative credentials, HTTPS certificate, and HTTPS private key replaced and persisted.\n");
|
||||
if (web_revoke_error != ESP_OK && web_revoke_error != ESP_ERR_INVALID_STATE) {
|
||||
printf("Warning: existing WebSocket sessions could not be revoked: %s\n",
|
||||
esp_err_to_name(web_revoke_error));
|
||||
}
|
||||
if (ssh_revoke_error != ESP_OK && ssh_revoke_error != ESP_ERR_INVALID_STATE) {
|
||||
printf("Warning: existing SSH sessions could not be revoked: %s\n",
|
||||
esp_err_to_name(ssh_revoke_error));
|
||||
}
|
||||
printf("Username: %.*s\nPassword: %.*s\n",
|
||||
(int)credentials.username_length, credentials.username,
|
||||
(int)credentials.password_length, credentials.password);
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
if (was_running) {
|
||||
return restart_if_running(true);
|
||||
}
|
||||
|
||||
error = web_server_start();
|
||||
if (error != ESP_OK) {
|
||||
printf("Security material recovered, but HTTPS could not start: %s\n",
|
||||
esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS started with the recovered security material.\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -313,6 +302,16 @@ static int command_web(int argc, char **argv)
|
||||
print_usage();
|
||||
return 0;
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "performance") == 0) {
|
||||
if (performance_command(argv[2]) == 0) return 0;
|
||||
print_usage();
|
||||
return 1;
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "diagnostics") == 0) {
|
||||
if (web_diagnostics_command(argv[2]) == 0) return 0;
|
||||
print_usage();
|
||||
return 1;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "status") == 0) {
|
||||
return show_status();
|
||||
}
|
||||
@@ -326,6 +325,15 @@ static int command_web(int argc, char **argv)
|
||||
return 0;
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "stop") == 0) {
|
||||
if (admin_ssh_console_dispatch_is_web()) {
|
||||
esp_err_t error = admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_STOP, 0U);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not schedule HTTPS stop: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS stop scheduled after console output drains; both browser connections will close.\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));
|
||||
@@ -349,18 +357,6 @@ static int command_web(int argc, char **argv)
|
||||
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();
|
||||
@@ -371,11 +367,21 @@ static int command_web(int argc, char **argv)
|
||||
printf("Certificate rotation requires: web certificate rotate --force\n");
|
||||
return 1;
|
||||
}
|
||||
if (admin_ssh_console_dispatch_is_web()) {
|
||||
esp_err_t error = admin_ssh_console_dispatch_defer(
|
||||
ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE, 0U);
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not schedule HTTPS certificate rotation: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS identity rotation scheduled after console output drains; all web logins and browser terminals will close. A new identity may persist even if stop/start fails; no rollback. Verify the new fingerprint via trusted UART0 web certificate info before renewing browser trust, then reload and sign in. SSH and USB UART1 access remain independent.\n");
|
||||
return 0;
|
||||
}
|
||||
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");
|
||||
printf("TLS-only certificate/private-key replacement requires: web reset --force\n");
|
||||
return 1;
|
||||
}
|
||||
return reset_material();
|
||||
@@ -389,7 +395,7 @@ 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",
|
||||
.help = "Manage authenticated HTTPS and recover TLS certificate/private key",
|
||||
.hint = NULL,
|
||||
.func = &command_web,
|
||||
.argtable = NULL,
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_cookie_auth.h"
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "secure_random.h"
|
||||
#include "web_auth_parse.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include "web_login_ui.h"
|
||||
#include "web_serial_transport.h"
|
||||
|
||||
#define SESSION_COOKIE "__Host-sak-session"
|
||||
#define PRELOGIN_COOKIE "__Host-sak-prelogin"
|
||||
#define COOKIE_FLAGS "; Secure; HttpOnly; SameSite=Strict; Path=/; Max-Age="
|
||||
#define CHALLENGE_US 120000000LL
|
||||
#define WINDOW_US 60000000LL
|
||||
|
||||
typedef struct {
|
||||
int64_t expiry;
|
||||
uint8_t token_digest[32];
|
||||
uint8_t origin_digest[32];
|
||||
char csrf[65];
|
||||
} challenge_t;
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static challenge_t s_challenges[4];
|
||||
static bool s_ready;
|
||||
static uint64_t s_epoch;
|
||||
static int64_t s_window;
|
||||
static unsigned s_attempts;
|
||||
static web_cookie_auth_snapshot_t s_counts;
|
||||
|
||||
static bool equal(const void *a, const void *b, size_t size)
|
||||
{
|
||||
const uint8_t *x = a, *y = b;
|
||||
unsigned difference = 0;
|
||||
for (size_t i = 0; i < size; ++i) difference |= x[i] ^ y[i];
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
static bool header(httpd_req_t *r, const char *name, char *out, size_t size)
|
||||
{
|
||||
size_t length = httpd_req_get_hdr_value_len(r, name);
|
||||
out[0] = 0;
|
||||
return length < size && httpd_req_get_hdr_value_str(r, name, out, size) == ESP_OK;
|
||||
}
|
||||
|
||||
static bool origin(httpd_req_t *r, bool required, char canonical[129])
|
||||
{
|
||||
char host[129] = {0}, supplied[137] = {0}, site[16] = {0};
|
||||
if (!header(r, "Host", host, sizeof(host))) return false;
|
||||
if (header(r, "Sec-Fetch-Site", site, sizeof(site))) {
|
||||
if (strcmp(site, "same-origin") && strcmp(site, "none")) return false;
|
||||
} else if (httpd_req_get_hdr_value_len(r, "Sec-Fetch-Site")) return false;
|
||||
if (!header(r, "Origin", supplied, sizeof(supplied))) {
|
||||
if (required || httpd_req_get_hdr_value_len(r, "Origin")) return false;
|
||||
int length = snprintf(supplied, sizeof(supplied), "https://%s", host);
|
||||
if (length < 0 || (size_t)length >= sizeof(supplied)) return false;
|
||||
}
|
||||
return web_auth_parse_origin(host, strlen(host), supplied, strlen(supplied), canonical);
|
||||
}
|
||||
|
||||
static bool cookie(httpd_req_t *r, const char *name, char token[65])
|
||||
{
|
||||
char cookies[1025] = {0};
|
||||
bool valid = header(r, "Cookie", cookies, sizeof(cookies)) &&
|
||||
web_auth_parse_cookie(cookies, strlen(cookies), name, token);
|
||||
secure_wipe(cookies, sizeof(cookies));
|
||||
return valid;
|
||||
}
|
||||
|
||||
static bool cookies_valid(httpd_req_t *r)
|
||||
{
|
||||
char cookies[1025] = {0}, token[65] = {0};
|
||||
bool present;
|
||||
esp_err_t error = httpd_req_get_hdr_value_str(r, "Cookie", cookies, sizeof(cookies));
|
||||
bool valid = error == ESP_ERR_NOT_FOUND ||
|
||||
(error == ESP_OK && httpd_req_get_hdr_value_len(r, "Cookie") < sizeof(cookies) &&
|
||||
web_auth_parse_optional_cookie(cookies, strlen(cookies), SESSION_COOKIE, token, &present) &&
|
||||
web_auth_parse_optional_cookie(cookies, strlen(cookies), PRELOGIN_COOKIE, token, &present));
|
||||
secure_wipe(cookies, sizeof(cookies));
|
||||
secure_wipe(token, sizeof(token));
|
||||
return valid;
|
||||
}
|
||||
|
||||
static esp_err_t response(httpd_req_t *r, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(r, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(r, "application/json; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(r, "X-Frame-Options", "DENY");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(r, body);
|
||||
/* Never let HTTPD drain an attacker-controlled rejected request body. */
|
||||
return web_httpd_unread_body(r) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
static esp_err_t failure(httpd_req_t *r, const char *status, const char *code)
|
||||
{
|
||||
bool security = !strcmp(status, "403 Forbidden");
|
||||
bool credentials = !strcmp(code, "invalid_credentials");
|
||||
bool throttled = !strcmp(code, "throttled");
|
||||
bool full = !strcmp(code, "capacity");
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_counts.security_rejections += security;
|
||||
s_counts.login_failures += credentials;
|
||||
s_counts.throttled += throttled;
|
||||
s_counts.capacity_rejections += full;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
char body[80];
|
||||
snprintf(body, sizeof(body), "{\"error\":\"%s\"}", code);
|
||||
return response(r, status, body);
|
||||
}
|
||||
|
||||
static esp_err_t capacity(httpd_req_t *r)
|
||||
{
|
||||
if (httpd_resp_set_hdr(r, "Retry-After", "5") != ESP_OK) return ESP_FAIL;
|
||||
return failure(r, "503 Service Unavailable", "capacity");
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_start(void)
|
||||
{
|
||||
esp_err_t error = web_session_store_init();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (error == ESP_OK && s_epoch != UINT64_MAX) {
|
||||
++s_epoch;
|
||||
secure_wipe(s_challenges, sizeof(s_challenges));
|
||||
s_window = 0;
|
||||
s_attempts = 0;
|
||||
s_ready = true;
|
||||
} else {
|
||||
s_ready = false;
|
||||
error = ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_cookie_auth_stop(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
s_ready = false;
|
||||
if (s_epoch != UINT64_MAX) ++s_epoch;
|
||||
secure_wipe(s_challenges, sizeof(s_challenges));
|
||||
s_window = 0;
|
||||
s_attempts = 0;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
web_session_store_stop();
|
||||
}
|
||||
|
||||
void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot) return;
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
*snapshot = s_counts;
|
||||
snapshot->ready = s_ready;
|
||||
snapshot->active_challenges = 0;
|
||||
for (unsigned i = 0; i < 4; ++i) {
|
||||
if (s_challenges[i].expiry <= now) secure_wipe(&s_challenges[i], sizeof(s_challenges[i]));
|
||||
else ++snapshot->active_challenges;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_cookie_auth_clear_counters(void)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
memset(&s_counts, 0, sizeof(s_counts));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static esp_err_t require(httpd_req_t *r, bool mutation, bool upgrade, size_t body_limit,
|
||||
web_session_view_t *view, bool *allowed)
|
||||
{
|
||||
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
|
||||
*allowed = false;
|
||||
memset(view, 0, sizeof(*view));
|
||||
if (!web_httpd_headers_valid(r) || !cookies_valid(r) || (!upgrade && strchr(r->uri, '?')) ||
|
||||
r->content_len > body_limit || r->method != (mutation ? HTTP_POST : HTTP_GET))
|
||||
return failure(r, "400 Bad Request", "invalid_request");
|
||||
if (!origin(r, mutation || upgrade, canonical))
|
||||
return failure(r, "403 Forbidden", "origin");
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) return failure(r, "503 Service Unavailable", "unavailable");
|
||||
esp_err_t error = ESP_ERR_NOT_FOUND;
|
||||
if (cookie(r, SESSION_COOKIE, token))
|
||||
error = web_session_store_lookup(token, strlen(token), canonical, strlen(canonical), view);
|
||||
secure_wipe(token, sizeof(token));
|
||||
if (error != ESP_OK) {
|
||||
if (error != ESP_ERR_NOT_FOUND)
|
||||
return failure(r, "503 Service Unavailable", "unavailable");
|
||||
if (!strcmp(r->uri, "/")) {
|
||||
if (httpd_resp_set_hdr(r, "Location", "/login") != ESP_OK) return ESP_FAIL;
|
||||
return response(r, "303 See Other", "");
|
||||
}
|
||||
return failure(r, "401 Unauthorized", "authentication_required");
|
||||
}
|
||||
if (mutation && (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) ||
|
||||
strlen(csrf) != 64U || !equal(csrf, view->csrf, 64U))) {
|
||||
secure_wipe(csrf, sizeof(csrf));
|
||||
secure_wipe(view, sizeof(*view));
|
||||
return failure(r, "403 Forbidden", "csrf");
|
||||
}
|
||||
secure_wipe(csrf, sizeof(csrf));
|
||||
*allowed = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_require(httpd_req_t *r, bool mutation, bool upgrade,
|
||||
web_session_view_t *view, bool *allowed)
|
||||
{
|
||||
return require(r, mutation, upgrade, 0, view, allowed);
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_require_json(httpd_req_t *r, size_t body_limit,
|
||||
web_session_view_t *view, bool *allowed)
|
||||
{
|
||||
return require(r, true, false, body_limit, view, allowed);
|
||||
}
|
||||
|
||||
static bool secret(char out[65])
|
||||
{
|
||||
uint8_t bytes[32];
|
||||
bool ok = secure_random_fill(bytes, sizeof(bytes)) == ESP_OK;
|
||||
if (ok) {
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
for (size_t i = 0; i < sizeof(bytes); ++i) {
|
||||
out[2*i] = hex[bytes[i] >> 4];
|
||||
out[2*i+1] = hex[bytes[i] & 15];
|
||||
}
|
||||
out[64] = 0;
|
||||
}
|
||||
secure_wipe(bytes, sizeof(bytes));
|
||||
return ok;
|
||||
}
|
||||
|
||||
esp_err_t web_cookie_auth_handler(httpd_req_t *r)
|
||||
{
|
||||
bool login = !strcmp(r->uri, "/api/login");
|
||||
bool bootstrap = !strcmp(r->uri, "/api/login-challenge");
|
||||
bool logout = !strcmp(r->uri, "/api/logout");
|
||||
bool document = !strcmp(r->uri, "/login");
|
||||
web_session_view_t view = {0};
|
||||
char canonical[129] = {0}, token[65] = {0}, csrf[65] = {0};
|
||||
char set_cookie[180] = {0}, body[513] = {0};
|
||||
web_auth_credentials_t credentials = {0};
|
||||
challenge_t candidate = {0};
|
||||
uint8_t digest[32] = {0}, origin_digest[32] = {0};
|
||||
esp_err_t result = ESP_FAIL;
|
||||
const char *status = "400 Bad Request", *code = "invalid_request";
|
||||
bool consumed = false, allowed = false;
|
||||
bool challenge_published = false;
|
||||
uint64_t epoch;
|
||||
int selected = -1;
|
||||
int64_t now = esp_timer_get_time();
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_ready;
|
||||
epoch = s_epoch;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
|
||||
if (!web_httpd_headers_valid(r) || !cookies_valid(r) || strchr(r->uri, '?') ||
|
||||
r->method != ((login || logout) ? HTTP_POST : HTTP_GET) ||
|
||||
(!login && r->content_len)) goto deny;
|
||||
if (document) { result = web_login_ui_send_response(r); goto cleanup; }
|
||||
if (!login && !bootstrap) {
|
||||
result = web_cookie_auth_require(r, logout, false, &view, &allowed);
|
||||
if (!allowed) goto cleanup;
|
||||
if (logout) {
|
||||
web_serial_transport_revoke_web_session(view.id);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
++s_counts.logouts;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
result = httpd_resp_set_hdr(r, "Set-Cookie", SESSION_COOKIE "=" COOKIE_FLAGS "0");
|
||||
if (result != ESP_OK) goto cleanup;
|
||||
result = response(r, "204 No Content", "");
|
||||
} else {
|
||||
/* Database usernames are restricted ASCII; encode nevertheless. */
|
||||
char username[97] = {0};
|
||||
size_t used = 0;
|
||||
for (size_t i = 0; i < view.principal.username_length && i < 16; ++i)
|
||||
used += (size_t)snprintf(username + used, sizeof(username) - used,
|
||||
"\\u%04x", (unsigned char)view.principal.username[i]);
|
||||
int64_t remaining = (view.expires_at_us - esp_timer_get_time()) / 1000000LL;
|
||||
snprintf(body, sizeof(body), "{\"username\":\"%s\",\"role\":\"%s\",\"csrf\":\"%s\",\"expires_in\":%lld}",
|
||||
username, view.principal.role == USER_ROLE_ADMIN ? "admin" : "user", view.csrf,
|
||||
(long long)(remaining > 0 ? remaining : 0));
|
||||
result = response(r, "200 OK", body);
|
||||
}
|
||||
goto cleanup;
|
||||
}
|
||||
if (!origin(r, login, canonical)) { status = "403 Forbidden"; code = "origin"; goto deny; }
|
||||
if (mbedtls_sha256((const uint8_t *)canonical, strlen(canonical), origin_digest, 0)) goto deny;
|
||||
if (bootstrap) {
|
||||
char flag[2];
|
||||
if (!header(r, "X-Login-Bootstrap", flag, sizeof(flag)) || strcmp(flag, "1")) {
|
||||
status = "403 Forbidden"; code = "csrf"; goto deny;
|
||||
}
|
||||
} else {
|
||||
char type[40];
|
||||
if (!header(r, "Content-Type", type, sizeof(type)) ||
|
||||
(strcmp(type, "application/json") && strcmp(type, "application/json; charset=utf-8"))) {
|
||||
status = "415 Unsupported Media Type"; code = "content_type"; goto deny;
|
||||
}
|
||||
if (!r->content_len || r->content_len > 512U) {
|
||||
status = "413 Payload Too Large"; code = "body_size"; goto deny;
|
||||
}
|
||||
if (cookie(r, SESSION_COOKIE, token) &&
|
||||
web_session_store_lookup(token, 64, canonical, strlen(canonical), &view) == ESP_OK) {
|
||||
status = "409 Conflict"; code = "already_authenticated"; goto deny;
|
||||
}
|
||||
if (!header(r, "X-CSRF-Token", csrf, sizeof(csrf)) || strlen(csrf) != 64) {
|
||||
status = "403 Forbidden"; code = "csrf"; goto deny;
|
||||
}
|
||||
}
|
||||
bool has_cookie = cookie(r, PRELOGIN_COOKIE, token);
|
||||
if (has_cookie && mbedtls_sha256((const uint8_t *)token, 64, digest, 0)) goto deny;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
challenge_t *entry = &s_challenges[i];
|
||||
if (entry->expiry <= now) secure_wipe(entry, sizeof(*entry));
|
||||
if (s_ready && epoch == s_epoch && entry->expiry && has_cookie &&
|
||||
equal(entry->token_digest, digest, 32) && equal(entry->origin_digest, origin_digest, 32)) {
|
||||
if (bootstrap || equal(entry->csrf, csrf, 64)) {
|
||||
candidate = *entry;
|
||||
selected = i;
|
||||
if (login) { secure_wipe(entry, sizeof(*entry)); consumed = true; }
|
||||
}
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (bootstrap) {
|
||||
bool fresh = selected < 0;
|
||||
if (fresh) {
|
||||
if (!secret(token) || !secret(candidate.csrf) ||
|
||||
mbedtls_sha256((const uint8_t *)token, 64, candidate.token_digest, 0)) {
|
||||
status = "503 Service Unavailable"; code = "unavailable"; goto deny;
|
||||
}
|
||||
memcpy(candidate.origin_digest, origin_digest, 32);
|
||||
candidate.expiry = now + CHALLENGE_US;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_ready && epoch == s_epoch) for (int i = 0; i < 4; ++i) {
|
||||
if (!s_challenges[i].expiry) {
|
||||
s_challenges[i] = candidate; selected = i; challenge_published = true; break;
|
||||
}
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (selected < 0) { result = capacity(r); goto cleanup; }
|
||||
snprintf(set_cookie, sizeof(set_cookie), PRELOGIN_COOKIE "=%s" COOKIE_FLAGS "120", token);
|
||||
if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) goto cleanup;
|
||||
}
|
||||
snprintf(body, sizeof(body), "{\"csrf\":\"%s\",\"expires_in\":%lld}", candidate.csrf,
|
||||
(long long)((candidate.expiry - now) / 1000000LL));
|
||||
result = response(r, "200 OK", body);
|
||||
goto cleanup;
|
||||
}
|
||||
if (!consumed) { status = "403 Forbidden"; code = "challenge_expired"; goto deny; }
|
||||
if (httpd_resp_set_hdr(r, "Set-Cookie", PRELOGIN_COOKIE "=" COOKIE_FLAGS "0") != ESP_OK) goto cleanup;
|
||||
size_t received = 0;
|
||||
int64_t deadline = now + 3000000LL;
|
||||
while (received < r->content_len && esp_timer_get_time() < deadline) {
|
||||
int count = httpd_req_recv(r, body + received, r->content_len - received);
|
||||
if (count <= 0) goto deny;
|
||||
received += (size_t)count;
|
||||
}
|
||||
if (received != r->content_len || !web_auth_parse_login(body, received, &credentials)) goto deny;
|
||||
now = esp_timer_get_time();
|
||||
unsigned attempts;
|
||||
int64_t retry;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
ready = s_ready && epoch == s_epoch;
|
||||
if (now - s_window >= WINDOW_US) { s_window = now; s_attempts = 0; }
|
||||
attempts = s_attempts;
|
||||
if (ready && attempts < 5) { ++s_attempts; ++s_counts.login_attempts; }
|
||||
retry = (s_window + WINDOW_US - now + 999999LL) / 1000000LL;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
|
||||
if (attempts >= 5) {
|
||||
char seconds[16];
|
||||
snprintf(seconds, sizeof(seconds), "%lld", (long long)retry);
|
||||
if (httpd_resp_set_hdr(r, "Retry-After", seconds) != ESP_OK) goto cleanup;
|
||||
result = failure(r, "429 Too Many Requests", "throttled");
|
||||
goto cleanup;
|
||||
}
|
||||
bool authenticated = false;
|
||||
user_principal_t principal = {0};
|
||||
esp_err_t error = user_database_authenticate_password(credentials.username, credentials.username_length,
|
||||
credentials.password, credentials.password_length, &principal, &authenticated);
|
||||
secure_wipe(body, sizeof(body));
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
if (error == ESP_OK && authenticated)
|
||||
error = web_session_store_issue(&principal, canonical, strlen(canonical), token, &view);
|
||||
secure_wipe(&principal, sizeof(principal));
|
||||
if (error == ESP_ERR_NO_MEM) { result = capacity(r); goto cleanup; }
|
||||
if (error != ESP_OK) { status = "503 Service Unavailable"; code = "unavailable"; goto deny; }
|
||||
if (!authenticated) { status = "401 Unauthorized"; code = "invalid_credentials"; goto deny; }
|
||||
snprintf(set_cookie, sizeof(set_cookie), SESSION_COOKIE "=%s" COOKIE_FLAGS "3600", token);
|
||||
if (httpd_resp_set_hdr(r, "Set-Cookie", set_cookie) != ESP_OK) {
|
||||
web_session_store_invalidate(view.id); goto cleanup;
|
||||
}
|
||||
result = response(r, "200 OK", "{\"authenticated\":true}");
|
||||
if (result != ESP_OK) web_session_store_invalidate(view.id);
|
||||
goto cleanup;
|
||||
deny:
|
||||
result = failure(r, status, code);
|
||||
cleanup:
|
||||
if (challenge_published && result != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (epoch == s_epoch && selected >= 0 &&
|
||||
equal(s_challenges[selected].token_digest, candidate.token_digest, 32))
|
||||
secure_wipe(&s_challenges[selected], sizeof(s_challenges[selected]));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
web_httpd_wipe_request(r, web_httpd_unread_body(r));
|
||||
secure_wipe(&view, sizeof(view));
|
||||
secure_wipe(token, sizeof(token));
|
||||
secure_wipe(csrf, sizeof(csrf));
|
||||
secure_wipe(set_cookie, sizeof(set_cookie));
|
||||
secure_wipe(body, sizeof(body));
|
||||
secure_wipe(&credentials, sizeof(credentials));
|
||||
secure_wipe(&candidate, sizeof(candidate));
|
||||
secure_wipe(digest, sizeof(digest));
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include "esp_http_server.h"
|
||||
#include "web_session_store.h"
|
||||
|
||||
esp_err_t web_cookie_auth_start(void);
|
||||
void web_cookie_auth_stop(void);
|
||||
typedef struct {
|
||||
uint32_t login_attempts, login_failures, throttled, capacity_rejections;
|
||||
uint32_t security_rejections, logouts, active_challenges;
|
||||
bool ready;
|
||||
} web_cookie_auth_snapshot_t;
|
||||
void web_cookie_auth_get_snapshot(web_cookie_auth_snapshot_t *snapshot);
|
||||
void web_cookie_auth_clear_counters(void);
|
||||
/* Sends an error on denial, with allowed=false. View is caller-wiped. */
|
||||
esp_err_t web_cookie_auth_require(httpd_req_t *request, bool mutation,
|
||||
bool upgrade, web_session_view_t *view,
|
||||
bool *allowed);
|
||||
esp_err_t web_cookie_auth_handler(httpd_req_t *request);
|
||||
/* Same mutation policy, allowing a bounded body; caller validates JSON/content type. */
|
||||
esp_err_t web_cookie_auth_require_json(httpd_req_t *request, size_t body_limit,
|
||||
web_session_view_t *view, bool *allowed);
|
||||
@@ -0,0 +1,233 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Post-TLS observation only. Never retain request data or replace TLS cleanup. */
|
||||
#include "web_diagnostics.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define DIAG_SOCKETS 6U
|
||||
#define DIAG_EVENTS 32U
|
||||
|
||||
typedef struct {
|
||||
const esp_tls_t *tls; /* Identity only, never dereferenced or printed. */
|
||||
uint64_t seq;
|
||||
int64_t opened_us;
|
||||
int fd;
|
||||
unsigned kind; /* 0 ordinary, 1 serial WS, 2 admin WS */
|
||||
} connection_t;
|
||||
|
||||
typedef struct {
|
||||
uint64_t id, seq;
|
||||
int64_t at_us, elapsed_us;
|
||||
uint32_t free_bytes[3], largest[3], stack_bytes;
|
||||
int fd, result;
|
||||
unsigned event, route, ordinary, serial, admin;
|
||||
} trace_t;
|
||||
|
||||
enum { TLS_OPEN, TLS_CLOSE, ENTER, RESULT };
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static connection_t s_connections[DIAG_SOCKETS];
|
||||
static trace_t s_events[DIAG_EVENTS];
|
||||
static bool s_enabled;
|
||||
static uint64_t s_connection_seq, s_event_seq, s_epoch;
|
||||
static uint32_t s_overwritten, s_unmatched, s_lost;
|
||||
static unsigned s_count, s_next;
|
||||
|
||||
static void increment(uint32_t *value)
|
||||
{
|
||||
if (*value != UINT32_MAX) ++*value;
|
||||
}
|
||||
|
||||
static void occupancy(trace_t *event)
|
||||
{
|
||||
for (unsigned i = 0; i < DIAG_SOCKETS; ++i) {
|
||||
if (!s_connections[i].seq) continue;
|
||||
if (s_connections[i].kind == 1) ++event->serial;
|
||||
else if (s_connections[i].kind == 2) ++event->admin;
|
||||
else ++event->ordinary;
|
||||
}
|
||||
}
|
||||
|
||||
/* Called by HTTPD only. Expensive capability scans stay outside the portMUX.
|
||||
* Epoch rejects a sample crossing clear/disable/enable. Sequence fences fd reuse. */
|
||||
static void record(connection_t connection, unsigned event, unsigned route,
|
||||
int result, int64_t elapsed_us, uint64_t epoch)
|
||||
{
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
bool enabled = s_enabled && s_epoch == epoch;
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
if (!enabled) return;
|
||||
trace_t row = {.seq = connection.seq, .fd = connection.fd,
|
||||
.at_us = esp_timer_get_time(), .elapsed_us = elapsed_us,
|
||||
.event = event, .route = route, .result = result};
|
||||
const uint32_t caps[] = {MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT,
|
||||
MALLOC_CAP_INTERNAL | MALLOC_CAP_DMA,
|
||||
MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT};
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
row.free_bytes[i] = heap_caps_get_free_size(caps[i]);
|
||||
row.largest[i] = heap_caps_get_largest_free_block(caps[i]);
|
||||
}
|
||||
/* ESP-IDF FreeRTOS reports minimum-free stack in bytes, not vanilla words. */
|
||||
row.stack_bytes = uxTaskGetStackHighWaterMark(NULL);
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
if (s_enabled && s_epoch == epoch && s_event_seq != UINT64_MAX) {
|
||||
row.id = ++s_event_seq;
|
||||
occupancy(&row);
|
||||
s_events[s_next] = row;
|
||||
s_next = (s_next + 1U) % DIAG_EVENTS;
|
||||
if (s_count < DIAG_EVENTS) ++s_count;
|
||||
else increment(&s_overwritten);
|
||||
}
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_diagnostics_tls(esp_https_server_user_cb_arg_t *arg)
|
||||
{
|
||||
if (!arg || !arg->tls) return;
|
||||
connection_t connection = {0};
|
||||
int fd = -1;
|
||||
if (arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE &&
|
||||
(esp_tls_get_conn_sockfd(arg->tls, &fd) != ESP_OK || fd < 0)) {
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
increment(&s_lost);
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
return;
|
||||
}
|
||||
int64_t now = esp_timer_get_time();
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
uint64_t epoch = s_epoch;
|
||||
if (arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE) {
|
||||
unsigned i;
|
||||
for (i = 0; i < DIAG_SOCKETS; ++i)
|
||||
if (s_connections[i].seq && (s_connections[i].tls == arg->tls ||
|
||||
s_connections[i].fd == fd)) break;
|
||||
/* Duplicate notifications are not new connections. */
|
||||
if (i != DIAG_SOCKETS) {
|
||||
increment(&s_unmatched);
|
||||
} else {
|
||||
for (i = 0; i < DIAG_SOCKETS; ++i) if (!s_connections[i].seq) break;
|
||||
if (i < DIAG_SOCKETS && s_connection_seq != UINT64_MAX) {
|
||||
connection = (connection_t){.tls = arg->tls, .fd = fd,
|
||||
.seq = ++s_connection_seq, .opened_us = now};
|
||||
s_connections[i] = connection;
|
||||
} else increment(&s_lost);
|
||||
}
|
||||
} else if (arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CLOSE) {
|
||||
unsigned i;
|
||||
for (i = 0; i < DIAG_SOCKETS; ++i)
|
||||
if (s_connections[i].seq && s_connections[i].tls == arg->tls) break;
|
||||
if (i < DIAG_SOCKETS) {
|
||||
connection = s_connections[i];
|
||||
memset(&s_connections[i], 0, sizeof(s_connections[i]));
|
||||
} else increment(&s_unmatched);
|
||||
}
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
if (connection.seq)
|
||||
record(connection, arg->user_cb_state == HTTPD_SSL_USER_CB_SESS_CREATE ? TLS_OPEN : TLS_CLOSE,
|
||||
0, 0, now - connection.opened_us, epoch);
|
||||
}
|
||||
|
||||
esp_err_t web_diagnostics_handler(httpd_req_t *request, web_diag_route_t route,
|
||||
esp_err_t (*handler)(httpd_req_t *))
|
||||
{
|
||||
int fd = httpd_req_to_sockfd(request);
|
||||
connection_t connection = {.fd = fd};
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
uint64_t epoch = s_epoch;
|
||||
bool enabled = s_enabled;
|
||||
for (unsigned i = 0; i < DIAG_SOCKETS; ++i)
|
||||
if (s_connections[i].seq && s_connections[i].fd == fd) connection = s_connections[i];
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
record(connection, ENTER, route, 0, 0, epoch);
|
||||
int64_t start = enabled ? esp_timer_get_time() : 0;
|
||||
esp_err_t result = handler(request);
|
||||
int64_t elapsed = enabled ? esp_timer_get_time() - start : 0;
|
||||
if (route == WEB_DIAG_SERIAL_UPGRADE || route == WEB_DIAG_ADMIN_UPGRADE) {
|
||||
bool upgraded = httpd_ws_get_fd_info(request->handle, fd) == HTTPD_WS_CLIENT_WEBSOCKET;
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
for (unsigned i = 0; i < DIAG_SOCKETS; ++i)
|
||||
if (connection.seq && s_connections[i].seq == connection.seq && upgraded)
|
||||
s_connections[i].kind = route == WEB_DIAG_SERIAL_UPGRADE ? 1U : 2U;
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
record(connection, RESULT, route, result, elapsed, epoch);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void show(void)
|
||||
{
|
||||
connection_t connections[DIAG_SOCKETS];
|
||||
trace_t counts = {0};
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
memcpy(connections, s_connections, sizeof(connections));
|
||||
occupancy(&counts);
|
||||
uint64_t last = s_event_seq;
|
||||
unsigned count = s_count;
|
||||
bool enabled = s_enabled;
|
||||
uint32_t overwritten = s_overwritten, unmatched = s_unmatched, lost = s_lost;
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
int64_t now = esp_timer_get_time();
|
||||
printf("Web diagnostics %s; post-TLS occupancy=%u/6 ordinary=%u serial=%u admin=%u\n",
|
||||
enabled ? "enabled" : "disabled", counts.ordinary + counts.serial + counts.admin,
|
||||
counts.ordinary, counts.serial, counts.admin);
|
||||
printf("snapshot_us=%" PRId64 " retained=%u/32 overwritten=%" PRIu32 " unmatched=%" PRIu32 " lost=%" PRIu32 "\n",
|
||||
now, count, overwritten, unmatched, lost);
|
||||
printf("No preaccept/TLS-failure timing; occupancy excludes in-progress TLS. rc is handler return, NOT HTTP status.\n");
|
||||
for (unsigned i = 0; i < DIAG_SOCKETS; ++i) {
|
||||
connection_t c = connections[i];
|
||||
if (c.seq) printf("live fd=%d conn=%" PRIu64 " kind=%u opened_us=%" PRId64 " age_us=%" PRId64 "\n",
|
||||
c.fd, c.seq, c.kind, c.opened_us, now - c.opened_us);
|
||||
}
|
||||
printf("events: open/close/enter/result; routes: serial-ticket/admin-ticket/serial-upgrade/admin-upgrade; heap pairs free/largest internal,DMA,PSRAM bytes; stack=HTTPD minimum-free bytes\n");
|
||||
const char *const events[] = {"open", "close", "enter", "result"};
|
||||
const char *const routes[] = {"serial-ticket", "admin-ticket", "serial-upgrade", "admin-upgrade"};
|
||||
/* Copy one immutable-ID-qualified row at a time; never hold a lock while printing.
|
||||
* Concurrent overwrite/clear can omit rows, but cannot turn show into an endless stream. */
|
||||
for (unsigned n = 0; n < count; ++n) {
|
||||
uint64_t id = last - count + 1U + n;
|
||||
trace_t row = {0};
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
for (unsigned i = 0; i < DIAG_EVENTS; ++i)
|
||||
if (s_events[i].id == id) { row = s_events[i]; break; }
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
if (!row.id) { printf("event=%" PRIu64 " no longer retained\n", id); continue; }
|
||||
printf("event=%" PRIu64 " t_us=%" PRId64 " fd=%d conn=%" PRIu64 " %s %s rc=%d dt_us=%" PRId64
|
||||
" occ=%u/%u/%u heap=%" PRIu32 "/%" PRIu32 ",%" PRIu32 "/%" PRIu32 ",%" PRIu32 "/%" PRIu32 " stack=%" PRIu32 "\n",
|
||||
row.id, row.at_us, row.fd, row.seq, events[row.event],
|
||||
row.event < ENTER ? "-" : routes[row.route], row.result, row.elapsed_us,
|
||||
row.ordinary, row.serial, row.admin,
|
||||
row.free_bytes[0], row.largest[0], row.free_bytes[1], row.largest[1],
|
||||
row.free_bytes[2], row.largest[2], row.stack_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
int web_diagnostics_command(const char *action)
|
||||
{
|
||||
if (strcmp(action, "show") == 0) { show(); return 0; }
|
||||
bool enable = strcmp(action, "enable") == 0;
|
||||
bool disable = strcmp(action, "disable") == 0;
|
||||
bool clear = strcmp(action, "clear") == 0;
|
||||
if (!enable && !disable && !clear) return 1;
|
||||
portENTER_CRITICAL(&s_lock);
|
||||
/* Never wrap identity or capture epochs; exhausting diagnostics cannot affect HTTPD. */
|
||||
if (s_epoch != UINT64_MAX) {
|
||||
++s_epoch;
|
||||
if (enable || disable) s_enabled = enable;
|
||||
} else s_enabled = false;
|
||||
if (clear) {
|
||||
memset(s_events, 0, sizeof(s_events));
|
||||
s_count = s_next = 0;
|
||||
s_overwritten = s_unmatched = s_lost = 0;
|
||||
}
|
||||
bool enabled = s_enabled;
|
||||
portEXIT_CRITICAL(&s_lock);
|
||||
printf("Web diagnostics %s%s; live identities retained.\n", enabled ? "enabled" : "disabled",
|
||||
clear ? ", trace cleared" : "");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
|
||||
#include "esp_https_server.h"
|
||||
|
||||
typedef enum {
|
||||
WEB_DIAG_SERIAL_TICKET,
|
||||
WEB_DIAG_ADMIN_TICKET,
|
||||
WEB_DIAG_SERIAL_UPGRADE,
|
||||
WEB_DIAG_ADMIN_UPGRADE,
|
||||
} web_diag_route_t;
|
||||
|
||||
/* Synchronous HTTPD-owner callbacks only; no socket/context ownership transfer. */
|
||||
void web_diagnostics_tls(esp_https_server_user_cb_arg_t *arg);
|
||||
esp_err_t web_diagnostics_handler(httpd_req_t *request, web_diag_route_t route,
|
||||
esp_err_t (*handler)(httpd_req_t *));
|
||||
/* Canonical console dispatcher only. No HTTPD calls or network waits. */
|
||||
int web_diagnostics_command(const char *action);
|
||||
@@ -0,0 +1,236 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_display_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "secure_random.h"
|
||||
#include "local_status_ui.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
|
||||
enum { APPLY, SAVE, LOAD, DEFAULTS, RESET, ACTION_COUNT };
|
||||
static const char *const s_actions[] = {"apply", "save", "load", "defaults", "reset"};
|
||||
enum { IDLE, PENDING, OK, FAILED, CANCELLED, LOADED_DEFAULTS, CONFLICT };
|
||||
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "loaded_defaults", "conflict"};
|
||||
typedef struct {
|
||||
uint32_t id;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
int64_t deadline;
|
||||
local_ui_config_t config;
|
||||
uint32_t generation;
|
||||
unsigned action, state;
|
||||
} display_operation_t;
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static display_operation_t s_operation;
|
||||
static uint32_t s_next_id;
|
||||
|
||||
/* Deliberately narrow flat JSON: ASCII names/enums, unsigned decimal integers,
|
||||
* no escapes, nesting, duplicate/unknown fields, exponent or fractional values. */
|
||||
static bool parse(const char *body, size_t length, display_operation_t *operation)
|
||||
{
|
||||
const char *keys[] = {"action", "generation", "dim_seconds", "off_seconds"};
|
||||
unsigned seen = 0;
|
||||
size_t pos = 0;
|
||||
local_ui_config_defaults(&operation->config);
|
||||
operation->action = ACTION_COUNT;
|
||||
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
|
||||
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
|
||||
TAKE('{');
|
||||
for (unsigned field = 0; field < 4; ++field) {
|
||||
if (field) { TAKE(','); }
|
||||
TAKE('"');
|
||||
size_t start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
unsigned key = 0;
|
||||
for (; key < 4; ++key)
|
||||
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
||||
if (key == 4 || (seen & (1U << key))) return false;
|
||||
++pos; TAKE(':'); SPACE();
|
||||
if (key == 0) {
|
||||
TAKE('"'); start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
for (unsigned i = 0; i < ACTION_COUNT; ++i)
|
||||
if (strlen(s_actions[i]) == pos - start && !memcmp(body + start, s_actions[i], pos - start)) operation->action = i;
|
||||
if (operation->action == ACTION_COUNT) return false;
|
||||
++pos;
|
||||
} else {
|
||||
uint32_t number = 0;
|
||||
start = pos;
|
||||
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
|
||||
unsigned digit = (unsigned)(body[pos++] - '0');
|
||||
if (number > (UINT32_MAX - digit) / 10U) return false;
|
||||
number = number * 10U + digit;
|
||||
}
|
||||
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
||||
if (key == 1) operation->generation = number;
|
||||
if (key == 2) operation->config.dim_timeout_seconds = number;
|
||||
if (key == 3) operation->config.off_timeout_seconds = number;
|
||||
}
|
||||
seen |= 1U << key;
|
||||
SPACE();
|
||||
if (pos < length && body[pos] == '}') break;
|
||||
}
|
||||
TAKE('}'); SPACE();
|
||||
#undef TAKE
|
||||
#undef SPACE
|
||||
return pos == length && seen == (operation->action == APPLY ? 15U : 3U) &&
|
||||
operation->generation != 0 && local_ui_config_validate(&operation->config) == ESP_OK;
|
||||
}
|
||||
|
||||
void web_display_settings_execute(uint32_t id)
|
||||
{
|
||||
display_operation_t operation;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
operation = s_operation;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!id || operation.id != id || operation.state != PENDING) {
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
return;
|
||||
}
|
||||
bool current = false;
|
||||
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t);
|
||||
unsigned state = CANCELLED;
|
||||
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
||||
esp_timer_get_time() < operation.deadline) {
|
||||
/* The owner checks the selected generation and reserves all config
|
||||
* mutations, including CLI callers, across storage IO. Buttons only
|
||||
* signal activity: they never replace configuration or own this gate. */
|
||||
bool defaults = false;
|
||||
static const local_ui_settings_action_t actions[] = {
|
||||
LOCAL_UI_SETTINGS_APPLY, LOCAL_UI_SETTINGS_SAVE, LOCAL_UI_SETTINGS_LOAD,
|
||||
LOCAL_UI_SETTINGS_DEFAULTS, LOCAL_UI_SETTINGS_RESET
|
||||
};
|
||||
error = local_status_ui_update_settings(actions[operation.action], operation.generation,
|
||||
&operation.config, &defaults);
|
||||
state = error == ESP_OK ? (defaults ? LOADED_DEFAULTS : OK) :
|
||||
error == ESP_ERR_INVALID_STATE ? CONFLICT : FAILED;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.id == id && s_operation.state == PENDING) {
|
||||
s_operation.state = state;
|
||||
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
|
||||
secure_wipe(&s_operation.config, sizeof(s_operation.config));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
}
|
||||
|
||||
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(request, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
esp_err_t web_display_operation_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
bool mutation = request->method == HTTP_POST;
|
||||
esp_err_t error = mutation
|
||||
? web_cookie_auth_require_json(request, 256, &view, &allowed)
|
||||
: web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
display_operation_t operation = {0};
|
||||
if (mutation) {
|
||||
char type[40] = {0}, body[256];
|
||||
size_t received = 0;
|
||||
bool valid = request->content_len &&
|
||||
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
|
||||
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
|
||||
/* Finite bytes and receive calls; timeout/error closes, never retry/drain. */
|
||||
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
|
||||
int count = httpd_req_recv(request, body + received, request->content_len - received);
|
||||
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
|
||||
else received += (size_t)count;
|
||||
}
|
||||
valid = valid && received == request->content_len && parse(body, received, &operation);
|
||||
secure_wipe(body, sizeof(body));
|
||||
if (!valid) {
|
||||
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_display_request\"}");
|
||||
goto done;
|
||||
}
|
||||
operation.session = view.id;
|
||||
operation.principal = view.principal;
|
||||
operation.deadline = esp_timer_get_time() + 30000000LL;
|
||||
operation.state = PENDING;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
|
||||
if (!busy) {
|
||||
operation.id = ++s_next_id;
|
||||
s_operation = operation;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (busy || admin_ssh_console_submit_display_settings(operation.id) != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
error = httpd_resp_set_hdr(request, "Retry-After", "1");
|
||||
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
goto done;
|
||||
}
|
||||
} else {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.session == view.id) {
|
||||
operation.id = s_operation.id;
|
||||
operation.action = s_operation.action;
|
||||
operation.state = s_operation.state;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
char response[96];
|
||||
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
|
||||
operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]);
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
||||
respond(request, mutation ? "202 Accepted" : "200 OK", response);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
done:
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_display_settings_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
local_ui_config_t config;
|
||||
uint32_t generation;
|
||||
error = local_status_ui_get_settings(&config, &generation);
|
||||
if (error != ESP_OK) {
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"display_unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
char response[128];
|
||||
int written = snprintf(response, sizeof(response),
|
||||
"{\"generation\":%" PRIu32 ",\"dim_seconds\":%" PRIu32 ",\"off_seconds\":%" PRIu32 "}",
|
||||
generation, config.dim_timeout_seconds, config.off_timeout_seconds);
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
||||
respond(request, "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/* Optional admin-only RAM snapshot and typed dispatcher admission/results. */
|
||||
esp_err_t web_display_settings_handler(httpd_req_t *request);
|
||||
esp_err_t web_display_operation_handler(httpd_req_t *request);
|
||||
void web_display_settings_execute(uint32_t id);
|
||||
@@ -0,0 +1,239 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Deliberately isolated dependency on the installed IDF HTTPD layout. */
|
||||
#include "web_httpd_adapter.h"
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include "esp_idf_version.h"
|
||||
#include "esp_httpd_priv.h"
|
||||
#include "secure_random.h"
|
||||
|
||||
#if ESP_IDF_VERSION != ESP_IDF_VERSION_VAL(5, 5, 0)
|
||||
#error "Reaudit HTTPD headers, upgrade, idle cleanup and WS/TLS send contracts for this IDF"
|
||||
#endif
|
||||
|
||||
/* IDF 5.5.0 httpd_sess_process increments lru_counter only AFTER successful
|
||||
* req_new + req_delete (handler, response, leftover-body purge and cleanup).
|
||||
* Work callbacks run between sessions, never inside synchronous parse/TLS/send.
|
||||
* This counter is an observation marker, NOT permission to enable LRU purge. */
|
||||
void web_httpd_idle_sweep(httpd_handle_t server,
|
||||
web_httpd_idle_row_t rows[WEB_HTTPD_IDLE_SOCKETS], int64_t now)
|
||||
{
|
||||
struct httpd_data *hd = server;
|
||||
if (!hd || !rows || hd->config.max_open_sockets > WEB_HTTPD_IDLE_SOCKETS ||
|
||||
httpd_os_thread_handle() != hd->hd_td.handle || hd->hd_req_aux.sd) return;
|
||||
for (unsigned i = 0; i < hd->config.max_open_sockets; ++i) {
|
||||
struct sock_db *sd = &hd->hd_sd[i];
|
||||
web_httpd_idle_row_t *row = &rows[i];
|
||||
/* Actual SDK classification, not delayed diagnostic route metadata. */
|
||||
if (sd->fd < 0 || sd->for_async_req || sd->ws_handshake_done || sd->ws_close) {
|
||||
memset(row, 0, sizeof(*row));
|
||||
continue;
|
||||
}
|
||||
if (!row->observed || row->fd != sd->fd || row->completed != sd->lru_counter) {
|
||||
*row = (web_httpd_idle_row_t){.fd = sd->fd, .completed = sd->lru_counter,
|
||||
.idle_since_us = now, .observed = true};
|
||||
continue;
|
||||
}
|
||||
if (row->shutdown_sent) continue;
|
||||
/* Control work precedes data processing in httpd_main. Do not expire a
|
||||
* connection whose next request is buffered in HTTPD, TLS or TCP. Zero
|
||||
* timeout select does not consume bytes or change TLS receive ownership.
|
||||
* Errors are conservative too; normal HTTPD owns error cleanup. */
|
||||
fd_set ready;
|
||||
FD_ZERO(&ready);
|
||||
if (sd->fd >= FD_SETSIZE) { row->idle_since_us = now; continue; }
|
||||
FD_SET(sd->fd, &ready);
|
||||
struct timeval timeout = {0};
|
||||
if (sd->pending_len || (sd->pending_fn && sd->pending_fn(hd, sd->fd) != 0) ||
|
||||
select(sd->fd + 1, &ready, NULL, NULL, &timeout) != 0) {
|
||||
row->idle_since_us = now;
|
||||
continue;
|
||||
}
|
||||
if (now - row->idle_since_us < WEB_HTTPD_IDLE_TIMEOUT_US) continue;
|
||||
/* Still the current fd on its owner; no queued sock_db pointer can later
|
||||
* target a replacement. HTTPD performs normal TLS/session destruction
|
||||
* on the next read. A failed shutdown retries on the next probe. */
|
||||
if (shutdown(sd->fd, SHUT_RDWR) == 0) row->shutdown_sent = true;
|
||||
}
|
||||
}
|
||||
|
||||
static int web_httpd_aborted_send(httpd_handle_t server, int fd,
|
||||
const char *buffer, size_t length, int flags)
|
||||
{
|
||||
(void)server; (void)fd; (void)buffer; (void)length; (void)flags;
|
||||
return HTTPD_SOCK_ERR_FAIL;
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_ws_send_binary(httpd_handle_t server, int fd,
|
||||
const void *expected_context,
|
||||
const uint8_t *payload, size_t length)
|
||||
{
|
||||
struct httpd_data *hd = server;
|
||||
if (!hd || fd < 0 || !expected_context || (!payload && length) ||
|
||||
length > WEB_HTTPD_WS_BINARY_MAX_PAYLOAD) return ESP_ERR_INVALID_ARG;
|
||||
if (httpd_os_thread_handle() != hd->hd_td.handle || hd->hd_req_aux.sd)
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
struct sock_db *sd = httpd_sess_get(hd, fd);
|
||||
if (!sd || sd->ctx != expected_context || !sd->ws_handshake_done ||
|
||||
sd->ws_close || sd->for_async_req || !sd->send_fn)
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
|
||||
/* IDF 5.5.0 httpd_ws_send_frame_async emits header/payload separately.
|
||||
* Keep the existing send override (HTTPS -> esp_tls_conn_write), not raw
|
||||
* socket IO. Owner-local scratch lives through the synchronous call only. */
|
||||
uint8_t wire[WEB_HTTPD_WS_BINARY_MAX_PAYLOAD + 4U];
|
||||
size_t header = length <= 125U ? 2U : 4U;
|
||||
wire[0] = 0x82; /* FIN, binary; server frames are never masked. */
|
||||
wire[1] = header == 2U ? (uint8_t)length : 126U;
|
||||
if (header == 4U) {
|
||||
wire[2] = (uint8_t)(length >> 8U);
|
||||
wire[3] = (uint8_t)length;
|
||||
}
|
||||
if (length) memcpy(wire + header, payload, length);
|
||||
size_t total = header + length;
|
||||
int sent = sd->send_fn(hd, fd, (const char *)wire, total, 0);
|
||||
if (sent == (int)total) return ESP_OK;
|
||||
/* Owner retains this validated session across the synchronous send. TLS
|
||||
* may hold pending output after short/zero/WANT/error: never retry it with
|
||||
* different arguments. Deferred close alone permits SDK automatic PONG or
|
||||
* CLOSE first. Shutdown alone cannot block buffered-input TLS calls either.
|
||||
* Reject all sends before shutdown, even if shutdown fails. ws_close also
|
||||
* skips SDK request processing; normal HTTPD still owns TLS destruction. */
|
||||
sd->send_fn = web_httpd_aborted_send;
|
||||
sd->ws_close = true;
|
||||
(void)shutdown(fd, SHUT_RDWR);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
bool web_httpd_headers_valid(httpd_req_t *request)
|
||||
{
|
||||
if (!request || !request->aux) return false;
|
||||
const struct httpd_req_aux *aux = request->aux;
|
||||
const char *start = aux->scratch;
|
||||
if (!start || aux->scratch_cur_size > 1024U) return false;
|
||||
const char *end = start + aux->scratch_cur_size;
|
||||
const char *line = start;
|
||||
for (unsigned i = 0; i < aux->req_hdrs_count; ++i) {
|
||||
if (line >= end) return false;
|
||||
while (line < end && !*line) ++line;
|
||||
const char *stop = memchr(line, 0, (size_t)(end - line));
|
||||
if (!stop) return false;
|
||||
const char *colon = memchr(line, ':', (size_t)(stop - line));
|
||||
if (!colon || colon == line) return false;
|
||||
size_t length = (size_t)(colon - line);
|
||||
for (const char *p = line; p < colon; ++p) {
|
||||
if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') ||
|
||||
(*p >= '0' && *p <= '9') || strchr("!#$%&'*+-.^_`|~", *p))) return false;
|
||||
}
|
||||
for (const char *p = colon + 1; p < stop; ++p) {
|
||||
if ((unsigned char)*p < 32U || (unsigned char)*p == 127U) return false;
|
||||
}
|
||||
/* Reject transfer coding and Expect rather than draining an unbounded
|
||||
* body after an authentication failure. No application route uses them. */
|
||||
if ((length == 17U && !strncasecmp(line, "Transfer-Encoding", length)) ||
|
||||
(length == 6U && !strncasecmp(line, "Expect", length))) return false;
|
||||
const char *previous = start;
|
||||
for (unsigned j = 0; j < i; ++j) {
|
||||
while (previous < line && !*previous) ++previous;
|
||||
const char *previous_end = memchr(previous, 0, (size_t)(line - previous));
|
||||
if (!previous_end) return false;
|
||||
const char *previous_colon = memchr(previous, ':', (size_t)(previous_end - previous));
|
||||
if (!previous_colon) return false;
|
||||
if ((size_t)(previous_colon - previous) == length &&
|
||||
!strncasecmp(previous, line, length)) return false;
|
||||
previous = previous_end + 1;
|
||||
}
|
||||
line = stop + 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool web_httpd_upgrade_requested(httpd_req_t *request)
|
||||
{
|
||||
const struct httpd_req_aux *aux = request->aux;
|
||||
if (!aux || !aux->sd || !aux->ws_handshake_detect || aux->sd->ws_handshake_done)
|
||||
return false;
|
||||
char version[3], key[25];
|
||||
if (httpd_req_get_hdr_value_len(request, "Sec-WebSocket-Version") != 2U ||
|
||||
httpd_req_get_hdr_value_str(request, "Sec-WebSocket-Version", version, sizeof(version)) != ESP_OK ||
|
||||
strcmp(version, "13") || httpd_req_get_hdr_value_len(request, "Sec-WebSocket-Key") != 24U ||
|
||||
httpd_req_get_hdr_value_str(request, "Sec-WebSocket-Key", key, sizeof(key)) != ESP_OK ||
|
||||
key[22] != '=' || key[23] != '=' || !strchr("AQgw", key[21])) return false;
|
||||
for (unsigned i = 0; i < 21; ++i)
|
||||
if (!strchr("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", key[i])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_upgrade(httpd_req_t *request,
|
||||
esp_err_t (*handler)(httpd_req_t *))
|
||||
{
|
||||
if (!web_httpd_upgrade_requested(request)) return ESP_ERR_INVALID_STATE;
|
||||
esp_err_t error = httpd_ws_respond_server_handshake(request, NULL);
|
||||
if (error == ESP_OK) {
|
||||
struct httpd_req_aux *aux = request->aux;
|
||||
aux->sd->ws_handshake_done = true;
|
||||
aux->sd->ws_handler = handler;
|
||||
aux->sd->ws_control_frames = false;
|
||||
aux->sd->ws_user_ctx = NULL;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
void web_httpd_wipe_request(httpd_req_t *request, bool closing)
|
||||
{
|
||||
struct httpd_req_aux *aux = request->aux;
|
||||
if (!aux) return;
|
||||
if (aux->scratch) secure_wipe(aux->scratch, aux->scratch_cur_size);
|
||||
aux->req_hdrs_count = 0;
|
||||
if (aux->sd) {
|
||||
size_t keep = closing ? 0 : aux->sd->pending_len;
|
||||
/* httpd_unrecv()/httpd_recv_pending() right-align unread bytes. */
|
||||
if (keep <= sizeof(aux->sd->pending_data))
|
||||
secure_wipe(aux->sd->pending_data, sizeof(aux->sd->pending_data) - keep);
|
||||
}
|
||||
}
|
||||
|
||||
bool web_httpd_unread_body(httpd_req_t *request)
|
||||
{
|
||||
const struct httpd_req_aux *aux = request->aux;
|
||||
return aux && aux->remaining_len != 0;
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_register_optional(httpd_handle_t server, const httpd_uri_t *uri)
|
||||
{
|
||||
struct httpd_data *hd = server;
|
||||
if (!hd || !uri || !uri->uri || !uri->handler ||
|
||||
(uri->method != HTTP_GET && uri->method != HTTP_POST) ||
|
||||
uri->is_websocket || uri->supported_subprotocol || hd->config.uri_match_fn)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
size_t length = 0;
|
||||
while (length < 128 && uri->uri[length]) ++length;
|
||||
if (!length || length == 128) return ESP_ERR_INVALID_ARG;
|
||||
int slot = -1;
|
||||
for (unsigned i = 0; i < hd->config.max_uri_handlers; ++i) {
|
||||
if (!hd->hd_calls[i]) { if (slot < 0) slot = (int)i; }
|
||||
else if (!strcmp(hd->hd_calls[i]->uri, uri->uri) && hd->hd_calls[i]->method == uri->method)
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
if (slot < 0) return ESP_ERR_NO_MEM;
|
||||
/* IDF 5.5.0 publishes its descriptor before strdup; strdup failure leaves a
|
||||
* freed hd_calls entry. Optional registration must leave the table intact. */
|
||||
httpd_uri_t *copy = malloc(sizeof(*copy));
|
||||
if (!copy) return ESP_ERR_NO_MEM;
|
||||
char *name = malloc(length + 1);
|
||||
if (!name) { free(copy); return ESP_ERR_NO_MEM; }
|
||||
memcpy(name, uri->uri, length + 1);
|
||||
*copy = *uri;
|
||||
copy->uri = name;
|
||||
hd->hd_calls[slot] = copy;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri)
|
||||
{
|
||||
if (!uri || uri->method != HTTP_GET) return ESP_ERR_INVALID_ARG;
|
||||
return web_httpd_register_optional(server, uri);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include "esp_http_server.h"
|
||||
#include <stdint.h>
|
||||
|
||||
#define WEB_HTTPD_IDLE_SOCKETS 6U
|
||||
#define WEB_HTTPD_IDLE_TIMEOUT_US INT64_C(15000000)
|
||||
typedef struct {
|
||||
uint64_t completed;
|
||||
int64_t idle_since_us;
|
||||
int fd;
|
||||
bool observed, shutdown_sent;
|
||||
} web_httpd_idle_row_t;
|
||||
/* HTTPD-owner work boundary only. TLS create must invalidate reused fd rows. */
|
||||
void web_httpd_idle_sweep(httpd_handle_t server,
|
||||
web_httpd_idle_row_t rows[WEB_HTTPD_IDLE_SOCKETS], int64_t now);
|
||||
|
||||
/* HTTPD-owner only, before body reads or any response. Reject duplicate lines,
|
||||
* including Cookie, rather than trusting first-match public getters. */
|
||||
bool web_httpd_headers_valid(httpd_req_t *request);
|
||||
bool web_httpd_upgrade_requested(httpd_req_t *request);
|
||||
bool web_httpd_unread_body(httpd_req_t *request);
|
||||
/* After the final response/lookup: preserve only unread pipelined data on a
|
||||
* keepalive connection. Closing requests may discard pending data entirely. */
|
||||
void web_httpd_wipe_request(httpd_req_t *request, bool closing);
|
||||
esp_err_t web_httpd_upgrade(httpd_req_t *request,
|
||||
esp_err_t (*handler)(httpd_req_t *));
|
||||
|
||||
#define WEB_HTTPD_WS_BINARY_MAX_PAYLOAD 512U
|
||||
/* HTTPD-owner work only; caller retains generation/lifecycle and one-work-slot
|
||||
* ownership. Synchronous FIN/binary/unmasked send through the session override.
|
||||
* Non-full writes synchronously block further session sends and abort the socket;
|
||||
* caller must retain normal failure/cleanup accounting, never replay the frame. */
|
||||
esp_err_t web_httpd_ws_send_binary(httpd_handle_t server, int fd,
|
||||
const void *expected_context,
|
||||
const uint8_t *payload, size_t length);
|
||||
|
||||
/* Serialized server startup only, exact-match ordinary GET, URI <= 127 bytes.
|
||||
* Stage both allocations before publication; HTTPD owns/frees them on success. */
|
||||
esp_err_t web_httpd_register_optional_get(httpd_handle_t server, const httpd_uri_t *uri);
|
||||
/* Same staged startup ownership for ordinary exact GET or POST. */
|
||||
esp_err_t web_httpd_register_optional(httpd_handle_t server, const httpd_uri_t *uri);
|
||||
@@ -0,0 +1,137 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_httpd_idle.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define IDLE_POLL_US INT64_C(1000000)
|
||||
#define IDLE_FENCE_US INT64_C(1000000)
|
||||
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static httpd_handle_t s_server;
|
||||
static esp_timer_handle_t s_timer;
|
||||
static uintptr_t s_generation;
|
||||
static bool s_accepting, s_queued, s_submitting;
|
||||
/* Only HTTPD touches rows while alive; prepare runs before SSL startup. */
|
||||
static web_httpd_idle_row_t s_rows[WEB_HTTPD_IDLE_SOCKETS];
|
||||
|
||||
static void idle_work(void *argument)
|
||||
{
|
||||
uintptr_t generation = (uintptr_t)argument;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool current = s_queued && generation == s_generation;
|
||||
httpd_handle_t server = current && s_accepting ? s_server : NULL;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (server) web_httpd_idle_sweep(server, s_rows, esp_timer_get_time());
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (current && generation == s_generation) s_queued = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
static void idle_timer(void *argument)
|
||||
{
|
||||
(void)argument;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
httpd_handle_t server = NULL;
|
||||
uintptr_t generation = s_generation;
|
||||
if (s_accepting && !s_queued && !s_submitting) {
|
||||
server = s_server;
|
||||
s_queued = s_submitting = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!server) return;
|
||||
esp_err_t error = httpd_queue_work(server, idle_work, (void *)generation);
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
/* A callback may finish before queue_work returns. Keep the submission
|
||||
* reservation until here so it cannot clear a newer probe's queued flag. */
|
||||
if (error != ESP_OK) s_queued = false;
|
||||
s_submitting = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_idle_prepare(void)
|
||||
{
|
||||
#if defined(CONFIG_HTTPD_QUEUE_WORK_BLOCKING) && CONFIG_HTTPD_QUEUE_WORK_BLOCKING
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
#else
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool allowed = !s_server && !s_queued && !s_submitting && s_generation != UINTPTR_MAX;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!allowed) return ESP_ERR_INVALID_STATE;
|
||||
if (!s_timer) {
|
||||
esp_timer_handle_t timer = NULL;
|
||||
const esp_timer_create_args_t args = {
|
||||
.callback = idle_timer, .name = "web_idle", .skip_unhandled_events = true,
|
||||
};
|
||||
esp_err_t error = esp_timer_create(&args, &timer);
|
||||
if (error == ESP_OK) error = esp_timer_start_periodic(timer, IDLE_POLL_US);
|
||||
if (error != ESP_OK) {
|
||||
if (timer) (void)esp_timer_delete(timer);
|
||||
return error;
|
||||
}
|
||||
s_timer = timer;
|
||||
}
|
||||
memset(s_rows, 0, sizeof(s_rows));
|
||||
return ESP_OK;
|
||||
#endif
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_idle_attach(httpd_handle_t server)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool allowed = server && s_timer && !s_server && !s_queued && !s_submitting &&
|
||||
s_generation != UINTPTR_MAX;
|
||||
if (allowed) {
|
||||
++s_generation; /* Never reused, including when HTTPD's handle is reused. */
|
||||
s_server = server;
|
||||
s_accepting = true;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
return allowed ? ESP_OK : ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
esp_err_t web_httpd_idle_detach(httpd_handle_t server)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool owned = server && s_server == server;
|
||||
bool absent = !s_server;
|
||||
if (owned) s_accepting = false;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
/* Partial startup may never have attached. */
|
||||
if (!owned) return absent ? ESP_OK : ESP_ERR_INVALID_STATE;
|
||||
int64_t deadline = esp_timer_get_time() + IDLE_FENCE_US;
|
||||
for (;;) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool submitting = s_submitting;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!submitting) return ESP_OK;
|
||||
if (esp_timer_get_time() >= deadline) return ESP_ERR_TIMEOUT;
|
||||
vTaskDelay(1);
|
||||
}
|
||||
}
|
||||
|
||||
void web_httpd_idle_stopped(httpd_handle_t server)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (server && s_server == server && !s_accepting && !s_submitting) {
|
||||
s_server = NULL;
|
||||
s_queued = false; /* Successful HTTPD stop joined owner and destroyed queue. */
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
|
||||
void web_httpd_idle_tls(esp_https_server_user_cb_arg_t *arg)
|
||||
{
|
||||
if (!arg || !arg->tls || arg->user_cb_state != HTTPD_SSL_USER_CB_SESS_CREATE) return;
|
||||
int fd = -1;
|
||||
if (esp_tls_get_conn_sockfd(arg->tls, &fd) != ESP_OK || fd < 0) {
|
||||
/* Identity unavailable: conservatively restart every idle observation. */
|
||||
memset(s_rows, 0, sizeof(s_rows));
|
||||
return;
|
||||
}
|
||||
for (unsigned i = 0; i < WEB_HTTPD_IDLE_SOCKETS; ++i)
|
||||
if (s_rows[i].fd == fd) memset(&s_rows[i], 0, sizeof(s_rows[i]));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include "esp_https_server.h"
|
||||
|
||||
/* Serialized web_server lifecycle. Prepare before SSL start; stop must fence
|
||||
* submissions before destroying HTTPD, and retire only after successful stop. */
|
||||
esp_err_t web_httpd_idle_prepare(void);
|
||||
esp_err_t web_httpd_idle_attach(httpd_handle_t server);
|
||||
esp_err_t web_httpd_idle_detach(httpd_handle_t server);
|
||||
void web_httpd_idle_stopped(httpd_handle_t server);
|
||||
/* Synchronous HTTPD-owner TLS callback, composed with diagnostics by server. */
|
||||
void web_httpd_idle_tls(esp_https_server_user_cb_arg_t *arg);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user