Add Typed SSH Service Controls

Provide admin-only SSH status plus generation-safe start, stop, and
single-session disconnect operations through the bounded dispatcher.
Include
Settings UI coverage, lifecycle safeguards, and host-side regression
tests.
This commit is contained in:
2026-09-13 16:07:04 +02:00
parent 7ccc8799e9
commit 737bd29f9e
25 changed files with 1049 additions and 47 deletions
+111
View File
@@ -0,0 +1,111 @@
# Phase 8D.19 — first service slice: SSH ordinary controls
Date: 2026-09-13. User-authorized implementation; **SSH slice implemented, host-tested and build-verified. Independent review complete with no confirmed actionable defects; target sign-off pending. Phase 8D.19 as a whole remains incomplete.**
Independent reviewer reran canonical SSH4, cookie SSH6+shared, dispatcher, lifecycle27, UI143+renderer/HTML/CSP and diff checks, all PASS. Parent final `pio run` confirmation PASS19.95s at100,404 B RAM/1,808,441 B flash; diff check PASS. Host doubles do not prove actual concurrent SSH cleanup, physical HTTPS/USB continuity or runtime margins.
## Scope decision made before editing
Read `docs/agent/{code-map,architecture,design-decisions,current-state}.md`, the exact 8D.19 row/work-unit rules, SSH public APIs/owner implementation, canonical `ssh_console` and parsed browser-shell restrictions. Initial worktree was clean. Baseline `pio run` passed in 6.87 s, 100,300 B linked RAM / 1,793,301 B flash.
The plan explicitly requires a service split when multiple owner adaptations are necessary. Selected one coherent, end-to-end service: **SSH status, SSH start, SSH stop, and disconnect exactly one SSH session**. SSH has one existing owner task and two fixed session slots; these operations do not terminate the invoking HTTPS session. Allowed source set: `ssh_transport.{c,h}`, narrow `web_ssh_settings.{c,h}`, `admin_ssh_console.{c,h}`, `web_server.c`, `web_ui.c`, and its CMake source entry. Related host tests and agent/phase contracts are part of this deliverable.
Explicit exclusions:
- No WebSocket/browser-admin/HTTPS-cookie disconnection, web-server stop/restart, or all-web/account revocation controls. An arbitrary broker ID is **not** a transport disconnect handle.
- No USB start/stop/disconnect; preserve DTR/attach ownership and native network-independent UART1 access.
- No new serial-service controls (already in 8D.9), Wi-Fi controls, reboot, certificate/SSH host-key rotation/reset, secret fetch, or 8D.20/8D.21 work.
- No shell-policy broadening. Canonical browser shell still rejects `ssh stop` and `ssh disconnect`: its SSH-specific deferral is not a WEB-owner action. Typed SSH requests have a separate, narrow current-admin admission boundary and reuse canonical SSH lifecycle/owner close semantics, not command strings.
- No new task, timer, generic runner, queue depth, stack size, socket/session capacity, dependencies, SDK/configuration or generated assets. No commit/upload/erase.
Remaining 8D.19: separately audit and select a web-transport slice for **other**, non-invoking sessions if useful. It must identify the precise originating login/transport, exclude invoking-session effects, and retain HTTPD-owner fd/epoch safety. USB actions are not promised: first establish whether its actual public ownership/API model permits any useful non-recovery-disrupting control. Do not expose generic broker disconnects or claim all-service parity. Self-affecting HTTPS/Wi-Fi actions remain 8D.20, identities 8D.21.
## HTTP contract and bounded dispatcher
Three optional exact handlers (36 total, previously 33):
| Route | Contract |
|---|---|
| `GET /api/settings/ssh` | Current cookie/admin, bodyless/queryless secret-free published status. |
| `POST /api/settings/ssh-operation` | Current cookie/admin + canonical Origin/CSRF JSON policy; admits exactly one typed action. |
| `GET /api/settings/ssh-operation` | Latest retained result for this login only; bodyless/queryless current-admin read. |
Snapshot is `{generation, running, transitioning, sessions}`. At most two rows `{id, state, route, name_hex, closing}`. `state`: 1 handshake, 2 active, 3 closing. `route`: 0 not yet routed, 1 broker serial, 2 admin console. Names are at most 16 bytes encoded as lowercase hex; empty until a principal is published. No socket fd, principal/account IDs, authentication material, keys, password, ticket or peer data is serialized. `transitioning` also gates incomplete cleanup; use canonical shell recovery rather than guessing that a failed cleanup is a clean stop.
Mutation body has **exactly** `{action, generation, target}`:
- `start`, `stop`: `target:0`, nonzero nonsaturated observed service generation.
- `disconnect`: nonzero SSH session ID, observed service generation. Not a broker client ID, account ID, fd, console slot or browser session ID.
- Strict decimal uint32 values; no unknown/duplicate fields, escapes, exponents/fractions, leading-zero integers, nesting or additional service/action selector. 256 bytes and four receives maximum.
HTTPD authenticates, parses and publishes one fixed login-bound slot, then submits **only a non-reused uint32 operation ID** with zero wait to the existing four-entry dispatcher queue. No HTTPD lifecycle work or owner wait. Pending slot/queue exhaustion rejects with 503; optional route allocation failure preserves other settings, authentication and both browser transports. Mutation is never registered without both SSH read endpoints; POST registration failure removes the result GET where possible, with failed unregister retaining reads only.
Dispatcher revalidates initiating session/principal/current admin and 30-second dequeue deadline before the conditional SSH API. It executes outside console/HTTPD/operation locks. A pending operation is not replaced by another login/tab. Terminal results are replaceable, not a durable history or idempotency key. Completed principal scratch is wiped; IDs saturate rather than wrap. There is no expiry timer: stale queued work occupies its single slot until the dispatcher dequeues it. Already admitted lifecycle/close work can finish after logout/revocation. Authentication stop/restart invalidates old initiating sessions; queued old IDs cannot act for a new login.
96-byte result `{id, action, state}` uses `idle`, `pending`, `ok`, `failed`, `cancelled`, `conflict`. Other logins see idle, never another login's result. Invalid current service/absent target gives conflict; owner errors/timeouts give failed. **Failed or lost acknowledgement is not proof of cancellation.** Successful disconnect means an owner-qualified close request was published, not that cleanup/peer closure or response delivery is complete. Start/stop use canonical lifecycle completion/error semantics.
## SSH owner/lifecycle safety
`ssh_transport_get_management_snapshot()` copies published rows and lifecycle state under the existing short portMUX; it does not call wolfSSH, scan task stacks, acquire the command mutex or inspect owner-task slots. It includes externally requested close intent even before owner consumption. It returns unavailable before successful initialization; optional SSH failure does not disable HTTPS.
`ssh_transport_manage_current()` acquires the existing command mutex **with zero wait**. It checks initialized/nontransitioning/clean state and service generation under the SSH lock. For disconnect it checks the active nonclosing published session and sets the existing per-slot `s_external_close_id` under that same lock. For lifecycle it retains the command mutex through comparison and canonical `request_running_locked()` admission/wait. CLI and host-key lifecycle wrappers already use this mutex. No snapshot-check-unlock-unconditional-stop race against another command-mutex caller.
- Separate service generation starts at 1, advances on every real canonical lifecycle admission, saturates at UINT32_MAX and survives counter clears. Stop/start ABA rejects old confirmation even if running state matches again. Transitions/timeouts stay fenced until the existing owner completes. Saturation disables typed controls but not canonical UART0 recovery.
- SSH IDs retain their existing two-slot-bit/generation encoding. `find_free_slot()` now retires a slot at `SSH_TRANSPORT_GENERATION_MAX`; accept increments rather than wraps. Existing cleanup preserves its generation. Stale published IDs/queued owner requests cannot close a replacement connection after disconnect/reuse or listener restart. Capacity remains two until exhaustion (over a billion admissions per slot), then that slot requires reboot; reboot invalidates browser sessions too.
- Existing `consume_external_close()` compares the exact owner slot session ID and preserves visible close intent while cleanup starts. Published snapshots can lag actual owner progress: disconnect may be accepted just as that connection disappears, but never intentionally closes a replacement. Normal close/revocation/task routing is unchanged.
- Start/stop change runtime service state, not NVS settings or SSH/HTTPS identity. Stop is explicitly **all SSH sessions**, including admissions after the displayed snapshot and before execution; session rows are not a frozen stop target set. A queued/executing SSH console command has its existing cancellation/currentness semantics; already executing work is not rolled back.
- Only SSH owner task touches wolfSSH/context/socket cleanup. No HTTPD callback is queued or awaited by this module. Snapshot has no blocking owner mutex; dispatcher does not hold console/HTTPD locks while waiting. This avoids a new HTTPD-task deadlock cycle. Existing bounded canonical owner wait remains (no hard task-scheduling guarantee); no peer-drain/receipt guarantee is introduced.
## UI contract
Admin-only **Settings → SSH**, using existing settings navigation, definition-list/form/action styles and native confirmation. No new contextual/quick entry or additional dependencies. Serial/Admin sockets continue draining; Settings never sends terminal input or changes the writer lease by navigation/selection. Explicit SSH disconnection/stop removes the affected SSH broker clients through normal owner cleanup; ordinary broker semantics handle an affected writer.
- Read/selection never mutates. Confirmation names exactly SSH/all SSH or one selected session and explains settings/identity preservation, independent HTTPS/Wi-Fi/USB/UART0 and already-executing-command limits.
- Explicit session selection captures ID, route/name and the original service generation. Refresh never rebases it. Absence, identity/version mismatch, transitioning state, close intent or failed/timed-out read latches stale and clears the visible selection. A later matching response cannot resurrect it; explicit reselection required.
- Captured request survives delayed session validation unchanged. Backend identity/version check remains authoritative for changes while the native confirmation is open.
- One request at a time, 15-second whole-request deadline including session validation/body processing, controller/work-generation/navigation fencing. No automatic polling or retry in this bounded slice: after acknowledgement use **Check Operation Result**, then **Refresh**. This is deliberately manual completion inspection, not the Broker controller's live refresh/automatic completion loop.
- Lost acknowledgement, replaced results, errors/timeouts and navigation preserve pending/unknown handling and prohibit automatic replay. Mutation remains gated until result inspection; refreshing alone never resolves pending uncertainty. Terminal result invalidates the snapshot, requiring Refresh before new controls.
- 8D.18 Broker clients/Active writer contexts and the single quick-settings host are unchanged. Existing UI regression suite remains authoritative for those contracts.
## Resource accounting and build
| Resource | Before → after |
|---|---|
| Linked RAM | 100,300 → **100,404 B** (**+104 B**) |
| Flash | 1,793,301 → **1,808,441 B** (**+15,140 B**) |
| HTTPD handlers | 33 → 36, three optional descriptors/URI copies + three table pointers |
| HTTPS sockets | 6 → 6; LRU still disabled |
| SSH slots / shared remote console slots | 2 / 2, unchanged |
| Dispatcher queue | four entries, unchanged item union size; added ID variant only |
| Tasks / stacks / firmware timers | unchanged |
| New operation storage | target object symbols:80 B slot +8 B lock +4 B next ID;4 B SSH service version (96 B before linker padding/placement; actual linked delta104 B) |
| HTTP request/result/snapshot buffers | 256 / 96 / 768 B fixed arrays, plus bounded published SSH-row/auth/local operation copies on existing stacks |
| New module dynamic allocation / PSRAM payload | none |
| HTTPD registration heap | bounded descriptor/name/table growth only; allocator overhead and runtime floors unmeasured |
Baseline pio 6.87 s; first integration pio 82.78 s (nonfatal existing SDK FATFS bool-default warnings during configuration); final production pio **35.31 s**, PASS at the sizes above. CPU160 independently verified in tracked defaults, active sdkconfig and generated sdkconfig.h; board marketing line still says 240MHz but is not the effective setting. Signed-off combined WebSocket binary send path, baud rates, capacities and transport buffers unchanged.
No hardware/real-browser geometry/accessibility/heap/stack measurements this phase. Linked-size deltas are not runtime reserve approval. Optional URI allocations and existing HTTPD/dispatcher/SSH stack high-water/floors need target measurement, especially given the previously recorded low internal/DMA minima.
## Validation actually run
- `python3 tests/ssh_management/run.py`: **4 groups**, exact production management/snapshot/canonical lifecycle admission/close-consumption/free-slot functions; deterministic locks/RTOS/owner completion, plus accept increment guard. Tests stale/disconnected/reused IDs, duplicate/other-slot isolation, mutex busy, start/stop and CLI ABA, timeout/incomplete cleanup, saturation and ordinary recovery. Not a real socket/handshake/scheduler test.
- `python3 tests/web_cookie_auth/run.py --ssh`: **6 SSH groups + shared policy/store tests**. Production handlers/parser/auth/store with SSH/dispatcher doubles: admin denial, Origin/CSRF/framing/receive limits, safe two-row projection, all actions, slot/result isolation, old IDs, queue error, stale principal/session/deadline, auth stop/restart, lost acknowledgement and ID exhaustion.
- `python3 tests/admin_console_boundary/run.py`: existing suites + new SSH zero-wait/not-ready/full/four-entry/FIFO ID routing, no command-string execution.
- `python3 tests/web_admin_transport/server_lifecycle.py`: **27 groups**, all six SSH staged descriptor/name failure positions, read-only failed-unregister residue, failed-stop ownership/restart, other-domain isolation; real lifecycle orchestration with dependency doubles.
- `python3 tests/web_ui_session/run.py`: **143 behavior groups** (8 new SSH), production C renderer/JS, six-view structural HTML/shared styles, security headers and exact loader CSP. New UI checks cover scoped/cancelled confirmations, all typed bodies, stale/reordered/absent identity, manual result flow, malformed snapshots, timeout/lost acknowledgement/replacement, delayed auth, navigation/expiry fences and both-terminal isolation. Not real browser/device validation.
- Broad sequential regression batch PASS: cookie `--broker`, `--display`, `--network`, `--accounts`, `--serial-settings`, `--settings`, `--admin`; `admin_ssh_policy/run.py`; `admin_console_boundary/{accounts.py,lifecycle.py}`; `session_broker_diagnostics/run.py`; `web_auth_parse/run.py`; `web_diagnostics/run.py`; `web_httpd_idle/run.py`; `web_login_ui/run.py`; `web_network_settings/run.py`; `web_security/run.py`; `web_serial_performance/run.py`; `web_session_store/run.py` and `--serial`; `web_admin_transport/run.py --tickets`.
- Target-object `readelf -sW` measured the slot/lock/version symbols listed above; these are compiler object sizes, not runtime stack/heap margins. Final focused SSH owner/cookie/lifecycle/UI suites rerun after integration/documentation, PASS.
- `git diff --check` PASS; production/registration/UI diff self-reviewed. Fresh worktree initially clean, no unrelated source edits. Test development corrected a lock-token lvalue/missing extracted constant in the new owner harness and one erroneous UI-fixture assertion; focused suites passed afterward. Formatting-only integration corrections preceded final build.
## Pending target/parent checks — no sign-off
1. Independently review this SSH-only contract and diff; do not mark all 8D.19 complete.
2. Compare Settings SSH status/session IDs/routes with UART0 `ssh status`/`ssh sessions`, including handshake, user and admin connections, and missing/unavailable SSH. Verify ordinary-user server denial.
3. Confirm/cancel start, all-session stop and one-session disconnect. Confirm browser login, both browser terminals, UART0 and USB remain available; other SSH connection survives targeted disconnect. Host identity/fingerprint and saved settings must remain unchanged across stop/start.
4. Disconnect/reconnect/reuse selected SSH target while confirmation/read is delayed; exercise CLI stop/start ABA, simultaneous new admissions, close/revocation and queued typed work. Stale targets must not close replacements. Stop scope includes new SSH admissions before execution.
5. Delay dispatcher with a normal bounded admin interaction; test pending/result visibility, deadline expiry, other-login isolation, logout/account-generation changes, request loss and repeated result checks. Inspect after uncertainty, never assume disconnect cancelled work or acknowledgement proves receipt.
6. With SSH admin command in flight, verify stop/disconnect cleanup and subsequent console-slot reuse. Record real owner/dispatcher progress, errors/timeouts and lack of HTTPD watchdog/deadlock.
7. Repeat full supported client mix at **160MHz / 230400 baud**, quiet UART0 broker/web counters before/after, byte/drop comparison on unaffected clients; include both 8D.18 contexts and native USB independent access. No throughput sign-off is inferred from host tests.
8. Record settled boot/full-mix/repeated SSH close/start/stop/login-cycle internal/DMA/PSRAM free/minimum/largest blocks and available HTTPD/dispatcher/SSH stack margins. No reserve floor approval or long-soak result exists here.
9. Real pointer/keyboard/touch/narrow-window Settings SSH selection and native confirmation, focus, screen-reader labels, refresh/reselect and uncertain-result recovery. No automatic polling is intended.