Files
ESP32_Serial_Swiss_Army_Knife/docs/phase8d19_implementation.md
T
Commander1024 737bd29f9e 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.
2026-09-13 16:07:04 +02:00

18 KiB

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.