Compare commits
2
Commits
0b86fd9c70
...
326119812f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
326119812f | ||
|
|
17520b15b7 |
@@ -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/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,6 +171,10 @@ For SSH, standard output/error is redirected to the invoking session's bounded o
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
The first 8D.7 slice enables browser-admin reboot and HTTPS stop through the same control task. `web stop` is deferred only for browser origin; UART0/admin SSH keep their synchronous HTTPS-stop path. WEB performs authoritative cookie/principal/token validation after drain and delay, then calls lifecycle APIs outside console locks, never socket IO. Console snapshots expose pending deferral; HTTPD discards buffered/new input observed during it and latches each frame's discard decision across payload reception/cancellation. HTTPS stop intentionally closes both browser routes.
|
||||||
|
|
||||||
|
The second 8D.7 slice additionally permits exact parsed browser `web certificate rotate --force`. The request queue has a typed command-line/deferred-action union with unchanged capacity. An immutable owner `dispatcher_actions` mask sends certificate work, after the bounded drain and 200 ms delay, nonblockingly to the existing 12 KiB dispatcher rather than the 4 KiB control stack; zero mask retains SSH control-task behavior. Pending input remains gated through queueing/execution. Dispatcher token/principal/session/owner revalidation and an executing-slot reservation prevent stale execution or reuse during self-detach; WEB validates currentness again before lifecycle APIs. Transactional certificate generation/persistence commits before stop → start; generation/commit error skips lifecycle calls, stop error skips start and retains HTTPD ownership, and later lifecycle failure does not roll back committed material. HTTPD alone owns socket IO. Successful restart invalidates browser sessions and both routes; certificate trust and login must be renewed, while USB/UART0/SSH remain available. Account/legacy-credential/network/restricted SSH mutations remain blocked. No new tasks, depths, routes, assets or stack sizes. Drain/acknowledgement bounds are not execution deadlines or delivery guarantees; owner-mask/local-scratch target layout and control/dispatcher stack margins remain unmeasured (host sizeof is not target proof).
|
||||||
|
|
||||||
## Wi-Fi and persistence
|
## 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_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.
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ This is a semantic map, not a complete file inventory. Start here, then read the
|
|||||||
|
|
||||||
### Browser admin backend (8D.5)
|
### Browser admin backend (8D.5)
|
||||||
|
|
||||||
|
- 8D.7 second slice: exact parsed browser `web certificate rotate --force`; `admin_ssh_console.{c,h}` supplies the typed request union/owner `dispatcher_actions` mask, bounded drain/200 ms handoff to the existing 12 KiB dispatcher, persistent pending gate and revalidated executing-slot reservation. `web_console.c` schedules; `web_admin_transport.c` revalidates then calls transactional `web_security_rotate_certificate()` → `web_server_stop()` → `web_server_start()`, short-circuiting errors and retaining ownership on failed stop. SSH/UART0 unchanged. No tasks/depth/routes/assets/stacks added; target stack margins unknown. Boundary `run.py` includes `certificate.c`; lifecycle/policy and transport 25/tickets 12 host groups pass as reported. Credential/account then other owner slices remain; user authorized stacking, not target/M2 sign-off. See `docs/phase8d7_implementation.md`.
|
||||||
|
- 8D.7 first-slice history: browser `reboot`/`web stop` defer via `admin_ssh_console` control task; WEB owner revalidates cookie/principal/token before lifecycle calls. Pending console input is discarded (incoming-frame disposition latched before receive). `web_console.c` defers stop only for browser origin; other restrictions remain. Tests additionally include `python3 tests/admin_console_boundary/lifecycle.py`; handoff: `docs/phase8d7_implementation.md`. No 8D.7/M2 acceptance yet.
|
||||||
|
|
||||||
- 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`.
|
- 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. No UI entry or broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled, 16 URI handlers.
|
- Routes: CSRF-protected admin-only `POST /api/admin/ws-ticket`; ordinary `GET /ws/admin` with cookie/Origin/ticket/shared-console admission before explicit 101. No UI entry or broker client. One socket, two tickets, existing two shared console slots; six total HTTPD sockets, LRU disabled, 16 URI handlers.
|
||||||
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ This file is working memory. Update it during active work and before handoff; do
|
|||||||
|
|
||||||
## Development state
|
## Development state
|
||||||
|
|
||||||
|
- **8D.7 second certificate slice implemented / host-tested / build-verified, target pending:** Exact parsed browser `web certificate rotate --force` is enabled. Typed deferred-request union preserves queue capacity; immutable owner `dispatcher_actions` transfers certificate work after bounded drain/200 ms delay to the existing 12 KiB dispatcher, not the 4 KiB control stack. Pending-input gate survives handoff; token/principal/session revalidation and executing-slot reservation fence stale work/self-detach reuse. WEB uses transactional certificate commit → stop → start, returning on early errors and retaining ownership on failed stop. SSH/UART0 unchanged; other account/network/credential/SSH mutations remain blocked. No new tasks/depth/routes/assets/stacks. Parent final `pio run` PASS **26.32 s, 95,580 B RAM / 1,648,061 B flash**: **0 / +1,036 B** vs first slice, **0 / +1,572 B** vs 8D.6, **+1,048 / +48,088 B** vs 8D.0. Parent final rerun passes boundary (`run.py` including `certificate.c`), lifecycle, policy, transport **25**/tickets **12**, server **11**, cookie/admin, store/serial, UI **17** plus renderer/CSP and diff checks; independent reviewer has no actionable findings. Sanitizers unavailable (missing libasan/libubsan); no hardware validation. Owner mask/local scratch and target stack margins remain unmeasured; host sizeof is not target proof. Drain/acknowledgement is bounded, not execution latency or peer receipt. **User explicitly authorized continuing to stack the next bounded slice, not target sign-off. Next: credential/account, then other owner slices; 8D.7/M2 acceptance and numeric reserves pending.** See `docs/phase8d7_implementation.md` for separate slice histories and certificate force/trust/relogin/USB/SSH/failure checklist. Supersedes the first-slice next-step/continuation-pending instruction below. Production/tests/docs updated and parent build/regressions executed; no device operation, asset regeneration or commit.
|
||||||
|
|
||||||
|
- **8D.7 first increment implemented / host-tested / build-verified (2026-09-06), target pending:** User requests starting 8D.7. Scope split enables only browser `reboot` and `web stop` through existing bounded deferred control; WEB revalidates session/principal/token before lifecycle APIs. Adds browser-origin query and pending snapshot flag; HTTPD discards deferred input, latching disposition before receive. Review fixed second-frame-before-poll and cancellation-during-receive races; final reviewer confirms correction. Canonical handler/console/policy, transport **23**/tickets **12**, lifecycle **11**, cookie/admin, store/serial and UI **17** suites pass. Final `pio run` **12.44 s, 95,580 B RAM / 1,647,025 B flash**, **0 / +536 B** vs 8D.6. No tasks/routes/capacity/assets/UI/credential changes or device operations. `docs/phase8d7_implementation.md` holds behavior, restrictions, validation limits/resources and disruptive target checklist. **8D.7/M2 incomplete; next slice remains safe HTTPS identity/certificate handling**, then remaining owner/account restrictions. Obtain target regression or explicit user decision before stacking runtime changes. `web stop` intentionally closes both browser routes; restart via UART0/admin SSH. Prior 8D.6 sign-off stands; internal/DMA minima **6,516 / 1,580 B** and numeric reserves remain follow-ups.
|
||||||
|
|
||||||
- **8D.6 validated by explicit user sign-off (2026-09-06):** After 60-second boot/full-mix/partial-cleanup telemetry, user confirms all discussed remaining checks and requests wrap-up. Closes 8D.6 including Open admin and toolbar-order fixes, superseding older target-pending notes below. Evidence in `docs/phase8d6_implementation.md`: full mix at **230400 baud**, browser admin + admin SSH, two web serial/SSH/USB broker clients; web24 sole writer. Loaded internal/DMA/PSRAM free **33,900 / 26,144 / 8,087,468 B**, minima **6,516 / 1,580 / 8,063,428 B**, largest **24,576 / 24,576 / 7,995,392 B**; SSH stack minimum-free **16,280 B**. Partial cleanup retains serial, internal/DMA largest returns to **31,744 B**. One SSH handshake failure and rejected input byte remain unattributed. Exact revision/browser/counts/soak duration not separately supplied; no invented detailed execution or reserve approval. Numeric reserves and low lifetime minima remain follow-ups, not blockers reopening signed-off 8D.6. Last toolbar build/tests: **7.60 s, 95,580 B RAM / 1,646,489 B flash**, 17 UI groups/CSP/diff pass. This handoff changes documentation only. **Wait for separately requested 8D.7 — bounded web-shell lifecycle parity and M2 acceptance.** Preserve current restrictions until their safe owner paths are implemented; no settings/M2 completion or new implementation is implied.
|
- **8D.6 validated by explicit user sign-off (2026-09-06):** After 60-second boot/full-mix/partial-cleanup telemetry, user confirms all discussed remaining checks and requests wrap-up. Closes 8D.6 including Open admin and toolbar-order fixes, superseding older target-pending notes below. Evidence in `docs/phase8d6_implementation.md`: full mix at **230400 baud**, browser admin + admin SSH, two web serial/SSH/USB broker clients; web24 sole writer. Loaded internal/DMA/PSRAM free **33,900 / 26,144 / 8,087,468 B**, minima **6,516 / 1,580 / 8,063,428 B**, largest **24,576 / 24,576 / 7,995,392 B**; SSH stack minimum-free **16,280 B**. Partial cleanup retains serial, internal/DMA largest returns to **31,744 B**. One SSH handshake failure and rejected input byte remain unattributed. Exact revision/browser/counts/soak duration not separately supplied; no invented detailed execution or reserve approval. Numeric reserves and low lifetime minima remain follow-ups, not blockers reopening signed-off 8D.6. Last toolbar build/tests: **7.60 s, 95,580 B RAM / 1,646,489 B flash**, 17 UI groups/CSP/diff pass. This handoff changes documentation only. **Wait for separately requested 8D.7 — bounded web-shell lifecycle parity and M2 acceptance.** Preserve current restrictions until their safe owner paths are implemented; no settings/M2 completion or new implementation is implied.
|
||||||
|
|
||||||
- **8D.6 Open admin bug fix (2026-09-06), target retest pending:** Confirmed frontend/backend ticket mismatch: `openAdmin()` required 32 URL-safe characters (serial format), while admin issuance returns 64 lowercase hex characters. Valid POST responses therefore threw before WebSocket construction with the user's exact generic message. Fixed only the admin validator; split the previously shared serial/admin test fixture, assert exact admin query URL and reject malformed/serial-format tickets without closing serial. Correct-format fixture reproduced pre-fix failure; final production-renderer/CSP + 17 UI groups, cookie/admin endpoint integration, transport 19/tickets 12 and lifecycle 11 pass. These are separate frontend and backend harnesses, not real-browser/on-wire integration. CSP loader hash matches the reported `o6St...` policy; rejected UUID/index.js scripts' origin is unverified, not grounds to relax CSP. Final bounded `pio run` passed in **7.96 s**, **95,580 B RAM / 1,646,489 B flash**, unchanged sizes. Diff check passes. No assets, upload, commit, backend/admission changes or target pass. Short bug record in `docs/phase8d6_implementation.md`; parent independent review and user target retest pending.
|
- **8D.6 Open admin bug fix (2026-09-06), target retest pending:** Confirmed frontend/backend ticket mismatch: `openAdmin()` required 32 URL-safe characters (serial format), while admin issuance returns 64 lowercase hex characters. Valid POST responses therefore threw before WebSocket construction with the user's exact generic message. Fixed only the admin validator; split the previously shared serial/admin test fixture, assert exact admin query URL and reject malformed/serial-format tickets without closing serial. Correct-format fixture reproduced pre-fix failure; final production-renderer/CSP + 17 UI groups, cookie/admin endpoint integration, transport 19/tickets 12 and lifecycle 11 pass. These are separate frontend and backend harnesses, not real-browser/on-wire integration. CSP loader hash matches the reported `o6St...` policy; rejected UUID/index.js scripts' origin is unverified, not grounds to relax CSP. Final bounded `pio run` passed in **7.96 s**, **95,580 B RAM / 1,646,489 B flash**, unchanged sizes. Diff check passes. No assets, upload, commit, backend/admission changes or target pass. Short bug record in `docs/phase8d6_implementation.md`; parent independent review and user target retest pending.
|
||||||
@@ -77,7 +81,7 @@ Based on checked-in source plus `README.md` and `docs/roadmap.md`:
|
|||||||
|
|
||||||
- Phase 8C hardware validation passed, including route separation, shared command serialization, history/completion, prompts, output backpressure, revocation during queued work, deferred SSH lifecycle/reboot actions, and full concurrent transport operation. At 460800 baud with SSH and WebSocket clients in parallel, substantial packet drops and slow display controls were observed under load, without memory exhaustion; no baud-rate reduction is planned.
|
- Phase 8C hardware validation passed, including route separation, shared command serialization, history/completion, prompts, output backpressure, revocation during queued work, deferred SSH lifecycle/reboot actions, and full concurrent transport operation. At 460800 baud with SSH and WebSocket clients in parallel, substantial packet drops and slow display controls were observed under load, without memory exhaustion; no baud-rate reduction is planned.
|
||||||
- Current HTTPS UI retains status/serial for both roles and exposes an admin-only selector in 8D.6. The 8D.5 backend's temporary policy still denies all user mutations and unsupported self-affecting lifecycle commands.
|
- Current HTTPS UI retains status/serial for both roles and exposes an admin-only selector in 8D.6. The 8D.5 backend's temporary policy still denies all user mutations and unsupported self-affecting lifecycle commands.
|
||||||
- Browser authentication uses cookie login/logout without Basic fallback. M1 and 8D.5 are signed off; 8D.6 selector target checks remain pending. Lifecycle parity remains separately requested 8D.7 work, not completed M2.
|
- Browser authentication uses cookie login/logout without Basic fallback. M1 and 8D.4–8D.6 are signed off. 8D.7 stop/reboot and certificate slices are implemented with target checks pending; the user authorized the next bounded credential/account slice, not M2 acceptance.
|
||||||
- NVS encryption, secure boot/flash encryption review, production certificate/provisioning policy, and OTA are not implemented. HTTPS login has a bounded global five-verifications/60-second throttle, not comprehensive cross-transport DoS protection.
|
- NVS encryption, secure boot/flash encryption review, production certificate/provisioning policy, and OTA are not implemented. HTTPS login has a bounded global five-verifications/60-second throttle, not comprehensive cross-transport DoS protection.
|
||||||
|
|
||||||
## Known inconsistencies
|
## Known inconsistencies
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs
|
|||||||
|
|
||||||
Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutable owner adapter on the existing control task, outside console locks. Tokens include a transport namespace; owners revalidate full identity and marshal to their transport APIs. `SELF_CLOSE` targets the invoking frontend while existing SSH action meanings remain unchanged. Unsupported actions must fail before side effects. The two console slots remain a shared bounded pool, with no hypothetical browser capacity allocated. The 8D.5 prerequisite additionally requires owner currentness on the dispatcher, outside console locks, before commands and during prompts; account currentness alone cannot establish originating browser-session liveness. Recheck token identity after external validation, reject revoked submitted replies, and wipe consumed output. Polling is not a hard cancellation deadline and cannot roll back arbitrary handlers; owners retain admission/input/output/lifecycle responsibilities.
|
Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutable owner adapter on the existing control task, outside console locks. Tokens include a transport namespace; owners revalidate full identity and marshal to their transport APIs. `SELF_CLOSE` targets the invoking frontend while existing SSH action meanings remain unchanged. Unsupported actions must fail before side effects. The two console slots remain a shared bounded pool, with no hypothetical browser capacity allocated. The 8D.5 prerequisite additionally requires owner currentness on the dispatcher, outside console locks, before commands and during prompts; account currentness alone cannot establish originating browser-session liveness. Recheck token identity after external validation, reject revoked submitted replies, and wipe consumed output. Polling is not a hard cancellation deadline and cannot roll back arbitrary handlers; owners retain admission/input/output/lifecycle responsibilities.
|
||||||
|
|
||||||
**Relevant files:** `src/admin_ssh_console.c`, `src/system_console.c`, `src/ssh_console.c`, `src/ssh_transport.c`
|
**8D.7 first slice:** WEB also supports reboot and explicit HTTPS stop on the existing control task, with originating-session/principal validation after drain/delay. Stop is service-wide, not admin-socket-only; serial isolation applies to selector/SELF_CLOSE, not explicit HTTPS shutdown. Buffered input observed during deferral is wiped, including an incoming frame whose payload read races cancellation. Keep unsupported identity/credential/network paths blocked until separately implemented; no new executor or delivery guarantee.
|
||||||
|
|
||||||
|
**8D.7 second certificate slice:** Exact parsed browser `web certificate rotate --force` schedules a typed action, not command replay. Use the existing request-queue union and immutable owner `dispatcher_actions` mask to hand off after drain/200 ms to the existing 12 KiB dispatcher: crypto/NVS must not run on the 4 KiB control stack. Preserve queue capacity, pending-input gating through execution, token/principal/session revalidation and executing-slot reservation across self-detach. Zero mask preserves legacy SSH execution. WEB revalidates before transactional certificate commit → stop → start; early errors short-circuit and failed stop retains HTTPD ownership without start. Lifecycle failure after commit does not restore the old identity. Browser trust/relogin and UART0/SSH recovery are explicit operational consequences; USB/SSH are not stopped. Bounded acknowledgement/drain is neither an execution deadline nor receipt proof. No stack-size/task/route expansion; owner mask and local scratch still need target accounting/high-water evidence, not host sizeof assumptions. Other credential/account/network/SSH mutations remain blocked pending bounded owner slices.
|
||||||
|
|
||||||
|
**Relevant files:** `src/admin_ssh_console.c`, `src/system_console.c`, `src/ssh_console.c`, `src/ssh_transport.c`, `src/web_admin_transport.c`, `src/web_console.c`
|
||||||
|
|
||||||
## Authentication uses copied principals and fail-safe currentness checks
|
## Authentication uses copied principals and fail-safe currentness checks
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
UART0 and authenticated `admin` SSH sessions use the same registered command implementations through one serialized dispatcher. Admin SSH exposes the full operational registry, including interactive prompts, recovery-secret display, network diagnostics, reboot, and HTTPS/SSH material mutation. Initial administrator bootstrap and explicit recovery of an unavailable user database remain physically bound to UART0. 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.
|
UART0 and authenticated `admin` SSH sessions use the same registered command implementations through one serialized dispatcher. Admin SSH exposes the full operational registry, including interactive prompts, recovery-secret display, network diagnostics, reboot, and HTTPS/SSH material mutation. Initial administrator bootstrap and explicit recovery of an unavailable user database remain physically bound to UART0. 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 temporary 8D.7 restrictions. It now supports deferred **`reboot`** and **`web stop`** in addition to admin-only `exit`. The control task waits up to ten seconds for application output drain plus 200 ms; acknowledgement delivery is best effort, not confirmed receipt. Input observed during deferral is discarded. `web stop` closes both browser routes; restart HTTPS via UART0/admin SSH `web start` and sign in again. Reboot affects all transports and loses RAM-only changes. The second slice also permits exact parsed **`web certificate rotate --force`** (equivalent quoting accepted, no missing force or extra arguments). After drain/delay it queues a typed action on the existing dispatcher; pending input remains discarded until completion. This does not bound dispatcher wait or certificate/NVS/lifecycle execution time. Certificate commit precedes HTTPS stop/start: early generation/persistence errors skip restart, stop failure retains ownership and skips start, and later lifecycle failure does not undo the new persisted identity. Success closes both browser routes and any web writer lease; verify/trust the new certificate through a trusted channel and sign in again with unchanged account credentials. USB/UART0/SSH remain usable; use UART0/admin SSH for stop/start recovery if needed. Other `web` forms besides `web status`, `web stop` and this exact rotation remain blocked (including certificate info, credential display/rotation and reset). User mutations, Wi-Fi/mDNS mutations and restricted SSH lifecycle commands remain unavailable from browser admin. See `phase8d7_implementation.md` for the exact remaining policy and target-validation status.
|
||||||
|
|
||||||
## System
|
## System
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `memory` | Show free memory, minimum free memory, and largest blocks for internal RAM, DMA-capable RAM, and PSRAM. |
|
| `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. |
|
| `reboot` | Drain console output briefly and restart the ESP32. |
|
||||||
| `exit` | Close the current administrative SSH session after its acknowledgement drains; unavailable on UART0. Ctrl+D on an empty admin SSH command line does the same. |
|
| `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
|
## Role-based users
|
||||||
|
|
||||||
@@ -130,7 +132,7 @@ When the Wi-Fi station receives an IPv4 address, the Wi-Fi manager announces `sa
|
|||||||
| `web credentials show` | Display the legacy migration/recovery credential on UART0 or authenticated admin SSH; it is not a role-based network login. |
|
| `web credentials show` | Display the legacy migration/recovery credential on UART0 or authenticated admin SSH; it is not a role-based network login. |
|
||||||
| `web credentials rotate --force` | Replace the legacy recovery credential and synchronize the migrated pre-bootstrap account only. |
|
| `web credentials rotate --force` | Replace the legacy recovery credential and synchronize the migrated pre-bootstrap account only. |
|
||||||
| `web certificate info` | Display certificate identity and fingerprint. |
|
| `web certificate info` | Display certificate identity and fingerprint. |
|
||||||
| `web certificate rotate --force` | Replace the HTTPS certificate and private key. |
|
| `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 missing, incompatible, or damaged legacy credentials and web material. |
|
| `web reset --force` | Explicitly replace missing, incompatible, or damaged legacy credentials and web material. |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Phase 8D.7 — Web-shell lifecycle parity and M2 acceptance
|
||||||
|
|
||||||
|
## Current status — second bounded certificate slice
|
||||||
|
|
||||||
|
**Second slice implemented / host-tested / build-verified; independent reviewer reports no actionable findings. No hardware validation or 8D.7/M2 acceptance.** The user explicitly authorized continuing to stack the next bounded slice; this is a continuation decision, not target sign-off. Next comes credential/account handling, then other owner-specific slices. Prior 8D.6 sign-off stands; numeric reserves remain open.
|
||||||
|
|
||||||
|
The second slice enables only exact parsed browser **`web certificate rotate --force`**. It changes the shared console boundary, WEB owner and canonical web handler (`src/admin_ssh_console.{c,h}`, `src/web_admin_transport.c`, `src/web_console.c`) plus focused tests. No new task, queue depth, route, socket/session/ticket capacity, asset, UI/settings feature or stack size. SSH and UART0 certificate/lifecycle behavior is unchanged. This documentation handoff does not change source/tests or run a build/device operation.
|
||||||
|
|
||||||
|
### Second-slice execution contract
|
||||||
|
|
||||||
|
- Policy uses the same argument parser as the canonical console: exactly four parsed arguments are required; quoted equivalents are accepted, missing `--force` or extra arguments are not. The browser handler schedules a typed action before certificate side effects, not a command string for later replay.
|
||||||
|
- The existing request queue uses a command-line/deferred-action union, preserving capacity. The immutable owner's `dispatcher_actions` mask selects certificate rotation for the existing **12 KiB dispatcher**, not the **4 KiB control task**. Other existing actions retain control-task execution; a zero mask preserves SSH behavior.
|
||||||
|
- Control waits up to ten seconds for application-buffer drain, cancels if it does not drain, then delays **200 ms** and attempts a nonblocking dispatcher handoff. A full queue fails before mutation. Pending input gating persists through queue residence and execution, not just handoff. This bounds the acknowledgement/drain stage, **not browser receipt, dispatcher queue latency, or certificate/NVS/stop/start execution time**; another command or prompt can delay the dispatcher.
|
||||||
|
- Dispatcher revalidates token/principal/originating session, verifies owner and pending state, reserves the slot as executing and rechecks currentness before the owner callback. WEB revalidates again before mutation. Self-detach cannot reuse the executing slot; it is wiped/released after return. Stale/revoked work fails before side effects; these checks cannot roll back an already executing action.
|
||||||
|
- WEB calls transactional `web_security_rotate_certificate()` → `web_server_stop()` → `web_server_start()`, outside console locks and without socket IO or handler replay. Generation/key generation/persistence failure leaves the previous committed/live material and skips stop/start. Successful commit installs the new identity before stop; later lifecycle failure does not undo it. A stop error returns immediately without start, retaining HTTPD ownership under existing server rules; auth may already be invalidated and transports detached. Start failure requires UART0/admin-SSH recovery.
|
||||||
|
- Successful restart closes both browser routes, invalidates old sessions/tickets and removes any web writer lease. Reconnect requires verifying/trusting the new certificate and logging in again. No account password or legacy recovery credential is rotated; USB UART1, UART0 and SSH are not stopped. Completion/error output after self-detach is not guaranteed to reach the browser.
|
||||||
|
|
||||||
|
### Second-slice reported verification and resources
|
||||||
|
|
||||||
|
The implementer reports PASS for `python3 tests/admin_console_boundary/run.py` (including `certificate.c`), `python3 tests/admin_console_boundary/lifecycle.py`, `python3 tests/admin_ssh_policy/run.py`, `python3 tests/web_admin_transport/run.py --tickets` (**25 transport / 12 ticket groups**), `python3 tests/web_admin_transport/server_lifecycle.py` (**11 groups**), `python3 tests/web_cookie_auth/run.py --admin`, `python3 tests/web_session_store/run.py --serial`, and `git diff --check`. Independent review found no actionable findings. Sanitizer validation is unavailable because `libasan`/`libubsan` are missing; no sanitizer pass is claimed. Host harnesses do not prove concurrent RTOS/HTTPD/TLS behavior or target stack margins.
|
||||||
|
|
||||||
|
Parent reports final **`pio run` PASS in 26.32 s: 95,580 B linked RAM / 1,648,061 B flash**. Deltas: **0 / +1,036 B** versus first slice, **0 / +1,572 B** versus 8D.6, **+1,048 / +48,088 B** versus 8D.0. Baselines were not rebuilt. The owner mask and local deferred-request/principal scratch are real accounting considerations despite unchanged linked RAM and queue capacity; host `sizeof` is not target layout or stack-margin proof. Control/dispatcher path high-water marks remain unknown. No new tasks, queue depths, routes, assets or stacks; existing HTTPD/SSH stacks and transport capacities remain unchanged.
|
||||||
|
|
||||||
|
## First-slice history (2026-09-06)
|
||||||
|
|
||||||
|
The following behavior, verification and resource figures record the first slice, not the second-slice final build.
|
||||||
|
|
||||||
|
**First increment implemented / host-tested / build-verified; target validation pending. 8D.7 and M2 are not complete.** Prior 8D.6 user sign-off stands. The user requested starting 8D.7; scope review splits the several distinct lifecycle/identity/network/account paths as the plan permits.
|
||||||
|
|
||||||
|
This increment enables **browser-admin `reboot` and `web stop`** using the existing deferred-control task. Allowed production files are `src/admin_ssh_console.{c,h}`, `src/web_admin_transport.c`, `src/web_console.c` and `src/system_console.c`; related host tests and documentation are updated. Starting worktree was clean. No new task, queue, socket, route, payload capacity, generated asset, UI/settings feature, upload, erase or commit.
|
||||||
|
|
||||||
|
## Implemented behavior
|
||||||
|
|
||||||
|
- The canonical `web stop` handler recognizes browser-origin dispatch through `admin_ssh_console_dispatch_is_web()` and queues `ADMIN_CONSOLE_DEFER_WEB_STOP` before any service side effect. UART0 and admin SSH keep their existing synchronous HTTPS-stop path.
|
||||||
|
- Browser `reboot` now passes parsed command policy and uses the existing canonical remote-reboot deferral. The acknowledgement says console output rather than SSH output. UART0 remains synchronous; SSH reboot remains deferred.
|
||||||
|
- In the first slice, WEB owner supports SELF_CLOSE, REBOOT and WEB_STOP only. Its control-task callback revalidates originating cookie-session/principal binding and full console identity after the drain delay, outside console locks. It calls lifecycle APIs, not socket IO; HTTPD remains the socket owner. Stop API errors propagate through the existing deferred-result path. Revoked/stale work does not invoke stop/reboot.
|
||||||
|
- Existing two-entry control queue, ten-second drain wait, 200 ms final delay and five-second output backpressure remain. Drain is a best-effort application-buffer heuristic, **not browser receipt confirmation**, and the drain bound does not establish a hard upper bound on underlying lifecycle API execution. A failed stop may already have invalidated authentication and require UART0/SSH recovery.
|
||||||
|
- Console snapshots expose the existing deferred-pending flag. While deferral is observed, HTTPD wipes buffered/new input rather than replaying it after cancellation. The incoming frame's discard decision is latched before bounded payload reception, so cancellation during reception cannot reclassify that frame as a new command. A second frame arriving before the periodic poll does not turn deferred trailing input into a session-closing overflow. Normal invalid/oversized/fragmented frames and nondeferred buffer exhaustion still fail closed. Discard events contribute to existing input-backpressure counts.
|
||||||
|
|
||||||
|
**Intentional effects:** `web stop` closes both browser-admin and serial WebSockets and invalidates HTTPS sessions. A web writer therefore loses its broker lease; USB, UART0 and SSH are not stopped. Restart HTTPS through UART0/admin SSH using `web start`, then log in again. `reboot` restarts the entire device and loses RAM-only configuration. Normal terminal selection and admin-only `exit` retain their existing serial-isolation behavior.
|
||||||
|
|
||||||
|
## Restrictions still in force
|
||||||
|
|
||||||
|
These remain deliberately blocked for WEB, before canonical handler side effects:
|
||||||
|
|
||||||
|
- All `web` forms except exact parsed `web status`, `web stop` and `web certificate rotate --force`. This still blocks certificate info, start, help/counters, legacy credential display/rotation and full material reset.
|
||||||
|
- Wi-Fi/mDNS commands except exact status queries.
|
||||||
|
- User mutations; allowed inspection remains bare `user`, `user status`, `user list`, `user show <name>`.
|
||||||
|
- SSH stop/disconnect/reset and host-key mutation (host-key info remains allowed).
|
||||||
|
- Bootstrap/recovery remain UART0-only for all remotes. No one-time self-generated credential workflow was enabled.
|
||||||
|
|
||||||
|
The next increment remains **within 8D.7**: credential/account handling, then other owner-specific restrictions in bounded slices. The user explicitly authorized stacking the next bounded slice without target sign-off. Preserve explicit rejection until each path is implemented. Do not proceed to settings or close M2 based on either slice.
|
||||||
|
|
||||||
|
## First-slice executed verification
|
||||||
|
|
||||||
|
| Command | Result |
|
||||||
|
|---|---|
|
||||||
|
| `python3 tests/admin_console_boundary/run.py` | Shared console/SSH, dispatcher-origin, pending snapshot, action bounds, queue/drain/cancellation and existing prompt/currentness regressions pass |
|
||||||
|
| `python3 tests/admin_console_boundary/lifecycle.py` | New extracted canonical-handler checks pass: browser stop deferred, SSH/UART stop unchanged, remote reboot and scheduling-failure isolation |
|
||||||
|
| `python3 tests/admin_ssh_policy/run.py` | Actual IDF parser permits only the intended new WEB forms, including quotes; remaining restrictions/SSH policy pass |
|
||||||
|
| `python3 tests/web_admin_transport/run.py --tickets` | **23 transport / 12 ticket groups pass**, including stale/revoked action rejection, control-task API routing/error propagation, pending input, second frame before poll and cancellation during receive |
|
||||||
|
| `python3 tests/web_admin_transport/server_lifecycle.py` | **11 groups pass**, including detach timeout, failed stop/retry and optional failure isolation |
|
||||||
|
| `python3 tests/web_cookie_auth/run.py --admin` | Real cookie/store/ticket/adapter/admin endpoint integration passes; lifecycle calls doubled and not invoked by endpoint tests |
|
||||||
|
| `python3 tests/web_session_store/run.py --serial` | Store/serial binding/isolation/race regressions pass |
|
||||||
|
| `python3 tests/web_ui_session/run.py` | Renderer/CSP and **17 UI groups pass**; no UI changes |
|
||||||
|
| `git diff --check` | Pass |
|
||||||
|
| `pio run` | Final source build **12.44 s**, finite 180-second timeout; **95,580 B linked RAM / 1,647,025 B flash** |
|
||||||
|
|
||||||
|
Independent review identified the second-frame-before-poll cancellation bug, then cancellation-during-receive reclassification. Both were fixed with regressions; reviewer confirmed the final correction with no remaining findings in it. An initial console test still expected `web stop` denial; updated it to a still-restricted command. These are not target failures. An earlier pre-review-final build was 1,646,965 B flash; the table above supersedes it.
|
||||||
|
|
||||||
|
Tests use deterministic dependencies, not real concurrent RTOS/HTTPD/TLS/device execution. Actual command handlers, console state machine, transport, and server orchestration are exercised in focused harnesses, not one fully linked concurrent end-to-end firmware harness. No sanitizer pass or on-device stop/reboot is claimed.
|
||||||
|
|
||||||
|
## First-slice resources
|
||||||
|
|
||||||
|
Versus signed-off 8D.6 (95,580 / 1,646,489 B): **0 B RAM / +536 B flash**. Versus recorded 8D.0 (94,532 / 1,599,973 B): **+1,048 / +47,052 B**. Baselines were not rebuilt.
|
||||||
|
|
||||||
|
No new static payload/state, module heap/PSRAM allocation, task, stack-size, queue-depth, HTTP handler, TLS/socket, ticket or session capacity. The snapshot adds a boolean describing already-existing console state; callers use local snapshot scratch. WEB lifecycle validation copies one secret-free principal on the existing control stack. Existing control stack is 4 KiB, dispatcher 12 KiB; runtime stack high-water marks for these new call paths remain unmeasured. HTTPD remains 10 KiB, SSH owner 20 KiB, web payload 1,552 B PSRAM-only, six HTTPD sockets and two shared console slots.
|
||||||
|
|
||||||
|
Carry forward 8D.6 loaded lifetime minima **6,516 B internal / 1,580 B DMA**, noting overlapping capabilities and conservative regional sums. Numeric reserves remain open; no safe margin or new reserve violation is inferred. Do not reopen the prior sign-off solely for incomplete numeric instrumentation.
|
||||||
|
|
||||||
|
## Pending target checklist — both slices
|
||||||
|
|
||||||
|
1. User-controlled flash/reload; capture exact revision, 60-second settled `memory`, web/SSH status and available stack telemetry. Check browser login/serial/admin, USB UART1, user/admin SSH and UART0 at the established 230400-baud workload.
|
||||||
|
2. With browser serial + admin and USB/user/admin SSH active, issue **`web stop` from browser admin**. Expect its best-effort scheduling acknowledgement, then both browser routes close; UART0/USB/SSH remain usable. Confirm no browser writer remains. Restart with UART0/admin SSH `web start`, reauthenticate and reconnect. Repeat five times and compare full-mix/cleanup heap and largest blocks; do not expect boot equivalence when UART/clients remain active.
|
||||||
|
3. With any RAM-only settings loss understood, issue browser **`reboot`**. Expect best-effort acknowledgement and device restart, then usable UART0 recovery and browser login. Verify no replay of the command on reconnect.
|
||||||
|
4. Exercise queued/slow output, trailing input and rapid additional input during deferral. No cross-session output, no deferred input replay after cancellation, no unrelated admin-only-close effect on serial. Logout/revoke/disconnect before execution should prevent stale actions. Timing/failure injection not practical on target remains explicitly unexecuted rather than assumed passed.
|
||||||
|
5. Check remaining forbidden commands still reject without side effects, and SSH/UART0 stop/reboot/certificate behavior remains unchanged. For browser rotation, reject missing force, extra arguments and other certificate forms without identity changes; accept only exact parsed `web certificate rotate --force` (including equivalent quoting).
|
||||||
|
6. With browser serial/admin plus USB and user/admin SSH active, rotate the certificate. Expect only a best-effort scheduling acknowledgement, both browser routes closing, old session/ticket rejection and loss of a web writer lease. Verify the changed public fingerprint through trusted UART0/admin SSH `web certificate info`, update browser trust deliberately, log in again with unchanged account credentials and reconnect. Confirm USB UART1/SSH traffic and UART0 recovery remain available; do not log private keys, passwords or session/ticket material.
|
||||||
|
7. Repeat rotation/relogin under the established full mix and compare loaded/cleanup memory and largest blocks. Exercise revocation/disconnect/slot reuse before dispatcher execution, queued prompts/slow output, trailing input and no replay. Collect control/dispatcher/HTTPD stack high-water evidence where instrumentation permits; record unknown margins rather than substituting host sizes.
|
||||||
|
8. Where safely injectable, verify generation/persistence failure skips stop/start and retains old material; stop/detach failure after commit retains ownership, skips start and may leave auth disabled; successful stop followed by start failure retains the new persisted identity. Recover through UART0/admin SSH by inspecting state and completing stop/start as appropriate, with no duplicate HTTPD start or blind rotation retry. Unavailable target failure injection remains unexecuted.
|
||||||
|
|
||||||
|
Do not run these disruptive commands automatically. The user has explicitly authorized stacking the next bounded runtime slice; no hardware checklist item is thereby passed. Final M2 acceptance remains pending and requires lifecycle parity, the full mixed-client/soak/resource checkpoint and explicit target/browser acceptance before settings.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Phase 8D — Incremental web administration plan
|
# Phase 8D — Incremental web administration plan
|
||||||
|
|
||||||
Status: **8D.0–8D.5 and M1 validated by explicit user sign-off. 8D.6 implemented, host-tested and build-verified; target validation and independent parent review pending, no phase sign-off. 8D.5 closure on 2026-09-06 stands. Numeric reserve gates remain open. 8D.7–8D.22 remain planned, each requiring a separate implementation request.** See the [8D.6 implementation record](phase8d6_implementation.md), [8D.5 implementation record](phase8d5_implementation.md), [8D.4 implementation record](phase8d4_implementation.md), [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md).
|
Status: **8D.0–8D.6 and M1 validated by explicit user sign-off. 8D.7 first stop/reboot and second certificate slices implemented, host-tested, build-verified and reviewed; target validation and M2 acceptance pending. User explicitly authorized stacking the next bounded credential/account slice, not target sign-off. Other owner slices remain; 8D.8–8D.22 are not authorized by that continuation. Numeric reserve gates remain open.** See the [8D.6 implementation record](phase8d6_implementation.md), [8D.5 implementation record](phase8d5_implementation.md), [8D.4 implementation record](phase8d4_implementation.md), [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md).
|
||||||
|
|
||||||
This is the execution plan for [roadmap Phase 8D](roadmap.md#phase-8--role-based-users-and-administrative-access). The roadmap retains the feature/security requirements; this document defines small work units, dependencies, and release gates. The [administration test matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration) remains the final acceptance checklist.
|
This is the execution plan for [roadmap Phase 8D](roadmap.md#phase-8--role-based-users-and-administrative-access). The roadmap retains the feature/security requirements; this document defines small work units, dependencies, and release gates. The [administration test matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration) remains the final acceptance checklist.
|
||||||
|
|
||||||
@@ -138,11 +138,15 @@ If 8D.3 exceeds the work-unit limit, first split out inert login-page rendering
|
|||||||
|
|
||||||
### 8D.7 — Web-shell lifecycle parity and M2 acceptance
|
### 8D.7 — Web-shell lifecycle parity and M2 acceptance
|
||||||
|
|
||||||
|
**Second bounded certificate slice:** [Implementation, separate slice histories and pending target checklist](phase8d7_implementation.md). Exact parsed browser `web certificate rotate --force` uses a typed request-queue union and immutable owner `dispatcher_actions` mask: bounded drain/200 ms then nonblocking handoff to the existing 12 KiB dispatcher, not 4 KiB control. Pending input gating, token/principal/session revalidation and executing-slot reservation persist through execution. Transactional certificate commit → stop → start short-circuits errors and retains ownership on failed stop; SSH/UART0 unchanged. No new tasks/depth/routes/assets/stacks; target owner-mask/local-scratch accounting and stack margins unknown, host sizeof is not proof. Parent final `pio run` PASS **26.32 s, 95,580 B RAM / 1,648,061 B flash**: **0 / +1,036 B** vs first slice, **0 / +1,572 B** vs 8D.6, **+1,048 / +48,088 B** vs 8D.0. Implementer focused suites pass (transport **25**/tickets **12**, server **11**, boundary including certificate, lifecycle/policy/cookie-admin/store-serial/diff); independent reviewer reports no actionable findings. Sanitizers unavailable (missing libasan/libubsan); no hardware validation. User explicitly authorized stacking the next bounded slice: credential/account, then other owner slices. Other mutations remain blocked; target/M2 acceptance and numeric reserves pending. This supersedes the first-slice next-step/continuation-pending statement below.
|
||||||
|
|
||||||
|
**First bounded increment history (2026-09-06):** [Implementation, restrictions and target checklist](phase8d7_implementation.md). Browser `reboot` and `web stop` now use existing deferred control with final WEB session/currentness checks and discard of pending input. Other identity/network/account/SSH restrictions remain explicit. Host suites/review/build pass: **95,580 B RAM / 1,647,025 B flash**, **0 / +536 B** versus 8D.6, final build **12.44 s**. No new task/route/capacity. Target regression or explicit continuation decision pending; this is not completed 8D.7/M2. Next slice remains HTTPS identity/certificate handling, not settings. Prior sign-offs stand; numeric reserves remain open.
|
||||||
|
|
||||||
**Start in:** The console deferred-control boundary, web lifecycle owner, and only the affected command handlers.
|
**Start in:** The console deferred-control boundary, web lifecycle owner, and only the affected command handlers.
|
||||||
|
|
||||||
**Scope:** Close the explicit 8D.5 restrictions for self-terminating web-shell operations (including HTTPS stop/identity changes and reboot where supported by the canonical registry). Reuse bounded deferred acknowledgement/close semantics; never claim application-buffer draining proves browser receipt. Prevent further input during pending actions. Preserve UART0-only bootstrap/recovery and safe policy for one-time self-generated credentials. No typed danger-zone API yet. If this requires several distinct owner changes, split them before implementation and keep unimplemented actions explicitly rejected.
|
**Scope:** Close the explicit 8D.5 restrictions for self-terminating web-shell operations (including HTTPS stop/identity changes and reboot where supported by the canonical registry). Reuse bounded deferred acknowledgement/close semantics; never claim application-buffer draining proves browser receipt. Prevent further input during pending actions. Preserve UART0-only bootstrap/recovery and safe policy for one-time self-generated credentials. No typed danger-zone API yet. If this requires several distinct owner changes, split them before implementation and keep unimplemented actions explicitly rejected.
|
||||||
|
|
||||||
**Gate — M2 (mandatory target/browser pause):** Browser/UART0/admin-SSH commands serialize with no output or hidden-prompt crossover; queued work is discarded after logout/revocation/slot reuse. Deferred operations acknowledge or fail within bounds and reconnect behaves as documented. Run simultaneous USB, serial WebSocket, admin WebSocket, user SSH, admin SSH, UART0 and UART1 traffic; verify lease retention, recovery availability, and measured memory/stack floors. Web-admin initialization/admission failure must leave M1 serial login and existing non-web paths usable. Stop for user confirmation before settings.
|
**Gate — M2 (mandatory target/browser pause):** Browser/UART0/admin-SSH commands serialize with no output or hidden-prompt crossover; queued work is discarded after logout/revocation/slot reuse. Deferred acknowledgement/drain handling is bounded and reconnect behaves as documented; this is not confirmed peer receipt or a deadline for dispatcher queue residence or underlying certificate/NVS/lifecycle execution. Run simultaneous USB, serial WebSocket, admin WebSocket, user SSH, admin SSH, UART0 and UART1 traffic; verify lease retention, recovery availability, and measured memory/stack floors. Web-admin initialization/admission failure must leave M1 serial login and existing non-web paths usable. Stop for user confirmation before settings.
|
||||||
|
|
||||||
## M3 — Typed settings, one domain per chunk
|
## M3 — Typed settings, one domain per chunk
|
||||||
|
|
||||||
@@ -179,11 +183,11 @@ Update the roadmap and user/command documentation to distinguish completed featu
|
|||||||
|
|
||||||
## Progress and next-request template
|
## Progress and next-request template
|
||||||
|
|
||||||
Progress: **8D.0–8D.5 and M1 validated by user sign-off; numeric reserves remain open. 8D.6 implemented/host-tested/build-verified, target pending. 8D.7–8D.22 planned.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds. The baseline records user-provided evidence and sign-off; this does not imply completion of later browser-authentication acceptance checks.
|
Progress: **8D.0–8D.6 and M1 validated by user sign-off; numeric reserves remain open. 8D.7 stop/reboot and certificate slices implemented/host-tested/build-verified/reviewed, target/M2 acceptance pending. User authorized the next bounded credential/account slice, then other owner slices; settings remain out of scope.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds. The baseline records user-provided evidence and sign-off; this does not imply completion of later browser-authentication acceptance checks.
|
||||||
|
|
||||||
Suggested next request:
|
Suggested next request:
|
||||||
|
|
||||||
> Review and target-validate Phase 8D.6 using its implementation checklist. Preserve the serial connection, broker identity and writer lease while switching terminals. Record actual browser/target results and open numeric reserve gates; do not implement 8D.7 lifecycle parity or settings without a separate request.
|
> Continue the explicitly authorized next bounded 8D.7 credential/account slice. Preserve unsupported restrictions until safe owner handling exists, UART0-only bootstrap/recovery, secret-safe one-time credential policy and existing resource bounds. Use the second-certificate-slice handoff; do not treat continuation as target sign-off. Other owner slices and the full target/browser M2 acceptance checkpoint remain before settings.
|
||||||
|
|
||||||
For later chunks:
|
For later chunks:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Import("env")
|
||||||
|
|
||||||
|
env.Replace(COMPILATIONDB_INCLUDE_TOOLCHAIN=True)
|
||||||
+82
-14
@@ -70,9 +70,17 @@ typedef struct {
|
|||||||
uint8_t output[ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY];
|
uint8_t output[ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY];
|
||||||
} admin_session_t;
|
} admin_session_t;
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
admin_ssh_deferred_action_type_t action;
|
||||||
|
admin_ssh_console_token_t token;
|
||||||
|
const admin_console_owner_t *owner;
|
||||||
|
uint32_t argument;
|
||||||
|
} admin_control_request_t;
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
ADMIN_REQUEST_SSH = 0,
|
ADMIN_REQUEST_SSH = 0,
|
||||||
ADMIN_REQUEST_UART0,
|
ADMIN_REQUEST_UART0,
|
||||||
|
ADMIN_REQUEST_DEFERRED,
|
||||||
} admin_request_origin_t;
|
} admin_request_origin_t;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -80,16 +88,12 @@ typedef struct {
|
|||||||
admin_ssh_console_token_t token;
|
admin_ssh_console_token_t token;
|
||||||
user_principal_t principal;
|
user_principal_t principal;
|
||||||
TaskHandle_t completion_task;
|
TaskHandle_t completion_task;
|
||||||
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
union {
|
||||||
|
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
|
||||||
|
admin_control_request_t deferred;
|
||||||
|
};
|
||||||
} admin_request_t;
|
} admin_request_t;
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
admin_ssh_deferred_action_type_t action;
|
|
||||||
admin_ssh_console_token_t token;
|
|
||||||
const admin_console_owner_t *owner;
|
|
||||||
uint32_t argument;
|
|
||||||
} admin_control_request_t;
|
|
||||||
|
|
||||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||||
static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_MAX_SESSIONS];
|
static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_MAX_SESSIONS];
|
||||||
/* Claimed under s_lock, used outside it; competing TAB input is backpressured. */
|
/* Claimed under s_lock, used outside it; competing TAB input is backpressured. */
|
||||||
@@ -122,6 +126,12 @@ bool admin_ssh_console_dispatch_is_remote(void)
|
|||||||
return xTaskGetCurrentTaskHandle() == s_task && s_dispatch_remote;
|
return xTaskGetCurrentTaskHandle() == s_task && s_dispatch_remote;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool admin_ssh_console_dispatch_is_web(void)
|
||||||
|
{
|
||||||
|
return admin_ssh_console_dispatch_is_remote() &&
|
||||||
|
s_dispatch_token.transport == ADMIN_CONSOLE_TRANSPORT_WEB;
|
||||||
|
}
|
||||||
|
|
||||||
const user_principal_t *admin_ssh_console_dispatch_principal(void)
|
const user_principal_t *admin_ssh_console_dispatch_principal(void)
|
||||||
{
|
{
|
||||||
return admin_ssh_console_dispatch_is_remote() ? &s_dispatch_principal : NULL;
|
return admin_ssh_console_dispatch_is_remote() ? &s_dispatch_principal : NULL;
|
||||||
@@ -401,7 +411,7 @@ esp_err_t admin_ssh_console_dispatch_defer(
|
|||||||
bool valid = token_matches(session, &s_dispatch_token) &&
|
bool valid = token_matches(session, &s_dispatch_token) &&
|
||||||
!session->deferred_action_pending;
|
!session->deferred_action_pending;
|
||||||
const admin_console_owner_t *owner = valid ? session->owner : NULL;
|
const admin_console_owner_t *owner = valid ? session->owner : NULL;
|
||||||
if (valid && ((unsigned)action > ADMIN_CONSOLE_DEFER_SELF_CLOSE ||
|
if (valid && ((unsigned)action > ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE ||
|
||||||
!(owner->supported_actions & (1U << action)))) {
|
!(owner->supported_actions & (1U << action)))) {
|
||||||
taskEXIT_CRITICAL(&s_lock);
|
taskEXIT_CRITICAL(&s_lock);
|
||||||
return ESP_ERR_NOT_SUPPORTED;
|
return ESP_ERR_NOT_SUPPORTED;
|
||||||
@@ -496,8 +506,12 @@ static bool remote_command_allowed(const admin_request_t *request)
|
|||||||
* User mutations remain available through UART0/SSH, subject to their policy.
|
* User mutations remain available through UART0/SSH, subject to their policy.
|
||||||
*/
|
*/
|
||||||
if (request->token.transport == ADMIN_CONSOLE_TRANSPORT_WEB && argc > 0U) {
|
if (request->token.transport == ADMIN_CONSOLE_TRANSPORT_WEB && argc > 0U) {
|
||||||
if (strcmp(argv[0], "web") == 0 || strcmp(argv[0], "wifi") == 0 ||
|
if (strcmp(argv[0], "web") == 0) {
|
||||||
strcmp(argv[0], "mdns") == 0) {
|
allowed = (argc == 2U && (strcmp(argv[1], "status") == 0 ||
|
||||||
|
strcmp(argv[1], "stop") == 0)) ||
|
||||||
|
(argc == 4U && strcmp(argv[1], "certificate") == 0 &&
|
||||||
|
strcmp(argv[2], "rotate") == 0 && strcmp(argv[3], "--force") == 0);
|
||||||
|
} else if (strcmp(argv[0], "wifi") == 0 || strcmp(argv[0], "mdns") == 0) {
|
||||||
allowed = argc == 2U && strcmp(argv[1], "status") == 0;
|
allowed = argc == 2U && strcmp(argv[1], "status") == 0;
|
||||||
} else if (strcmp(argv[0], "user") == 0) {
|
} else if (strcmp(argv[0], "user") == 0) {
|
||||||
allowed = argc == 1U ||
|
allowed = argc == 1U ||
|
||||||
@@ -505,9 +519,9 @@ static bool remote_command_allowed(const admin_request_t *request)
|
|||||||
strcmp(argv[1], "list") == 0)) ||
|
strcmp(argv[1], "list") == 0)) ||
|
||||||
(argc == 3U && strcmp(argv[1], "show") == 0);
|
(argc == 3U && strcmp(argv[1], "show") == 0);
|
||||||
} else if (strcmp(argv[0], "reboot") == 0) {
|
} else if (strcmp(argv[0], "reboot") == 0) {
|
||||||
allowed = false;
|
allowed = argc == 1U;
|
||||||
} else if (strcmp(argv[0], "ssh") == 0 && argc >= 2U) {
|
} else if (strcmp(argv[0], "ssh") == 0 && argc >= 2U) {
|
||||||
/* These handlers defer for every remote; WEB supports SELF_CLOSE only. */
|
/* SSH-specific deferred actions are not yet supported by WEB. */
|
||||||
if (strcmp(argv[1], "stop") == 0 || strcmp(argv[1], "disconnect") == 0 ||
|
if (strcmp(argv[1], "stop") == 0 || strcmp(argv[1], "disconnect") == 0 ||
|
||||||
strcmp(argv[1], "reset") == 0 ||
|
strcmp(argv[1], "reset") == 0 ||
|
||||||
(strcmp(argv[1], "host-key") == 0 &&
|
(strcmp(argv[1], "host-key") == 0 &&
|
||||||
@@ -598,6 +612,8 @@ static void dispatch_registered_command(admin_request_t *request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void dispatch_deferred_request(admin_request_t *request);
|
||||||
|
|
||||||
static void worker_task(void *context)
|
static void worker_task(void *context)
|
||||||
{
|
{
|
||||||
(void)context;
|
(void)context;
|
||||||
@@ -606,6 +622,11 @@ static void worker_task(void *context)
|
|||||||
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
|
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (request.origin == ADMIN_REQUEST_DEFERRED) {
|
||||||
|
dispatch_deferred_request(&request);
|
||||||
|
secure_wipe(&request, sizeof(request));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (request.origin == ADMIN_REQUEST_UART0) {
|
if (request.origin == ADMIN_REQUEST_UART0) {
|
||||||
dispatch_registered_command(&request);
|
dispatch_registered_command(&request);
|
||||||
if (request.completion_task != NULL) {
|
if (request.completion_task != NULL) {
|
||||||
@@ -683,6 +704,34 @@ static void finish_deferred_request(const admin_control_request_t *request,
|
|||||||
taskEXIT_CRITICAL(&s_lock);
|
taskEXIT_CRITICAL(&s_lock);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void dispatch_deferred_request(admin_request_t *request)
|
||||||
|
{
|
||||||
|
const admin_control_request_t *action = &request->deferred;
|
||||||
|
bool current = session_is_current(&action->token, &request->principal);
|
||||||
|
taskENTER_CRITICAL(&s_lock);
|
||||||
|
admin_session_t *session = &s_sessions[action->token.slot_index];
|
||||||
|
bool active = current && token_matches(session, &action->token) &&
|
||||||
|
session->owner == action->owner && session->deferred_action_pending &&
|
||||||
|
!session->command_pending && !session->executing;
|
||||||
|
if (active) session->executing = true;
|
||||||
|
taskEXIT_CRITICAL(&s_lock);
|
||||||
|
|
||||||
|
/* Keep the slot reserved across lifecycle callbacks, including self-detach.
|
||||||
|
* Recheck after reservation just as the canonical runner does. */
|
||||||
|
esp_err_t result = ESP_ERR_NOT_FOUND;
|
||||||
|
if (active && session_is_current(&action->token, &request->principal)) {
|
||||||
|
result = action->owner->perform(&action->token, action->action, action->argument);
|
||||||
|
}
|
||||||
|
taskENTER_CRITICAL(&s_lock);
|
||||||
|
session = &s_sessions[action->token.slot_index];
|
||||||
|
if (active && token_identity_matches(session, &action->token)) {
|
||||||
|
if (session->active) session->executing = false;
|
||||||
|
else secure_wipe(session, sizeof(*session));
|
||||||
|
}
|
||||||
|
taskEXIT_CRITICAL(&s_lock);
|
||||||
|
finish_deferred_request(action, result, false);
|
||||||
|
}
|
||||||
|
|
||||||
static void control_task(void *context)
|
static void control_task(void *context)
|
||||||
{
|
{
|
||||||
(void)context;
|
(void)context;
|
||||||
@@ -718,8 +767,26 @@ static void control_task(void *context)
|
|||||||
}
|
}
|
||||||
vTaskDelay(pdMS_TO_TICKS(200U));
|
vTaskDelay(pdMS_TO_TICKS(200U));
|
||||||
taskENTER_CRITICAL(&s_lock);
|
taskENTER_CRITICAL(&s_lock);
|
||||||
bool current = token_matches(&s_sessions[request.token.slot_index], &request.token);
|
admin_session_t *session = &s_sessions[request.token.slot_index];
|
||||||
|
bool current = token_matches(session, &request.token) &&
|
||||||
|
session->owner == request.owner && session->deferred_action_pending;
|
||||||
|
admin_request_t queued = {
|
||||||
|
.origin = ADMIN_REQUEST_DEFERRED,
|
||||||
|
.deferred = request,
|
||||||
|
};
|
||||||
|
if (current) queued.principal = session->principal;
|
||||||
taskEXIT_CRITICAL(&s_lock);
|
taskEXIT_CRITICAL(&s_lock);
|
||||||
|
if (current && (request.owner->dispatcher_actions & (1U << request.action))) {
|
||||||
|
/* Nonblocking handoff: a full dispatcher queue fails before mutation.
|
||||||
|
* Pending remains set until execution completes, not merely enqueue. */
|
||||||
|
if (xQueueSend(s_request_queue, &queued, 0U) != pdTRUE) {
|
||||||
|
finish_deferred_request(&request, ESP_ERR_TIMEOUT, false);
|
||||||
|
}
|
||||||
|
secure_wipe(&queued, sizeof(queued));
|
||||||
|
secure_wipe(&request, sizeof(request));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
secure_wipe(&queued, sizeof(queued));
|
||||||
esp_err_t result = current ? request.owner->perform(
|
esp_err_t result = current ? request.owner->perform(
|
||||||
&request.token, request.action, request.argument) : ESP_ERR_NOT_FOUND;
|
&request.token, request.action, request.argument) : ESP_ERR_NOT_FOUND;
|
||||||
finish_deferred_request(&request, result, false);
|
finish_deferred_request(&request, result, false);
|
||||||
@@ -1266,6 +1333,7 @@ esp_err_t admin_ssh_console_get_session_snapshot(
|
|||||||
snapshot->command_pending = session->command_pending;
|
snapshot->command_pending = session->command_pending;
|
||||||
snapshot->input_pending = session->input_length != 0U;
|
snapshot->input_pending = session->input_length != 0U;
|
||||||
snapshot->output_pending = session->output_length != 0U;
|
snapshot->output_pending = session->output_length != 0U;
|
||||||
|
snapshot->deferred_action_pending = session->deferred_action_pending;
|
||||||
snapshot->input_length = session->input_length;
|
snapshot->input_length = session->input_length;
|
||||||
snapshot->output_length = session->output_length;
|
snapshot->output_length = session->output_length;
|
||||||
taskEXIT_CRITICAL(&s_lock);
|
taskEXIT_CRITICAL(&s_lock);
|
||||||
|
|||||||
+14
-2
@@ -35,6 +35,8 @@ typedef enum {
|
|||||||
ADMIN_SSH_DEFER_HOST_KEY_ROTATE,
|
ADMIN_SSH_DEFER_HOST_KEY_ROTATE,
|
||||||
ADMIN_SSH_DEFER_HOST_KEY_RESET,
|
ADMIN_SSH_DEFER_HOST_KEY_RESET,
|
||||||
ADMIN_CONSOLE_DEFER_SELF_CLOSE,
|
ADMIN_CONSOLE_DEFER_SELF_CLOSE,
|
||||||
|
ADMIN_CONSOLE_DEFER_WEB_STOP,
|
||||||
|
ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
|
||||||
} admin_ssh_deferred_action_type_t;
|
} admin_ssh_deferred_action_type_t;
|
||||||
|
|
||||||
/* Small owner boundary; module/API names are retained for existing SSH callers.
|
/* Small owner boundary; module/API names are retained for existing SSH callers.
|
||||||
@@ -44,7 +46,10 @@ typedef enum {
|
|||||||
* An occupied or still-executing slot cannot be replaced by open_owned().
|
* An occupied or still-executing slot cannot be replaced by open_owned().
|
||||||
*
|
*
|
||||||
* The immutable adapter lives for firmware lifetime. Callbacks run on the
|
* The immutable adapter lives for firmware lifetime. Callbacks run on the
|
||||||
* control task OUTSIDE console locks for drained/perform. Required is_current
|
* 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
|
* runs on the dispatcher outside console locks; it must be bounded and validate
|
||||||
* full transport identity, originating-session liveness and principal binding,
|
* full transport identity, originating-session liveness and principal binding,
|
||||||
* without calling socket libraries or handlers. Core separately checks accounts.
|
* without calling socket libraries or handlers. Core separately checks accounts.
|
||||||
@@ -53,7 +58,9 @@ typedef enum {
|
|||||||
* its owner, never call socket libraries here. Neither callback may call console
|
* its owner, never call socket libraries here. Neither callback may call console
|
||||||
* handlers. supported_actions is a bitmask (1U << action); reject unsupported
|
* handlers. supported_actions is a bitmask (1U << action); reject unsupported
|
||||||
* actions before side effects. Legacy STOP/DISCONNECT/key actions mean SSH;
|
* actions before side effects. Legacy STOP/DISCONNECT/key actions mean SSH;
|
||||||
* SELF_CLOSE means this frontend, with argument ignored.
|
* 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
|
* One owner serializes feed calls per session; different owners may feed in
|
||||||
* parallel. Shared completion scratch is nonblocking/serialized by the core.
|
* parallel. Shared completion scratch is nonblocking/serialized by the core.
|
||||||
@@ -66,10 +73,13 @@ typedef enum {
|
|||||||
* Close wakes prompts; executing state is retained until the handler returns.
|
* Close wakes prompts; executing state is retained until the handler returns.
|
||||||
* Output remains bounded (5s write backpressure); deferred work waits at most
|
* Output remains bounded (5s write backpressure); deferred work waits at most
|
||||||
* 10s for application drain plus 200ms, NOT peer-delivery confirmation.
|
* 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.
|
* No new tasks, queues, slots, or browser endpoint are provided by this API.
|
||||||
*/
|
*/
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uint32_t supported_actions;
|
uint32_t supported_actions;
|
||||||
|
uint32_t dispatcher_actions; /* Subset of supported_actions; immutable. */
|
||||||
bool (*is_current)(const admin_ssh_console_token_t *token,
|
bool (*is_current)(const admin_ssh_console_token_t *token,
|
||||||
const user_principal_t *principal);
|
const user_principal_t *principal);
|
||||||
bool (*drained)(const admin_ssh_console_token_t *token);
|
bool (*drained)(const admin_ssh_console_token_t *token);
|
||||||
@@ -95,6 +105,7 @@ typedef struct {
|
|||||||
bool command_pending;
|
bool command_pending;
|
||||||
bool input_pending;
|
bool input_pending;
|
||||||
bool output_pending;
|
bool output_pending;
|
||||||
|
bool deferred_action_pending;
|
||||||
size_t input_length;
|
size_t input_length;
|
||||||
size_t output_length;
|
size_t output_length;
|
||||||
} admin_ssh_console_session_snapshot_t;
|
} admin_ssh_console_session_snapshot_t;
|
||||||
@@ -108,6 +119,7 @@ esp_err_t admin_ssh_console_start_uart_frontend(void);
|
|||||||
|
|
||||||
/* Valid only while a registered command callback runs on the dispatcher task. */
|
/* 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_remote(void);
|
||||||
|
bool admin_ssh_console_dispatch_is_web(void);
|
||||||
const user_principal_t *admin_ssh_console_dispatch_principal(void);
|
const user_principal_t *admin_ssh_console_dispatch_principal(void);
|
||||||
esp_err_t admin_ssh_console_dispatch_read_input(
|
esp_err_t admin_ssh_console_dispatch_read_input(
|
||||||
const char *prompt, uint8_t *output, size_t capacity,
|
const char *prompt, uint8_t *output, size_t capacity,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ static int command_reboot(int argc, char **argv)
|
|||||||
printf("Could not schedule reboot: %s\n", esp_err_to_name(error));
|
printf("Could not schedule reboot: %s\n", esp_err_to_name(error));
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
printf("Reboot scheduled after SSH output drains; unsaved changes will be lost.\n");
|
printf("Reboot scheduled after console output drains; unsaved changes will be lost.\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n");
|
printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n");
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
#include "admin_ssh_console.h"
|
#include "admin_ssh_console.h"
|
||||||
#include "esp_heap_caps.h"
|
#include "esp_heap_caps.h"
|
||||||
#include "esp_timer.h"
|
#include "esp_timer.h"
|
||||||
|
#include "esp_system.h"
|
||||||
|
#include "web_server.h"
|
||||||
|
#include "web_security.h"
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
#include "secure_random.h"
|
#include "secure_random.h"
|
||||||
@@ -91,9 +94,34 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
|
|||||||
admin_ssh_deferred_action_type_t action, uint32_t argument)
|
admin_ssh_deferred_action_type_t action, uint32_t argument)
|
||||||
{
|
{
|
||||||
(void)argument;
|
(void)argument;
|
||||||
if (action != ADMIN_CONSOLE_DEFER_SELF_CLOSE) return ESP_ERR_NOT_SUPPORTED;
|
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);
|
taskENTER_CRITICAL(&s_lock);
|
||||||
bool valid = token_matches(token) && s_slot.active && s_accepting;
|
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. */
|
||||||
|
esp_err_t error = web_security_rotate_certificate();
|
||||||
|
if (error != ESP_OK) return error;
|
||||||
|
error = web_server_stop();
|
||||||
|
if (error != ESP_OK) return error;
|
||||||
|
return web_server_start();
|
||||||
|
}
|
||||||
|
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;
|
if (valid) s_slot.close_requested = true;
|
||||||
taskEXIT_CRITICAL(&s_lock);
|
taskEXIT_CRITICAL(&s_lock);
|
||||||
if (valid) admin_ssh_console_close(token);
|
if (valid) admin_ssh_console_close(token);
|
||||||
@@ -101,7 +129,10 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
|
|||||||
}
|
}
|
||||||
|
|
||||||
static const admin_console_owner_t s_owner = {
|
static const admin_console_owner_t s_owner = {
|
||||||
.supported_actions = 1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE,
|
.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,
|
.is_current = owner_current, .drained = owner_drained, .perform = owner_perform,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,11 +183,29 @@ static bool input_current(const admin_ssh_console_token_t *token,
|
|||||||
return false;
|
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,
|
static bool feed_pending(const admin_ssh_console_token_t *token,
|
||||||
const user_principal_t *principal)
|
const user_principal_t *principal)
|
||||||
{
|
{
|
||||||
if (s_payload->rx_offset == s_payload->rx_length) return true;
|
if (s_payload->rx_offset == s_payload->rx_length) return true;
|
||||||
if (!input_current(token, principal)) return false;
|
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) {
|
if (esp_timer_get_time() >= s_payload->input_deadline) {
|
||||||
count(&s_counts.input_backpressure, 1);
|
count(&s_counts.input_backpressure, 1);
|
||||||
request_close();
|
request_close();
|
||||||
@@ -425,9 +474,19 @@ static esp_err_t frame_handler(httpd_req_t *request)
|
|||||||
count(&s_counts.protocol_errors, 1);
|
count(&s_counts.protocol_errors, 1);
|
||||||
goto failure;
|
goto failure;
|
||||||
}
|
}
|
||||||
if (s_payload->rx_length != s_payload->rx_offset) {
|
admin_ssh_console_session_snapshot_t console;
|
||||||
count(&s_counts.input_backpressure, 1);
|
if (admin_ssh_console_get_session_snapshot(&token, &console) != ESP_OK || !console.active)
|
||||||
goto failure;
|
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;
|
frame.payload = s_payload->rx;
|
||||||
/* IDF treats len==0 as another header probe, not an empty payload read. */
|
/* IDF treats len==0 as another header probe, not an empty payload read. */
|
||||||
@@ -436,7 +495,8 @@ static esp_err_t frame_handler(httpd_req_t *request)
|
|||||||
s_payload->rx_length = frame.len;
|
s_payload->rx_length = frame.len;
|
||||||
s_payload->rx_offset = 0;
|
s_payload->rx_offset = 0;
|
||||||
s_payload->input_deadline = esp_timer_get_time() + ADMIN_INPUT_TIMEOUT_US;
|
s_payload->input_deadline = esp_timer_get_time() + ADMIN_INPUT_TIMEOUT_US;
|
||||||
if (!feed_pending(&token, &principal)) goto failure;
|
if (discard_frame) discard_pending_input();
|
||||||
|
else if (!feed_pending(&token, &principal)) goto failure;
|
||||||
count(&s_counts.rx_bytes, (uint32_t)frame.len);
|
count(&s_counts.rx_bytes, (uint32_t)frame.len);
|
||||||
secure_wipe(&principal, sizeof(principal));
|
secure_wipe(&principal, sizeof(principal));
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
/* UART0 HTTPS lifecycle, legacy recovery credential, and certificate commands. */
|
/* UART0 HTTPS lifecycle, legacy recovery credential, and certificate commands. */
|
||||||
|
|
||||||
#include "web_console.h"
|
#include "web_console.h"
|
||||||
|
#include "admin_ssh_console.h"
|
||||||
|
|
||||||
#include <inttypes.h>
|
#include <inttypes.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
@@ -394,6 +395,15 @@ static int command_web(int argc, char **argv)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (argc == 2 && strcmp(argv[1], "stop") == 0) {
|
if (argc == 2 && strcmp(argv[1], "stop") == 0) {
|
||||||
|
if (admin_ssh_console_dispatch_is_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();
|
esp_err_t error = web_server_stop();
|
||||||
if (error != ESP_OK) {
|
if (error != ESP_OK) {
|
||||||
printf("Could not stop HTTPS: %s\n", esp_err_to_name(error));
|
printf("Could not stop HTTPS: %s\n", esp_err_to_name(error));
|
||||||
@@ -439,6 +449,16 @@ static int command_web(int argc, char **argv)
|
|||||||
printf("Certificate rotation requires: web certificate rotate --force\n");
|
printf("Certificate rotation requires: web certificate rotate --force\n");
|
||||||
return 1;
|
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 certificate rotation scheduled after console output drains; both browser connections will close. Reconnect and verify the new certificate. If restart fails, use UART0 or SSH recovery.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
return rotate_certificate();
|
return rotate_certificate();
|
||||||
}
|
}
|
||||||
if (strcmp(argv[1], "reset") == 0) {
|
if (strcmp(argv[1], "reset") == 0) {
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
/* Typed deferred work exercises the production dispatcher/control state machine. */
|
||||||
|
static admin_ssh_console_token_t token={.session_id=7, .slot_generation=1,
|
||||||
|
.transport=ADMIN_CONSOLE_TRANSPORT_WEB};
|
||||||
|
static user_principal_t principal={.role=USER_ROLE_ADMIN, .auth_generation=1};
|
||||||
|
static bool live=true, close_in_action;
|
||||||
|
static unsigned validations, invalidate_at;
|
||||||
|
static esp_err_t action_result;
|
||||||
|
static bool current(const admin_ssh_console_token_t *t, const user_principal_t *p) {
|
||||||
|
assert(!lock_depth && t->session_id==7 && p->auth_generation==1);
|
||||||
|
if (++validations==invalidate_at) live=false;
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
static bool drained(const admin_ssh_console_token_t *t) {
|
||||||
|
assert(!lock_depth && current_task==s_control_task && t->session_id==7);
|
||||||
|
return owner_drained;
|
||||||
|
}
|
||||||
|
static esp_err_t perform(const admin_ssh_console_token_t *t,
|
||||||
|
admin_ssh_deferred_action_type_t action, uint32_t arg);
|
||||||
|
static const admin_console_owner_t owner={
|
||||||
|
.supported_actions=1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
|
||||||
|
.dispatcher_actions=1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
|
||||||
|
.is_current=current, .drained=drained, .perform=perform,
|
||||||
|
};
|
||||||
|
static esp_err_t perform(const admin_ssh_console_token_t *t,
|
||||||
|
admin_ssh_deferred_action_type_t action, uint32_t arg) {
|
||||||
|
assert(!lock_depth && current_task==s_task && current_task!=s_control_task);
|
||||||
|
assert(action==ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE && arg==0);
|
||||||
|
assert(s_sessions[0].executing && s_sessions[0].deferred_action_pending);
|
||||||
|
assert(!admin_ssh_console_accepts_input(t));
|
||||||
|
size_t consumed=99;
|
||||||
|
assert(!admin_ssh_console_feed_input(t,(const uint8_t *)"ignored",7,&consumed) && !consumed);
|
||||||
|
++actions;
|
||||||
|
if (close_in_action) {
|
||||||
|
admin_ssh_console_close(t);
|
||||||
|
admin_ssh_console_token_t replacement=*t; ++replacement.slot_generation;
|
||||||
|
assert(admin_ssh_console_open_owned(&replacement,&principal,&owner)==ESP_ERR_INVALID_STATE);
|
||||||
|
}
|
||||||
|
return action_result;
|
||||||
|
}
|
||||||
|
static void pump(void (*task)(void *)) {
|
||||||
|
current_task=task==control_task ? s_control_task : s_task;
|
||||||
|
if (!setjmp(loop_done)) task(NULL);
|
||||||
|
}
|
||||||
|
static void clear_output(void) {
|
||||||
|
uint8_t data[4096]; size_t n;
|
||||||
|
assert(admin_ssh_console_read_output(&token,data,sizeof(data),&n)==ESP_OK);
|
||||||
|
}
|
||||||
|
static void reopen_certificate_session(void) {
|
||||||
|
admin_ssh_console_close(&token); ++token.slot_generation;
|
||||||
|
live=principal_current=owner_drained=true; validations=invalidate_at=0;
|
||||||
|
close_in_action=false; action_result=ESP_OK; ticks=0;
|
||||||
|
assert(admin_ssh_console_open_owned(&token,&principal,&owner)==ESP_OK);
|
||||||
|
clear_output();
|
||||||
|
}
|
||||||
|
static esp_err_t schedule(void) {
|
||||||
|
current_task=s_task; s_dispatch_remote=true; s_dispatch_token=token;
|
||||||
|
s_dispatch_principal=principal;
|
||||||
|
s_sessions[0].executing=s_sessions[0].command_pending=true;
|
||||||
|
esp_err_t result=admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,0);
|
||||||
|
s_sessions[0].executing=s_sessions[0].command_pending=false;
|
||||||
|
s_dispatch_remote=false;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
static void revoke_delay(void) { if (ticks>=200) live=false; }
|
||||||
|
static void reuse_delay(void) {
|
||||||
|
if (ticks>=200) { delay_hook=NULL; reopen_certificate_session(); }
|
||||||
|
}
|
||||||
|
static void assert_pending(void) {
|
||||||
|
admin_ssh_console_session_snapshot_t snapshot;
|
||||||
|
assert(admin_ssh_console_get_session_snapshot(&token,&snapshot)==ESP_OK);
|
||||||
|
assert(snapshot.deferred_action_pending && !admin_ssh_console_accepts_input(&token));
|
||||||
|
}
|
||||||
|
static void uart_observes_pending(void) {
|
||||||
|
assert(current_task==s_task && actions==0); assert_pending();
|
||||||
|
}
|
||||||
|
int main(void) {
|
||||||
|
assert(admin_ssh_console_init()==ESP_OK);
|
||||||
|
assert(admin_ssh_console_start_uart_frontend()==ESP_OK);
|
||||||
|
s_task=(void *)1; s_control_task=(void *)2;
|
||||||
|
/* Union overlay preserves the old queue item allocation on this ABI. */
|
||||||
|
struct old_request { admin_request_origin_t origin; admin_ssh_console_token_t token;
|
||||||
|
user_principal_t principal; TaskHandle_t completion_task;
|
||||||
|
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY+1U]; };
|
||||||
|
assert(sizeof(admin_request_t)==sizeof(struct old_request));
|
||||||
|
assert(s_request_queue->capacity==4 && s_control_queue->capacity==2);
|
||||||
|
reopen_certificate_session(); queue_full=true;
|
||||||
|
assert(schedule()==ESP_ERR_TIMEOUT && !s_sessions[0].deferred_action_pending && !actions);
|
||||||
|
queue_full=false;
|
||||||
|
assert(schedule()==ESP_OK); assert_pending();
|
||||||
|
/* Drain waits for acknowledgement, then times out without enqueue/mutation. */
|
||||||
|
s_sessions[0].output_length=1; pump(control_task);
|
||||||
|
assert(ticks==10000 && !actions && !s_request_queue->count && !s_sessions[0].deferred_action_pending);
|
||||||
|
clear_output(); ticks=0;
|
||||||
|
assert(schedule()==ESP_OK);
|
||||||
|
admin_request_t uart={.origin=ADMIN_REQUEST_UART0, .line="memory"};
|
||||||
|
for (unsigned i=0;i<4;++i) assert(xQueueSend(s_request_queue,&uart,0));
|
||||||
|
pump(control_task);
|
||||||
|
assert(!actions && !s_sessions[0].deferred_action_pending && s_request_queue->count==4);
|
||||||
|
assert(s_sessions[0].output_length); pump(worker_task); clear_output();
|
||||||
|
puts("PASS: unchanged queue item/depths, admission and handoff queue failure before mutation, ack drain cancellation");
|
||||||
|
|
||||||
|
assert(schedule()==ESP_OK);
|
||||||
|
assert(xQueueSend(s_request_queue,&uart,0));
|
||||||
|
pump(control_task); assert_pending(); assert(!actions && s_request_queue->count==2);
|
||||||
|
command_hook=uart_observes_pending; pump(worker_task); command_hook=NULL;
|
||||||
|
assert(actions==1 && !s_sessions[0].deferred_action_pending && !s_sessions[0].executing);
|
||||||
|
assert(runs==5); /* Typed work never calls esp_console_run. */
|
||||||
|
puts("PASS: control only hands off, queued UART first, crypto callback exclusively serialized on dispatcher, input gated through callback");
|
||||||
|
|
||||||
|
for (unsigned cancellation=0;cancellation<7;++cancellation) {
|
||||||
|
reopen_certificate_session(); assert(schedule()==ESP_OK);
|
||||||
|
if (cancellation==0) delay_hook=revoke_delay;
|
||||||
|
if (cancellation==1) delay_hook=reuse_delay;
|
||||||
|
pump(control_task); delay_hook=NULL;
|
||||||
|
if (cancellation==2) live=false;
|
||||||
|
if (cancellation==3) principal_current=false;
|
||||||
|
if (cancellation==4) reopen_certificate_session();
|
||||||
|
if (cancellation==5) admin_ssh_console_close(&token);
|
||||||
|
if (cancellation==6) invalidate_at=2; /* Last check after executing reservation. */
|
||||||
|
pump(worker_task);
|
||||||
|
assert(actions==1 && !s_sessions[0].executing);
|
||||||
|
if (cancellation==1 || cancellation==4) {
|
||||||
|
assert(s_sessions[0].active && !s_sessions[0].deferred_action_pending && !s_sessions[0].output_length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
puts("PASS: delay/queued revoke, account revoke, close/reuse, final execution check; no output into replacements");
|
||||||
|
|
||||||
|
reopen_certificate_session(); action_result=ESP_ERR_NO_MEM; assert(schedule()==ESP_OK);
|
||||||
|
pump(control_task); pump(worker_task);
|
||||||
|
assert(actions==2 && !s_sessions[0].deferred_action_pending && admin_ssh_console_accepts_input(&token));
|
||||||
|
uint8_t out[512]={0}; size_t n;
|
||||||
|
assert(admin_ssh_console_read_output(&token,out,sizeof(out)-1,&n)==ESP_OK);
|
||||||
|
assert(strstr((char *)out,"Deferred remote action failed: fake"));
|
||||||
|
reopen_certificate_session(); close_in_action=true; assert(schedule()==ESP_OK);
|
||||||
|
pump(control_task); pump(worker_task);
|
||||||
|
admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty)) && actions==3);
|
||||||
|
puts("PASS: action error reaches deferred result, input resumes on failure, self-detach reserves slot until return and wipes state");
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ typedef struct {
|
|||||||
typedef unsigned TickType_t;
|
typedef unsigned TickType_t;
|
||||||
typedef void *TaskHandle_t;
|
typedef void *TaskHandle_t;
|
||||||
typedef int portMUX_TYPE;
|
typedef int portMUX_TYPE;
|
||||||
typedef struct { size_t size; unsigned count; unsigned char bytes[2048]; } StaticQueue_t;
|
typedef struct { size_t size; unsigned count, capacity; unsigned char bytes[2048]; } StaticQueue_t;
|
||||||
typedef StaticQueue_t *QueueHandle_t;
|
typedef StaticQueue_t *QueueHandle_t;
|
||||||
typedef int StaticSemaphore_t;
|
typedef int StaticSemaphore_t;
|
||||||
typedef int *SemaphoreHandle_t;
|
typedef int *SemaphoreHandle_t;
|
||||||
@@ -56,11 +56,13 @@ static void vTaskDelete(TaskHandle_t t) { (void)t; }
|
|||||||
static void xTaskNotifyGive(TaskHandle_t t) { (void)t; }
|
static void xTaskNotifyGive(TaskHandle_t t) { (void)t; }
|
||||||
static unsigned ulTaskNotifyTake(int b, unsigned t) { (void)b; (void)t; return 1; }
|
static unsigned ulTaskNotifyTake(int b, unsigned t) { (void)b; (void)t; return 1; }
|
||||||
static QueueHandle_t xQueueCreateStatic(unsigned n, size_t s, uint8_t *b, StaticQueue_t *q)
|
static QueueHandle_t xQueueCreateStatic(unsigned n, size_t s, uint8_t *b, StaticQueue_t *q)
|
||||||
{ (void)n; (void)b; q->size = s; return q; }
|
{ (void)b; q->size = s; q->capacity = n; assert(n*s <= sizeof(q->bytes)); return q; }
|
||||||
static int xQueueSend(QueueHandle_t q, const void *p, unsigned t)
|
static int xQueueSend(QueueHandle_t q, const void *p, unsigned t)
|
||||||
{ (void)t; if (queue_full) return 0; assert(!q->count); memcpy(q->bytes,p,q->size); q->count=1; return 1; }
|
{ (void)t; if (queue_full || q->count==q->capacity) return 0;
|
||||||
|
memcpy(q->bytes+q->count*q->size,p,q->size); ++q->count; return 1; }
|
||||||
static int xQueueReceive(QueueHandle_t q, void *p, unsigned t)
|
static int xQueueReceive(QueueHandle_t q, void *p, unsigned t)
|
||||||
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size); q->count=0; return 1; }
|
{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size);
|
||||||
|
--q->count; memmove(q->bytes,q->bytes+q->size,q->count*q->size); return 1; }
|
||||||
static SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *s) { return s; }
|
static SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *s) { return s; }
|
||||||
static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t)
|
static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t)
|
||||||
{ assert(!lock_depth); if (t && !*s) { ticks+=t; if (prompt_hook) prompt_hook(); }
|
{ assert(!lock_depth); if (t && !*s) { ticks+=t; if (prompt_hook) prompt_hook(); }
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Actual canonical stop/reboot handlers with deterministic side-effect doubles."""
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
def function(path, name):
|
||||||
|
source = path.read_text()
|
||||||
|
start = source.index('static int ' + name + '(')
|
||||||
|
return source[start:source.index('\n}', start) + 2]
|
||||||
|
|
||||||
|
header = '\n'.join(line for line in (ROOT / 'src/admin_ssh_console.h').read_text().splitlines()
|
||||||
|
if not line.startswith(('#include', '#pragma once')))
|
||||||
|
prelude = r'''
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
typedef int esp_err_t;
|
||||||
|
enum { ESP_OK=0, ESP_FAIL=-1, ESP_ERR_TIMEOUT=7 };
|
||||||
|
typedef struct { int unused; } user_principal_t;
|
||||||
|
'''
|
||||||
|
fakes = r'''
|
||||||
|
static bool remote, web;
|
||||||
|
static unsigned stops, reboots, scheduled, waits, rotations;
|
||||||
|
static esp_err_t schedule_result, stop_result;
|
||||||
|
static admin_ssh_deferred_action_type_t last_action;
|
||||||
|
bool admin_ssh_console_dispatch_is_remote(void) { return remote; }
|
||||||
|
bool admin_ssh_console_dispatch_is_web(void) { return remote && web; }
|
||||||
|
esp_err_t admin_ssh_console_dispatch_defer(admin_ssh_deferred_action_type_t action, uint32_t argument) {
|
||||||
|
assert(remote && !argument); ++scheduled; last_action=action; return schedule_result;
|
||||||
|
}
|
||||||
|
static const char *esp_err_to_name(esp_err_t error) { (void)error; return "fake"; }
|
||||||
|
static esp_err_t web_server_stop(void) { ++stops; return stop_result; }
|
||||||
|
static esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
|
||||||
|
static esp_err_t web_server_clear_counters(void) { assert(false); return ESP_FAIL; }
|
||||||
|
static esp_err_t web_serial_transport_clear_counters(void) { assert(false); return ESP_FAIL; }
|
||||||
|
static void esp_restart(void) { ++reboots; }
|
||||||
|
static void vTaskDelay(unsigned delay) { assert(delay==100); ++waits; }
|
||||||
|
#define pdMS_TO_TICKS(ms) (ms)
|
||||||
|
static void print_usage(void) { assert(false); }
|
||||||
|
static int show_status(void) { assert(false); return 1; }
|
||||||
|
static int show_counters(void) { assert(false); return 1; }
|
||||||
|
static int show_credentials(void) { assert(false); return 1; }
|
||||||
|
static int show_certificate(void) { assert(false); return 1; }
|
||||||
|
static int rotate_credentials(void) { assert(false); return 1; }
|
||||||
|
static int rotate_certificate(void) { ++rotations; return 0; }
|
||||||
|
static int reset_material(void) { assert(false); return 1; }
|
||||||
|
static bool force_is_present(int argc, char **argv, int expected) {
|
||||||
|
return argc == expected && !strcmp(argv[expected - 1], "--force");
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
tests = r'''
|
||||||
|
int main(void) {
|
||||||
|
char *stop[]={"web", "stop"};
|
||||||
|
remote=web=true;
|
||||||
|
assert(command_web(2,stop)==0 && scheduled==1 && !stops && last_action==ADMIN_CONSOLE_DEFER_WEB_STOP);
|
||||||
|
schedule_result=ESP_ERR_TIMEOUT;
|
||||||
|
assert(command_web(2,stop)==1 && scheduled==2 && !stops);
|
||||||
|
schedule_result=ESP_OK;
|
||||||
|
web=false; /* SSH preserves its synchronous HTTPS path. */
|
||||||
|
assert(command_web(2,stop)==0 && stops==1 && scheduled==2);
|
||||||
|
remote=false;
|
||||||
|
assert(command_web(2,stop)==0 && stops==2 && scheduled==2);
|
||||||
|
stop_result=ESP_FAIL;
|
||||||
|
assert(command_web(2,stop)==1 && stops==3);
|
||||||
|
remote=true;
|
||||||
|
assert(command_reboot(1,NULL)==0 && scheduled==3 && !reboots && last_action==ADMIN_SSH_DEFER_REBOOT);
|
||||||
|
web=true;
|
||||||
|
assert(command_reboot(1,NULL)==0 && scheduled==4 && !reboots && last_action==ADMIN_SSH_DEFER_REBOOT);
|
||||||
|
schedule_result=ESP_FAIL;
|
||||||
|
assert(command_reboot(1,NULL)==1 && scheduled==5 && !reboots);
|
||||||
|
assert(command_reboot(2,NULL)==1 && scheduled==5 && !reboots);
|
||||||
|
remote=false;
|
||||||
|
assert(command_reboot(1,NULL)==0 && reboots==1 && waits==1 && scheduled==5);
|
||||||
|
char *rotate[]={"web", "certificate", "rotate", "--force", "extra"};
|
||||||
|
remote=web=true; schedule_result=ESP_OK;
|
||||||
|
assert(command_web(4,rotate)==0 && scheduled==6 && !rotations &&
|
||||||
|
last_action==ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE);
|
||||||
|
schedule_result=ESP_ERR_TIMEOUT;
|
||||||
|
assert(command_web(4,rotate)==1 && scheduled==7 && !rotations);
|
||||||
|
assert(command_web(3,rotate)==1 && scheduled==7 && !rotations);
|
||||||
|
assert(command_web(5,rotate)==1 && scheduled==7 && !rotations);
|
||||||
|
web=false;
|
||||||
|
assert(command_web(4,rotate)==0 && rotations==1 && scheduled==7);
|
||||||
|
remote=false;
|
||||||
|
assert(command_web(4,rotate)==0 && rotations==2 && scheduled==7);
|
||||||
|
puts("PASS: canonical WEB stop/certificate deferred, exact force required, SSH/UART unchanged, reboot and queue failure isolation");
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
with tempfile.TemporaryDirectory(prefix='console-lifecycle-') as directory:
|
||||||
|
tmp = Path(directory)
|
||||||
|
(tmp / 'test.c').write_text(prelude + header + fakes +
|
||||||
|
function(ROOT / 'src/web_console.c', 'command_web') +
|
||||||
|
function(ROOT / 'src/system_console.c', 'command_reboot') + tests)
|
||||||
|
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror',
|
||||||
|
str(tmp / 'test.c'), '-o', str(tmp / 'test')], check=True, timeout=30)
|
||||||
|
subprocess.run([str(tmp / 'test')], check=True, timeout=10)
|
||||||
@@ -27,6 +27,14 @@ with tempfile.TemporaryDirectory(prefix="admin-console-boundary-") as directory:
|
|||||||
"-g", str(path / "test.c"), parser,
|
"-g", str(path / "test.c"), parser,
|
||||||
"-o", str(path / "test")], check=True, timeout=30)
|
"-o", str(path / "test")], check=True, timeout=30)
|
||||||
subprocess.run([str(path / "test")], check=True, timeout=10)
|
subprocess.run([str(path / "test")], check=True, timeout=10)
|
||||||
|
unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text()
|
||||||
|
+ strip_includes(header) + "\n" + strip_includes(source)
|
||||||
|
+ (ROOT / "tests/admin_console_boundary/certificate.c").read_text())
|
||||||
|
(path / "certificate.c").write_text(unit)
|
||||||
|
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
|
||||||
|
"-g", str(path / "certificate.c"), parser,
|
||||||
|
"-o", str(path / "certificate")], check=True, timeout=30)
|
||||||
|
subprocess.run([str(path / "certificate")], check=True, timeout=10)
|
||||||
ssh = (ROOT / "src/ssh_transport.c").read_text()
|
ssh = (ROOT / "src/ssh_transport.c").read_text()
|
||||||
adapter = ssh[ssh.index("static admin_ssh_console_token_t admin_console_token("):
|
adapter = ssh[ssh.index("static admin_ssh_console_token_t admin_console_token("):
|
||||||
ssh.index("static void *ssh_malloc(")]
|
ssh.index("static void *ssh_malloc(")]
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ static void test_shared_admission(void)
|
|||||||
assert(s_sessions[0].owner==&owner && s_sessions[1].owner==&second_owner);
|
assert(s_sessions[0].owner==&owner && s_sessions[1].owner==&second_owner);
|
||||||
unsigned before=runs;
|
unsigned before=runs;
|
||||||
clear_output(&web);
|
clear_output(&web);
|
||||||
feed(&web,"\"web\" \"stop\"\r"); pump(worker_task);
|
feed(&web,"\"web\" \"reset\" --force\r"); pump(worker_task);
|
||||||
assert(runs==before && !s_control_queue->count);
|
assert(runs==before && !s_control_queue->count);
|
||||||
uint8_t diagnostic[512]={0}; size_t received=0;
|
uint8_t diagnostic[512]={0}; size_t received=0;
|
||||||
assert(admin_ssh_console_read_output(&web,diagnostic,sizeof(diagnostic)-1,&received)==ESP_OK);
|
assert(admin_ssh_console_read_output(&web,diagnostic,sizeof(diagnostic)-1,&received)==ESP_OK);
|
||||||
@@ -235,12 +235,21 @@ int main(void)
|
|||||||
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); prompt_hook=NULL;
|
secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); prompt_hook=NULL;
|
||||||
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK);
|
||||||
setup_dispatch(); clear_output(&a);
|
setup_dispatch(); clear_output(&a);
|
||||||
|
assert(!admin_ssh_console_dispatch_is_web());
|
||||||
|
s_dispatch_token.transport = ADMIN_CONSOLE_TRANSPORT_WEB;
|
||||||
|
assert(admin_ssh_console_dispatch_is_web());
|
||||||
|
s_dispatch_remote = false; assert(!admin_ssh_console_dispatch_is_web());
|
||||||
|
s_dispatch_remote = true; s_dispatch_token = a;
|
||||||
|
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_STOP,0)==ESP_ERR_NOT_SUPPORTED);
|
||||||
|
assert(admin_ssh_console_dispatch_defer((admin_ssh_deferred_action_type_t)32,0)==ESP_ERR_NOT_SUPPORTED);
|
||||||
assert(admin_ssh_console_dispatch_defer(ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_SUPPORTED);
|
assert(admin_ssh_console_dispatch_defer(ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_SUPPORTED);
|
||||||
assert(!s_sessions[0].deferred_action_pending);
|
assert(!s_sessions[0].deferred_action_pending);
|
||||||
queue_full=true;
|
queue_full=true;
|
||||||
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_ERR_TIMEOUT);
|
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_ERR_TIMEOUT);
|
||||||
assert(!s_sessions[0].deferred_action_pending); queue_full=false;
|
assert(!s_sessions[0].deferred_action_pending); queue_full=false;
|
||||||
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
|
assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK);
|
||||||
|
admin_ssh_console_session_snapshot_t pending;
|
||||||
|
assert(admin_ssh_console_get_session_snapshot(&a, &pending)==ESP_OK && pending.deferred_action_pending);
|
||||||
assert(!admin_ssh_console_feed_input(&a,(const uint8_t *)"x",1,&n) && n==0);
|
assert(!admin_ssh_console_feed_input(&a,(const uint8_t *)"x",1,&n) && n==0);
|
||||||
s_sessions[0].command_pending=false; owner_drained=false; ticks=0;
|
s_sessions[0].command_pending=false; owner_drained=false; ticks=0;
|
||||||
pump(control_task); assert(ticks==10000 && actions==0);
|
pump(control_task); assert(ticks==10000 && actions==0);
|
||||||
|
|||||||
@@ -55,17 +55,24 @@ int main(void) {
|
|||||||
const char *web_allowed[] = {
|
const char *web_allowed[] = {
|
||||||
"", " ", "help", "memory", "exit", "user", "user status", "user list",
|
"", " ", "help", "memory", "exit", "user", "user status", "user list",
|
||||||
"user show admin", "\"user\" \"show\" \"bootstrap\"",
|
"user show admin", "\"user\" \"show\" \"bootstrap\"",
|
||||||
"web status", "wifi status", "mdns status", "\"web\" \"status\"",
|
"web status", "web stop", "reboot", "\"reboot\"", "\"web\" \"stop\"",
|
||||||
|
"wifi status", "mdns status", "\"web\" \"status\"",
|
||||||
|
"web certificate rotate --force",
|
||||||
|
" \"web\" \"certificate\" \"rotate\" \"--force\" ",
|
||||||
"ssh status", "ssh sessions", "ssh counters", "ssh host-key info", "ssh start",
|
"ssh status", "ssh sessions", "ssh counters", "ssh host-key info", "ssh start",
|
||||||
};
|
};
|
||||||
const char *web_denied[] = {
|
const char *web_denied[] = {
|
||||||
"web", "web help", "web start", "web stop", "web counters", "web clear-counters",
|
"web", "web help", "web start", "web stop extra", "web counters", "web clear-counters",
|
||||||
"web credentials show", "web credentials rotate --force", "web certificate info",
|
"web credentials show", "web credentials rotate --force", "web certificate info",
|
||||||
"web certificate rotate --force", "web reset --force", "web status extra",
|
"web certificate rotate", "web certificate rotate --force extra",
|
||||||
|
"web certificate rotate --force --force", "web certificate rotate --Force",
|
||||||
|
"web certificate rotate --forcex", "web certificate --force rotate",
|
||||||
|
"\"web\" \"certificate\" \"rotate\" \"--force extra\"",
|
||||||
|
"web reset --force", "web status extra",
|
||||||
"wifi", "wifi profiles", "wifi scan", "wifi start", "wifi stop", "wifi save",
|
"wifi", "wifi profiles", "wifi scan", "wifi start", "wifi stop", "wifi save",
|
||||||
"wifi load", "wifi defaults", "wifi reset", "wifi ping example.org",
|
"wifi load", "wifi defaults", "wifi reset", "wifi ping example.org",
|
||||||
"mdns", "mdns suffix test", "mdns save", "mdns load", "mdns defaults", "mdns reset",
|
"mdns", "mdns suffix test", "mdns save", "mdns load", "mdns defaults", "mdns reset",
|
||||||
"reboot", "reboot --force", "user bootstrap", "user recover --force",
|
"reboot --force", "user bootstrap", "user recover --force",
|
||||||
"user add other admin --generate", "user delete other --force",
|
"user add other admin --generate", "user delete other --force",
|
||||||
"user role other user --force", "user password admin --generate",
|
"user role other user --force", "user password admin --generate",
|
||||||
"user password other", "user key add admin", "user key clear admin --force",
|
"user password other", "user key add admin", "user key clear admin --force",
|
||||||
@@ -73,7 +80,7 @@ int main(void) {
|
|||||||
"ssh stop", "ssh disconnect 7", "ssh host-key rotate --force", "ssh reset --force",
|
"ssh stop", "ssh disconnect 7", "ssh host-key rotate --force", "ssh reset --force",
|
||||||
" \"user\" \"password\" \"admin\" \"--generate\"",
|
" \"user\" \"password\" \"admin\" \"--generate\"",
|
||||||
"\"web\" \"credentials\" \"show\"", "\"wifi\" \"stop\"",
|
"\"web\" \"credentials\" \"show\"", "\"wifi\" \"stop\"",
|
||||||
"\"mdns\" \"reset\"", "\"reboot\"", "\"ssh\" \"stop\"",
|
"\"mdns\" \"reset\"", "\"reboot\" extra", "\"ssh\" \"stop\"",
|
||||||
"\"ssh\" \"host-key\" \"rotate\" --force", "\"user\" \"recover\" --force",
|
"\"ssh\" \"host-key\" \"rotate\" --force", "\"user\" \"recover\" --force",
|
||||||
};
|
};
|
||||||
for (size_t i=0; i<sizeof(web_allowed)/sizeof(web_allowed[0]); ++i) {
|
for (size_t i=0; i<sizeof(web_allowed)/sizeof(web_allowed[0]); ++i) {
|
||||||
|
|||||||
@@ -17,7 +17,15 @@ transport functions are not copied or reimplemented. Temporary output is removed
|
|||||||
production entry points and inspects private state for lifecycle/wipe assertions.
|
production entry points and inspects private state for lifecycle/wipe assertions.
|
||||||
No firmware build, network access or device operation is performed.
|
No firmware build, network access or device operation is performed.
|
||||||
|
|
||||||
## Results recorded 2026-09-06
|
## Latest reported results — 8D.7 second certificate slice
|
||||||
|
|
||||||
|
Implementer reports `run.py --tickets` PASS **25 transport / 12 ticket groups**, including certificate owner routing, currentness rejection and commit → stop → start short-circuit/error behavior. The owner's `dispatcher_actions` mask selects the existing 12 KiB dispatcher, not the 4 KiB control task. `tests/admin_console_boundary/run.py` (including `certificate.c`) separately covers typed deferred handoff/pending gate/executing reservation; `lifecycle.py` covers canonical handlers and unchanged SSH/UART0 behavior. Policy, server lifecycle **11**, cookie `--admin` and store `--serial` also pass as reported. Independent review has no actionable findings; sanitizer validation is unavailable due to missing libasan/libubsan. No hardware validation is claimed or performed by this documentation update.
|
||||||
|
|
||||||
|
Current WEB policy allows exact parsed `web status`, `web stop`, `web certificate rotate --force`, and `reboot`/self-close; other web forms, account mutations, network mutations and restricted SSH lifecycle/key mutations remain blocked. Certificate drain/acknowledgement bounds do not bound queued execution or prove browser receipt. See `docs/phase8d7_implementation.md` for final parent build/resources, trust/relogin/failure checklist and authorized next bounded slice; M2 acceptance remains pending.
|
||||||
|
|
||||||
|
## Earlier results recorded 2026-09-06
|
||||||
|
|
||||||
|
8D.7 first slice: `run.py --tickets` passes **23 transport / 12 ticket groups**. Adds WEB stop/reboot owner routing, stale/revoked action rejection and stop-error propagation, pending-input discard before poll and cancellation-during-receive with/without an occupied RX buffer. `python3 tests/admin_console_boundary/lifecycle.py` separately checks the production canonical handlers and unchanged SSH/UART0 behavior. Dependencies remain doubled; no target stop/reboot is executed. See `docs/phase8d7_implementation.md` for scope and pending validation.
|
||||||
|
|
||||||
Final continuation: `run.py --tickets` passes **19 transport / 12 ticket groups**,
|
Final continuation: `run.py --tickets` passes **19 transport / 12 ticket groups**,
|
||||||
including the HTTPD-owned shutdown retry/reuse regression. `server_lifecycle.py`
|
including the HTTPD-owned shutdown retry/reuse regression. `server_lifecycle.py`
|
||||||
@@ -59,7 +67,8 @@ After the production empty-frame, input-deadline and timer-generation fixes:
|
|||||||
- At most one outstanding transport poll; byte-preserving input, partial input
|
- At most one outstanding transport poll; byte-preserving input, partial input
|
||||||
consumption/retry, consumed-input wiping, output delivery and TX wiping.
|
consumption/retry, consumed-input wiping, output delivery and TX wiping.
|
||||||
- Nonfinal/text/oversized frames and another frame while RX is occupied fail
|
- Nonfinal/text/oversized frames and another frame while RX is occupied fail
|
||||||
closed; stalled input closes after the five-second deadline. Pending bytes are
|
closed outside deferral; during observed deferral bounded input is discarded
|
||||||
|
without cancelling the scheduled action. Stalled input closes after the five-second deadline. Pending bytes are
|
||||||
not fed at or after the deadline even if the console can now consume them.
|
not fed at or after the deadline even if the console can now consume them.
|
||||||
- Session/account notification isolation, idle currentness failure, invalidation
|
- Session/account notification isolation, idle currentness failure, invalidation
|
||||||
during currentness checking and between output consumption and send. Notifier
|
during currentness checking and between output consumption and send. Notifier
|
||||||
|
|||||||
@@ -4,6 +4,19 @@ static int server_storage;
|
|||||||
#define SERVER ((void *)&server_storage)
|
#define SERVER ((void *)&server_storage)
|
||||||
static bool httpd_owner, alloc_fail, timer_fail, auth_allowed, session_current;
|
static bool httpd_owner, alloc_fail, timer_fail, auth_allowed, session_current;
|
||||||
static bool console_live, console_full, queue_fail, send_fail, upgrade_fail;
|
static bool console_live, console_full, queue_fail, send_fail, upgrade_fail;
|
||||||
|
static bool deferred_pending, cancel_on_receive;
|
||||||
|
static unsigned reboots, web_stops;
|
||||||
|
static esp_err_t web_stop_result, rotate_result, web_start_result;
|
||||||
|
static unsigned rotations, web_starts;
|
||||||
|
static esp_err_t web_security_rotate_certificate(void) {
|
||||||
|
OUTSIDE(); assert(!httpd_owner && !web_stops && !web_starts); ++rotations; return rotate_result;
|
||||||
|
}
|
||||||
|
static esp_err_t web_server_start(void) {
|
||||||
|
OUTSIDE(); assert(!httpd_owner && rotations && web_stops == 1 && web_stop_result == ESP_OK);
|
||||||
|
++web_starts; return web_start_result;
|
||||||
|
}
|
||||||
|
static void esp_restart(void) { OUTSIDE(); assert(!httpd_owner); ++reboots; }
|
||||||
|
esp_err_t web_server_stop(void) { OUTSIDE(); assert(!httpd_owner); ++web_stops; return web_stop_result; }
|
||||||
static bool ticket_live, upgrade_requested, revoke_on_open, revoke_on_send;
|
static bool ticket_live, upgrade_requested, revoke_on_open, revoke_on_send;
|
||||||
static unsigned upgrades, closes, sends, queues, wipes, checks, receive_headers;
|
static unsigned upgrades, closes, sends, queues, wipes, checks, receive_headers;
|
||||||
static size_t feed_limit, fed_length, output_length;
|
static size_t feed_limit, fed_length, output_length;
|
||||||
@@ -93,7 +106,8 @@ esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *t, uint
|
|||||||
}
|
}
|
||||||
esp_err_t admin_ssh_console_get_session_snapshot(const admin_ssh_console_token_t *t,
|
esp_err_t admin_ssh_console_get_session_snapshot(const admin_ssh_console_token_t *t,
|
||||||
admin_ssh_console_session_snapshot_t *s) {
|
admin_ssh_console_session_snapshot_t *s) {
|
||||||
OUTSIDE(); assert(t); *s = (admin_ssh_console_session_snapshot_t){.active = console_live}; return ESP_OK;
|
OUTSIDE(); assert(t); *s = (admin_ssh_console_session_snapshot_t){.active = console_live,
|
||||||
|
.deferred_action_pending = deferred_pending}; return ESP_OK;
|
||||||
}
|
}
|
||||||
static esp_err_t httpd_queue_work(httpd_handle_t h, void (*fn)(void *), void *arg) {
|
static esp_err_t httpd_queue_work(httpd_handle_t h, void (*fn)(void *), void *arg) {
|
||||||
OUTSIDE(); assert(h == SERVER); ++queues;
|
OUTSIDE(); assert(h == SERVER); ++queues;
|
||||||
@@ -122,7 +136,9 @@ static esp_err_t httpd_ws_recv_frame(httpd_req_t *r, httpd_ws_frame_t *f, size_t
|
|||||||
if (f->len == 0) { ++receive_headers; f->len = incoming.len; f->final = incoming.final; f->type = incoming.type; }
|
if (f->len == 0) { ++receive_headers; f->len = incoming.len; f->final = incoming.final; f->type = incoming.type; }
|
||||||
if (!capacity || !f->len) return ESP_OK;
|
if (!capacity || !f->len) return ESP_OK;
|
||||||
if (f->len > capacity) return ESP_ERR_INVALID_ARG;
|
if (f->len > capacity) return ESP_ERR_INVALID_ARG;
|
||||||
memcpy(f->payload, incoming.payload, f->len); return ESP_OK;
|
memcpy(f->payload, incoming.payload, f->len);
|
||||||
|
if (cancel_on_receive) deferred_pending = false;
|
||||||
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
static esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s) { io(); (void)r; snprintf(status, sizeof(status), "%s", s); return ESP_OK; }
|
static esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s) { io(); (void)r; snprintf(status, sizeof(status), "%s", s); return ESP_OK; }
|
||||||
static esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s) { io(); (void)r; assert(!strcmp(s, "application/json")); return ESP_OK; }
|
static esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s) { io(); (void)r; assert(!strcmp(s, "application/json")); return ESP_OK; }
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ static void reset(void) {
|
|||||||
httpd_owner = true; alloc_fail = timer_fail = false;
|
httpd_owner = true; alloc_fail = timer_fail = false;
|
||||||
auth_allowed = session_current = upgrade_requested = true;
|
auth_allowed = session_current = upgrade_requested = true;
|
||||||
console_live = console_full = queue_fail = send_fail = upgrade_fail = shutdown_fail = false;
|
console_live = console_full = queue_fail = send_fail = upgrade_fail = shutdown_fail = false;
|
||||||
ticket_live = revoke_on_open = revoke_on_send = false;
|
ticket_live = revoke_on_open = revoke_on_send = deferred_pending = false;
|
||||||
|
reboots = web_stops = rotations = web_starts = 0;
|
||||||
|
web_stop_result = rotate_result = web_start_result = ESP_OK; cancel_on_receive = false;
|
||||||
upgrades = closes = sends = queues = wipes = checks = receive_headers = 0;
|
upgrades = closes = sends = queues = wipes = checks = receive_headers = 0;
|
||||||
feed_limit = SIZE_MAX; fed_length = output_length = sent_length = 0;
|
feed_limit = SIZE_MAX; fed_length = output_length = sent_length = 0;
|
||||||
memset(fed, 0, sizeof(fed)); memset(output, 0, sizeof(output)); memset(sent, 0, sizeof(sent));
|
memset(fed, 0, sizeof(fed)); memset(output, 0, sizeof(output)); memset(sent, 0, sizeof(sent));
|
||||||
@@ -213,7 +215,7 @@ int main(void) {
|
|||||||
ok("submission fence timeout/retry and stopped retirement of unexecuted callback");
|
ok("submission fence timeout/retry and stopped retirement of unexecuted callback");
|
||||||
|
|
||||||
reset(); start(); admit();
|
reset(); start(); admit();
|
||||||
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_NOT_SUPPORTED);
|
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_STOP, 0) == ESP_ERR_NOT_SUPPORTED);
|
||||||
assert(console_live && !s_slot.close_requested);
|
assert(console_live && !s_slot.close_requested);
|
||||||
s_slot.sending = true; assert(!owner_drained(&s_slot.token)); s_slot.sending = false;
|
s_slot.sending = true; assert(!owner_drained(&s_slot.token)); s_slot.sending = false;
|
||||||
assert(owner_drained(&s_slot.token)); httpd_owner = false;
|
assert(owner_drained(&s_slot.token)); httpd_owner = false;
|
||||||
@@ -221,6 +223,86 @@ int main(void) {
|
|||||||
assert(!console_live && !closes); httpd_owner = true;
|
assert(!console_live && !closes); httpd_owner = true;
|
||||||
ok("unsupported deferred action has no side effects; self-close notifier and drain guard");
|
ok("unsupported deferred action has no side effects; self-close notifier and drain guard");
|
||||||
|
|
||||||
|
reset(); start(); admit(); httpd_owner = false;
|
||||||
|
assert(s_owner.supported_actions & (1U << ADMIN_SSH_DEFER_REBOOT));
|
||||||
|
assert(s_owner.supported_actions & (1U << ADMIN_CONSOLE_DEFER_WEB_STOP));
|
||||||
|
admin_ssh_console_token_t stale = s_slot.token; ++stale.slot_generation;
|
||||||
|
assert(owner_perform(&stale, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_NOT_FOUND && !reboots);
|
||||||
|
session_current = false;
|
||||||
|
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_ERR_NOT_FOUND && !web_stops);
|
||||||
|
session_current = true; check_hook = revoke_check;
|
||||||
|
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_ERR_NOT_FOUND && !reboots);
|
||||||
|
httpd_owner = true;
|
||||||
|
ok("deferred lifecycle rechecks current session and generation after drain/delay; revoked work cannot act");
|
||||||
|
|
||||||
|
reset(); start(); admit(); httpd_owner = false;
|
||||||
|
web_stop_result = ESP_ERR_TIMEOUT;
|
||||||
|
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_ERR_TIMEOUT);
|
||||||
|
assert(web_stops == 1 && !reboots && !closes);
|
||||||
|
web_stop_result = ESP_OK;
|
||||||
|
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_STOP, 0) == ESP_OK && web_stops == 2);
|
||||||
|
assert(owner_perform(&s_slot.token, ADMIN_SSH_DEFER_REBOOT, 0) == ESP_OK && reboots == 1);
|
||||||
|
httpd_owner = true;
|
||||||
|
ok("HTTPS stop/reboot marshal to lifecycle APIs outside HTTPD/locks; stop failure propagates");
|
||||||
|
|
||||||
|
for (unsigned failure=0; failure<4; ++failure) {
|
||||||
|
reset(); start(); admit(); httpd_owner=false;
|
||||||
|
assert(s_owner.dispatcher_actions == (1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE));
|
||||||
|
assert(s_owner.supported_actions & s_owner.dispatcher_actions);
|
||||||
|
if (failure==1) rotate_result=ESP_FAIL;
|
||||||
|
if (failure==2) web_stop_result=ESP_ERR_TIMEOUT;
|
||||||
|
if (failure==3) web_start_result=ESP_ERR_NO_MEM;
|
||||||
|
esp_err_t expected[]={ESP_OK, ESP_FAIL, ESP_ERR_TIMEOUT, ESP_ERR_NO_MEM};
|
||||||
|
assert(owner_perform(&s_slot.token, ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE, 0)==expected[failure]);
|
||||||
|
assert(rotations==1 && web_stops==(failure!=1) && web_starts==(failure==0 || failure==3));
|
||||||
|
assert(!closes && !sends);
|
||||||
|
}
|
||||||
|
ok("certificate dispatcher mask, transactional API then stop/start, first-error propagation and no IO");
|
||||||
|
|
||||||
|
for (unsigned invalid=0; invalid<5; ++invalid) {
|
||||||
|
reset(); start(); admit(); httpd_owner=false;
|
||||||
|
admin_ssh_console_token_t token=s_slot.token;
|
||||||
|
if (invalid==0) ++token.slot_generation;
|
||||||
|
if (invalid==1) session_current=false;
|
||||||
|
if (invalid==2) ++auth_view.principal.auth_generation;
|
||||||
|
if (invalid==3) check_hook=revoke_check;
|
||||||
|
if (invalid==4) ++s_slot.session;
|
||||||
|
assert(owner_perform(&token, ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE, 0)==ESP_ERR_NOT_FOUND);
|
||||||
|
assert(!rotations && !web_stops && !web_starts && !closes);
|
||||||
|
}
|
||||||
|
ok("certificate rejects stale token, revoked cookie/principal, revocation during check and replaced session");
|
||||||
|
|
||||||
|
reset(); start(); admit(); incoming.payload = bytes; incoming.len = sizeof(bytes);
|
||||||
|
feed_limit = 0;
|
||||||
|
assert(frame_handler(&request) == ESP_OK && s_payload->rx_length);
|
||||||
|
deferred_pending = true;
|
||||||
|
/* A second frame can beat the next poll after the command becomes deferred. */
|
||||||
|
assert(frame_handler(&request) == ESP_OK && console_live && !s_slot.close_requested);
|
||||||
|
tick(); work();
|
||||||
|
assert(!fed_length && !s_payload->rx_length && zeroed(s_payload->rx, sizeof(s_payload->rx)));
|
||||||
|
assert(console_live && !s_slot.close_requested);
|
||||||
|
assert(frame_handler(&request) == ESP_OK && !s_payload->rx_length && !fed_length);
|
||||||
|
deferred_pending = false;
|
||||||
|
assert(frame_handler(&request) == ESP_OK && s_payload->rx_length); /* Held input. */
|
||||||
|
deferred_pending = true; tick(); work(); /* Poll also discards independently. */
|
||||||
|
assert(!s_payload->rx_length && zeroed(s_payload->rx, sizeof(s_payload->rx)));
|
||||||
|
deferred_pending = false; feed_limit = SIZE_MAX; tick(); work();
|
||||||
|
assert(!fed_length); /* Failed/cancelled deferred work must not replay held input. */
|
||||||
|
assert(frame_handler(&request) == ESP_OK && fed_length == sizeof(bytes));
|
||||||
|
ok("pending actions discard buffered/new input, preserve drain, and never replay it on cancellation");
|
||||||
|
|
||||||
|
for (unsigned buffered = 0; buffered < 2; ++buffered) {
|
||||||
|
reset(); start(); admit(); incoming.payload = bytes; incoming.len = sizeof(bytes);
|
||||||
|
if (buffered) {
|
||||||
|
feed_limit = 0; assert(frame_handler(&request) == ESP_OK && s_payload->rx_length);
|
||||||
|
}
|
||||||
|
feed_limit = SIZE_MAX; deferred_pending = cancel_on_receive = true;
|
||||||
|
assert(frame_handler(&request) == ESP_OK && !deferred_pending && !fed_length);
|
||||||
|
assert(!s_payload->rx_length && zeroed(s_payload->rx, sizeof(s_payload->rx)));
|
||||||
|
assert(console_live && !s_slot.close_requested);
|
||||||
|
}
|
||||||
|
ok("deferral observed before payload read stays discarded when cancellation races receive, with/without buffered tail");
|
||||||
|
|
||||||
reset(); start(); admit(); incoming.len = 0;
|
reset(); start(); admit(); incoming.len = 0;
|
||||||
assert(frame_handler(&request) == ESP_OK);
|
assert(frame_handler(&request) == ESP_OK);
|
||||||
assert(receive_headers == 1 && !fed_length && console_live && !s_slot.close_requested);
|
assert(receive_headers == 1 && !fed_length && console_live && !s_slot.close_requested);
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ static void (*timer_poll)(void *), (*pending_poll)(void *);
|
|||||||
static void *pending_argument;
|
static void *pending_argument;
|
||||||
static httpd_req_t connected;
|
static httpd_req_t connected;
|
||||||
static unsigned admin_closes;
|
static unsigned admin_closes;
|
||||||
|
/* These endpoint tests never dispatch lifecycle commands. */
|
||||||
|
void esp_restart(void) { assert(false); }
|
||||||
|
esp_err_t web_server_stop(void) { assert(false); return ESP_FAIL; }
|
||||||
|
esp_err_t web_server_start(void) { assert(false); return ESP_FAIL; }
|
||||||
|
esp_err_t web_security_rotate_certificate(void) { assert(false); return ESP_FAIL; }
|
||||||
void *heap_caps_calloc(size_t n, size_t size, unsigned caps) {
|
void *heap_caps_calloc(size_t n, size_t size, unsigned caps) {
|
||||||
assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); return calloc(n, size);
|
assert(caps == (MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); return calloc(n, size);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ esp_err_t httpd_ws_respond_server_handshake(httpd_req_t *, const char *);
|
|||||||
|
|
||||||
admin = "--admin" in sys.argv
|
admin = "--admin" in sys.argv
|
||||||
if admin:
|
if admin:
|
||||||
|
HEADERS["esp_system.h"] = "#pragma once\nvoid esp_restart(void);\n"
|
||||||
HEADERS["esp_heap_caps.h"] = """#pragma once
|
HEADERS["esp_heap_caps.h"] = """#pragma once
|
||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
#define MALLOC_CAP_SPIRAM 1
|
#define MALLOC_CAP_SPIRAM 1
|
||||||
|
|||||||
Reference in New Issue
Block a user