From 737bd29f9e0c7e209673fb1317dfae4d05bf5531 Mon Sep 17 00:00:00 2001 From: Commander1024 Date: Sun, 13 Sep 2026 16:07:04 +0200 Subject: [PATCH] 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. --- docs/agent/architecture.md | 2 + docs/agent/code-map.md | 2 + docs/agent/current-state.md | 2 + docs/agent/design-decisions.md | 2 + docs/phase8d19_implementation.md | 111 ++++++++ docs/phase8d_plan.md | 2 +- src/CMakeLists.txt | 1 + src/admin_ssh_console.c | 18 +- src/admin_ssh_console.h | 1 + src/ssh_transport.c | 69 ++++- src/ssh_transport.h | 20 ++ src/web_server.c | 16 +- src/web_ssh_settings.c | 242 ++++++++++++++++++ src/web_ssh_settings.h | 9 + src/web_ui.c | 86 ++++++- tests/admin_console_boundary/fakes.h | 2 + tests/admin_console_boundary/test.c | 15 ++ tests/ssh_management/run.py | 140 ++++++++++ tests/web_admin_transport/server_lifecycle.py | 128 ++++++--- tests/web_cookie_auth/run.py | 4 +- tests/web_cookie_auth/ssh_settings_test.c | 137 ++++++++++ tests/web_cookie_auth/test.c | 6 + tests/web_ui_session/browser.cjs | 3 +- tests/web_ui_session/layout.py | 10 +- tests/web_ui_session/ssh.cjs | 68 +++++ 25 files changed, 1049 insertions(+), 47 deletions(-) create mode 100644 docs/phase8d19_implementation.md create mode 100644 src/web_ssh_settings.c create mode 100644 src/web_ssh_settings.h create mode 100644 tests/ssh_management/run.py create mode 100644 tests/web_cookie_auth/ssh_settings_test.c create mode 100644 tests/web_ui_session/ssh.cjs diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index 0d1a6b2..dde1b46 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -146,6 +146,8 @@ The 8D.6 document binds retained terminal state to its first validated username/ ### SSH +**Typed ordinary SSH controls (8D.19 first service slice):** `web_ssh_settings` owns one session-bound operation/result slot, with256-byte/four-receive JSON,768-byte safe two-row projection and96-byte result. Three optional current-admin routes use canonical cookie/Origin/CSRF protection and the existing four-entry dispatcher (IDs only), never lifecycle work on HTTPD. The dispatcher checks login/principal currentness and30-second dequeue deadline. `ssh_transport` copies only published state under its short lock, and conditional actions take the existing command mutex with zero wait before checking a saturated lifecycle generation; lifecycle comparison/admission shares that mutex with CLI. Disconnect publishes an exact SSH-ID close request under the existing SSH lock; only the owner closes sockets/wolfSSH. SSH session generations now retire exhausted slots, while a separate lifecycle generation fences stop/start ABA and survives counter clear. Failed/pending cleanup gates typed controls, retaining canonical UART0 recovery. UI confirms SSH/all-SSH/one-SSH scope, preserves stale selection without rebasing, and uses15-second bounded requests with manual result/refresh recovery, no automatic replay.36 handlers/six sockets and unchanged tasks/stacks/timers/queue depth. No invoking HTTPS-session-cutting action, web-session/USB/Wi-Fi controls or identity mutation. Full contracts, admitted-work/timeout limits and pending target checks: `docs/phase8d19_implementation.md`. + `ssh_transport` uses wolfSSH on port 22 with two fixed session/handshake slots. Initialization calls `wolfSSH_Init()` in the caller before task creation; after that, one owner task pinned to core 1 exclusively owns runtime contexts/sessions and wolfSSH calls. It enforces bounded handshakes, authentication attempts, receive work, and session buffers. Authentication uses user-database passwords or stored Ed25519/ECDSA-P256 public keys. Public-key lookup authorizes a username/key pair, while wolfSSH verifies signed proof of possession. SSH host identity is a separate persisted P-256 key managed by `ssh_security`. diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 79612bd..714be45 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -118,6 +118,8 @@ This is a semantic map, not a complete file inventory. Start here, then read the ## SSH +- **8D.19 first service slice:** `web_ssh_settings.{c,h}` adds optional admin-only GET `/api/settings/ssh`, GET/POST `/api/settings/ssh-operation`; existing dispatcher queues only IDs to one login-bound slot. `ssh_transport_get_management_snapshot()` copies published state without owner wait/stack scan; `ssh_transport_manage_current()` checks saturated service generation under existing command mutex and exact session ID under SSH lock before canonical lifecycle/external-close admission. Exhausted session slots retire instead of wrapping. `web_ui.c` adds confirmed SSH-only Settings, sticky stale selection,15-second requests/manual Check Result/Refresh.36 handlers/six sockets/no new tasks/timers/depth/stacks/assets; CPU160 and8D.18 preserved. Tests `tests/ssh_management/run.py`, cookie `--ssh`, dispatcher, lifecycle27 and UI143. Contracts/resources/remaining8D.19 service audit/target checks: `docs/phase8d19_implementation.md`. SSH slice implemented/host/build verified; parent review/target sign-off pending, not full8D.19. + **Responsibility:** authenticate SSH, route users to serial and administrators to the command dispatcher, and own wolfSSH lifecycle. - Files: `src/ssh_transport.{h,c}`, `src/ssh_security.{h,c}`, `src/ssh_console.{h,c}` diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index b43c8b4..4e000da 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **8D.19 first service slice — SSH (2026-09-13), user-authorized; implemented/host/build verified, independently reviewed; target sign-off pending:** Admin Settings/SSH provides safe two-row status and confirmed SSH start/stop/one-session disconnect only. `web_ssh_settings` owns256-byte/four-receive request,768-byte snapshot,96-byte result and one login-bound slot/30-second dequeue deadline; existing four-entry dispatcher receives IDs only. SSH owns zero-wait published snapshot, command-mutex conditional lifecycle admission with saturated service generation (CLI stop/start ABA included), exact-ID external close and retired exhausted session slots. HTTPD never waits for SSH/wolfSSH; no new task/timer/stack/depth/socket/asset/config/dependency.36 handlers/six sockets. UI uses existing Settings styles/native confirmations, sticky stale target selection,15-second whole-request deadline and explicit manual Check Result then Refresh (no polling/replay). Invoking HTTPS login/browser terminals/Wi-Fi/USB/UART0 are not stopped. Canonical browser-shell restrictions unchanged. All web-session/HTTPS/USB actions excluded; other-service8D.19 requires separate owner/scope audit, phase incomplete; no8D.20/21. Initial worktree clean. PASS canonical SSH4, cookie SSH6+shared/all existing variants, dispatcher, lifecycle27, UI143+six-view HTML/CSP, broad accounts/Network/broker/policy/auth/transport/store/idle/diagnostics/performance/security/login and diff check. Independent reviewer found no confirmed actionable defects; canonical SSH4, cookie SSH6+shared, dispatcher, lifecycle27, UI143+renderer/HTML/CSP and diff checks independently PASS. Parent final pio confirmation PASS19.95s100,404/1,808,441 B; diff check PASS. Baseline pio6.87s100,300 RAM/1,793,301 flash; final35.31s100,404/1,808,441 (+104/+15,140 B). CPU160 defaults/active/generated confirmed;8D.18 contexts/combined binary WS path preserved. No hardware/real-browser geometry/heap/stack validation, upload/erase/commit or target/reserve/M3 sign-off. Exact API/owner race/uncertainty/resource/test limits and pending checks: `docs/phase8d19_implementation.md`. + - **8D.18 implemented (2026-09-13), user-authorized; host/build verified, independently reviewed; target sign-off pending:** UI-only Broker clients/Active writer contexts reuse8D.16 snapshot/lease fence/operation controller and8D.17 single host/native confirmation/dismissal focus fix.5-second-after-success single-flight live refresh with5-second whole-read deadline; errors/timeouts stop, no auto error/mutation retry. Explicit selection identity+lease token is never rebased; stale/absent latches prevent resurrection, consumed selection requires explicit reselection. New triggers preserve full-page Serial/account/Network drafts even on activation; quick promotion preserves selection/controller and stops live timer. Focused controls use aria-disabled plus existing handler guards rather than native focus loss; previous terminals keep receiving with input isolated. Initial worktree clean. PASS135 UI groups+renderer/HTML/CSP, broker management/wrap, cookie Broker+base/all existing variants, lifecycle25, dispatcher/canonical accounts/Network, parser294, transports/tickets/store, idle/diagnostics/performance/security/login and diff check. Broad tests preceded final focus refinement; full UI/build rerun afterward. Independent reviewer found no confirmed actionable defects and reran UI135+renderer/HTML/CSP, canonical broker management/wrap, cookie Broker and diff checks PASS. Parent final pio confirmation PASS6.89s100,300/1,793,301 B; diff check PASS. Native picker during live updates and actual blank/reselect remain browser-validation gaps, not proven defects. Baseline pio7.02s100,300 RAM/1,788,661 flash; final21.52s100,300/1,793,301 (+0/+4,640 B). CPU160 defaults/active/generated verified, combined WS send unchanged. No backend/API/policy/assets/config/dependencies/icons/8D.15/19/later/upload/erase/commit/SDK changes. No real-browser geometry/accessibility or device tests this phase; no reserve/M3 claim. Exact contract, test-development corrections and pending parent/target checklist: `docs/phase8d18_implementation.md`. Stop before any separately authorized next phase. - **8D.17 independent-review P2 fixed (2026-09-13), independently confirmed resolved:** Quick dismissal now restores Serial/Admin state/input without xterm focus; automatic departure and adjacent-trigger switches preserve focus, Escape/Close focus only trigger. Observable Terminal.focus harness reproduced adjacent-trigger failure before fix;128 UI groups+renderer/HTML/CSP PASS, pio21.90s100,300 RAM/1,788,661 flash (+0/+32 B review delta; phase+0/+6,048 B), diff check PASS. Two new groups cover adjacent keyboard triggers and timed/outside-pointer dismissal/explicit focus for both terminal backgrounds. Existing uncommitted work preserved; no unrelated source/device/assets changes. Reviewer confirmed no new actionable findings and independently reran128 UI groups+renderer/HTML/CSP and diff checks PASS. Parent final build confirmation PASS6.98s,100,300/1,788,661 B; diff check PASS. Exact correction in `docs/phase8d17_implementation.md`; target/geometry limits remain. diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index abdb32b..203c14e 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -50,6 +50,8 @@ Only constraints supported by implementation or current project documentation be ## Resource IDs are generation-safe +**8D.19 SSH ordinary-control decision:** A transport disconnect must use the owning transport's handle, never an arbitrary broker client ID or socket fd. SSH retains its encoded session ID but retires exhausted generations instead of wrapping, preserving exact-ID owner-close consumption across reuse. Typed lifecycle confirmation adds a distinct saturated service generation advanced by canonical lifecycle admission, including CLI; compare and start/stop admission retain the existing command mutex. Published zero-wait snapshots do not scan owner/task state. HTTPD only admits current-admin bounded typed work to the existing dispatcher; it never waits for SSH or invokes wolfSSH. Disconnect success is an owner request, lifecycle timeout is not cancellation, and admitted work may finish after revocation. All-SSH stop explicitly includes new admissions before execution. Existing browser-shell SSH deferral restrictions stay intact. SSH-only first slice, not all-service8D.19 or8D.20; exact bounds/contracts: `docs/phase8d19_implementation.md`. + **Decision:** Broker clients, SSH/WebSocket slots, queued admin work, and user principals carry generations or random stable IDs to reject stale references and slot reuse. **Rationale/evidence:** Broker IDs encode slot generation; transports track slot generations; admin tokens include session/slot generation; user principal currentness includes account ID and authentication generation. diff --git a/docs/phase8d19_implementation.md b/docs/phase8d19_implementation.md new file mode 100644 index 0000000..7fd815e --- /dev/null +++ b/docs/phase8d19_implementation.md @@ -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. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index 408b99f..4101fd8 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -190,7 +190,7 @@ Typed operations must preserve subsystem owner/lock/persistence contracts and co | **8D.16 — Broker client visibility and writer transfer** | **Implemented, host/build verified; independent parent review and target sign-off pending.** Admin-only detailed snapshot plus explicit confirmed assignment through existing dispatcher/broker; smallest owner changes add atomic lease-version/target validation and nonwrapping IDs. [Contract/evidence](phase8d16_implementation.md). | Host stale/disconnected/reused target and lease ABA rejection, one writer, admin denial, bounded/session-isolated results, lifecycle and UI non-mutation regressions pass. Concurrent physical USB/SSH/browser/full-mix and stack-margin checklist remain pending. No transfer on page open/selection; no8D.17/18 popovers/icons. | | **8D.17 — Serial/Wi-Fi quick popovers** | **Implemented, host/build verified; independent parent review and target sign-off pending.** UI-only reuse of completed typed endpoints through one settings DOM/controller, full-page link and shared validation; `web_ui`. [Contract/evidence](phase8d17_implementation.md). | Hover/focus/click/tap, Escape/outside dismissal, no opening/selection mutation, explicit Apply/Save, Network password exclusion; draft/session/pending-operation regressions pass. Full-page hover deliberately preserves its existing draft; explicit activation navigates that page. Optional geometry blocked by sandbox; real pointer/keyboard/touch/full-mix checklist pending. No duplicate backend/new settings scope/icons/8D.18. | | **8D.18 — Client/writer contextual dialogs** | **Implemented, host/build verified; independent parent review and target sign-off pending.** UI-only reuse of8D.16 and8D.17's single host for live client popover and confirmed Active writer dialog. [Contract/tests/checklist](phase8d18_implementation.md). | Native pointer/keyboard/touch entrances; single-flight5-second live refresh/deadline, explicit selection preserved without lease-token renewal, sticky stale/absent rejection, full-page draft protection and focus-safe updates. Ordinary users retain only ordinary status.135 UI groups plus broad broker/auth/lifecycle regressions pass; real browser/device checks pending. No new writer policy/backend/icons/8D.19/later. | -| **8D.19 — Ordinary service/session controls** | Typed service status and targeted disconnect/start/stop controls, excluding actions that cut off the invoking HTTPS session; start in existing service APIs and generation-safe snapshots. | Explicit scope/confirmation, stale target rejection, owner-safe execution and failure isolation. Stop/start and disconnect do not clear settings/identities. Split by service if more than one owner adaptation is necessary. | +| **8D.19 — Ordinary service/session controls** | **First service slice SSH implemented, host/build verified; independent parent review and target sign-off pending. Phase incomplete.** Typed SSH status and confirmed exact-session disconnect/start/stop via existing dispatcher/SSH owner, excluding invoking HTTPS-session-cutting actions. [SSH contract/tests/resources](phase8d19_implementation.md). | Explicit SSH/all-SSH/one-session confirmation; owner lock/service generation/retired session IDs reject stale/reused targets and stop/start ABA. No settings/identity clear. SSH4, cookie SSH6+shared, dispatcher, lifecycle27 and UI143 PASS. Split-by-service rule applied: all web-session/HTTPS/USB controls excluded; next other-session web slice requires explicit login/owner-safety audit, USB actions are not promised. No generic broker disconnect or8D.20/21. Target full-mix/heap/stack/recovery checks pending. | | **8D.20 — Self-affecting service actions and reboot** | Confirmed typed operations for connection-losing HTTPS/Wi-Fi/service actions and reboot, reusing lifecycle behavior established in 8D.7/8D.13. | Acknowledgement/pending-action handling is bounded, no duplicate action on UI retry, connection loss is explained, recovery and reconnect work. No new unbounded queue or certificate/key rotation yet. | | **8D.21 — Security/danger-zone settings** | Carefully separated HTTPS/SSH identity rotation/reset and any explicitly retained recovery-secret operation through existing security APIs. Enumerate allowed operations first; split HTTPS and SSH work into separate requests if both need owner changes. | Confirmation, secret-safe one-time responses, no routine private-key export, expected trust/fingerprint changes, bounded self-disconnect, no accidental configuration wipe. Bootstrap/unavailable-database recovery remain UART0-only. NVS encryption, secure boot, OTA, and new factory-reset semantics stay out of scope. | diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0edeaf1..4711f64 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -36,6 +36,7 @@ idf_component_register( "web_network_settings.c" "web_display_settings.c" "web_broker_settings.c" + "web_ssh_settings.c" "web_admin_tickets.c" "web_admin_transport.c" "web_assets_data.c" diff --git a/src/admin_ssh_console.c b/src/admin_ssh_console.c index 96c3d64..5b0ee57 100644 --- a/src/admin_ssh_console.c +++ b/src/admin_ssh_console.c @@ -21,6 +21,7 @@ #include "web_network_settings.h" #include "web_display_settings.h" #include "web_broker_settings.h" +#include "web_ssh_settings.h" #define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U #define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U @@ -91,6 +92,7 @@ typedef enum { ADMIN_REQUEST_NETWORK_SETTINGS, ADMIN_REQUEST_DISPLAY_SETTINGS, ADMIN_REQUEST_BROKER_SETTINGS, + ADMIN_REQUEST_SSH_SETTINGS, } admin_request_origin_t; typedef struct { @@ -106,6 +108,7 @@ typedef struct { uint32_t network_settings_id; uint32_t display_settings_id; uint32_t broker_settings_id; + uint32_t ssh_settings_id; }; } admin_request_t; @@ -711,6 +714,16 @@ esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id) return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; } +esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id) +{ + taskENTER_CRITICAL(&s_lock); + bool ready = s_dispatch_ready; + taskEXIT_CRITICAL(&s_lock); + if (!ready || !id) return ESP_ERR_INVALID_STATE; + admin_request_t request = {.origin = ADMIN_REQUEST_SSH_SETTINGS, .ssh_settings_id = id}; + return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; +} + static void worker_task(void *context) { (void)context; @@ -721,12 +734,13 @@ static void worker_task(void *context) } if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS || request.origin == ADMIN_REQUEST_NETWORK_SETTINGS || request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS || - request.origin == ADMIN_REQUEST_BROKER_SETTINGS) { + request.origin == ADMIN_REQUEST_BROKER_SETTINGS || request.origin == ADMIN_REQUEST_SSH_SETTINGS) { if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id); else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id); else if (request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) web_network_settings_execute(request.network_settings_id); else if (request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) web_display_settings_execute(request.display_settings_id); - else web_broker_settings_execute(request.broker_settings_id); + else if (request.origin == ADMIN_REQUEST_BROKER_SETTINGS) web_broker_settings_execute(request.broker_settings_id); + else web_ssh_settings_execute(request.ssh_settings_id); secure_wipe(&request, sizeof(request)); continue; } diff --git a/src/admin_ssh_console.h b/src/admin_ssh_console.h index b8b0ff4..d7efff1 100644 --- a/src/admin_ssh_console.h +++ b/src/admin_ssh_console.h @@ -20,6 +20,7 @@ esp_err_t admin_ssh_console_submit_account_settings(uint32_t id); esp_err_t admin_ssh_console_submit_network_settings(uint32_t id); esp_err_t admin_ssh_console_submit_display_settings(uint32_t id); esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id); +esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id); /* Fits the longest supported ECDSA P-256 OpenSSH key import command. */ #define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U diff --git a/src/ssh_transport.c b/src/ssh_transport.c index 633f6d4..6301861 100644 --- a/src/ssh_transport.c +++ b/src/ssh_transport.c @@ -100,6 +100,8 @@ static bool s_running; static bool s_transitioning; static bool s_desired_running; static bool s_cleanup_pending; +/* Saturates independently of the internal completion sequence; never reset by counters. */ +static uint32_t s_management_generation = 1U; static uint32_t s_requested_sequence; static uint32_t s_completed_sequence; static esp_err_t s_command_result = ESP_ERR_INVALID_STATE; @@ -837,7 +839,8 @@ static ssh_slot_t *find_free_slot(size_t *slot_index) } for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) { - if (s_slots[index].state == SSH_TRANSPORT_SESSION_FREE) { + if (s_slots[index].state == SSH_TRANSPORT_SESSION_FREE && + s_slots[index].generation < SSH_TRANSPORT_GENERATION_MAX) { *slot_index = index; return &s_slots[index]; } @@ -907,7 +910,8 @@ static void accept_connections(void) (void)setsockopt(socket_fd, IPPROTO_TCP, TCP_NODELAY, &enabled, sizeof(enabled)); - uint32_t generation = next_generation(slot->generation); + /* Exhausted slots are retired by find_free_slot(), never reused after wrap. */ + uint32_t generation = slot->generation + 1U; memset(slot, 0, sizeof(*slot)); slot->state = SSH_TRANSPORT_SESSION_HANDSHAKE; slot->generation = generation; @@ -1512,6 +1516,7 @@ static esp_err_t request_running_locked(bool desired) return ESP_OK; } s_desired_running = desired; + if (s_management_generation != UINT32_MAX) ++s_management_generation; s_transitioning = true; s_requested_sequence = next_generation(s_requested_sequence); sequence = s_requested_sequence; @@ -1633,6 +1638,66 @@ esp_err_t ssh_transport_get_snapshot(ssh_transport_snapshot_t *snapshot) return ESP_OK; } +esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *snapshot) +{ + if (snapshot == NULL) return ESP_ERR_INVALID_ARG; + taskENTER_CRITICAL(&s_lock); + if (!s_initialized) { + taskEXIT_CRITICAL(&s_lock); + return ESP_ERR_INVALID_STATE; + } + memset(snapshot, 0, sizeof(*snapshot)); + snapshot->generation = s_management_generation; + snapshot->running = s_running; + snapshot->transitioning = s_transitioning || s_cleanup_pending; + for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) { + snapshot->sessions[i] = s_session_snapshots[i]; + snapshot->sessions[i].close_requested |= + snapshot->sessions[i].active && s_external_close_id[i] == snapshot->sessions[i].session_id; + } + taskEXIT_CRITICAL(&s_lock); + return ESP_OK; +} + +esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action, + uint32_t target, uint32_t generation) +{ + if (!generation || generation == UINT32_MAX || + action < SSH_TRANSPORT_MANAGE_START || action > SSH_TRANSPORT_MANAGE_DISCONNECT || + ((action == SSH_TRANSPORT_MANAGE_DISCONNECT) != (target != 0U))) return ESP_ERR_INVALID_ARG; + if (s_command_mutex == NULL) return ESP_ERR_INVALID_STATE; + if (xSemaphoreTake(s_command_mutex, 0U) != pdTRUE) return ESP_ERR_TIMEOUT; + esp_err_t error = ESP_ERR_INVALID_STATE; + taskENTER_CRITICAL(&s_lock); + if (s_initialized && !s_transitioning && !s_cleanup_pending && + generation == s_management_generation) { + if (action == SSH_TRANSPORT_MANAGE_DISCONNECT) { + error = ESP_ERR_NOT_FOUND; + for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) { + const ssh_transport_session_snapshot_t *session = &s_session_snapshots[i]; + if (session->active && session->session_id == target && + !session->close_requested && session->state != SSH_TRANSPORT_SESSION_CLOSING && + s_external_close_id[i] != target) { + s_external_close_id[i] = target; + error = ESP_OK; + break; + } + } + } else if (s_running != (action == SSH_TRANSPORT_MANAGE_START)) { + error = ESP_OK; + } + } + taskEXIT_CRITICAL(&s_lock); + /* The command mutex spans comparison and canonical lifecycle admission. + * No HTTPD work/lock is involved; only the SSH owner touches sockets/wolfSSH. */ + if (error == ESP_OK) { + if (action == SSH_TRANSPORT_MANAGE_DISCONNECT) notify_task(); + else error = request_running_locked(action == SSH_TRANSPORT_MANAGE_START); + } + xSemaphoreGive(s_command_mutex); + return error; +} + esp_err_t ssh_transport_clear_counters(void) { taskENTER_CRITICAL(&s_lock); diff --git a/src/ssh_transport.h b/src/ssh_transport.h index 4709d75..c3068f5 100644 --- a/src/ssh_transport.h +++ b/src/ssh_transport.h @@ -99,6 +99,26 @@ typedef struct { ssh_transport_counters_t counters; } ssh_transport_snapshot_t; +typedef enum { + SSH_TRANSPORT_MANAGE_START = 0, + SSH_TRANSPORT_MANAGE_STOP, + SSH_TRANSPORT_MANAGE_DISCONNECT, +} ssh_transport_management_action_t; + +typedef struct { + uint32_t generation; + bool running; + bool transitioning; + ssh_transport_session_snapshot_t sessions[SSH_TRANSPORT_MAX_SESSIONS]; +} ssh_transport_management_snapshot_t; + +/* Compact published state only; no wolfSSH calls or task-stack scan. */ +esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *snapshot); +/* Dispatcher-only conditional admission; success on disconnect means owner notified, + * not peer receipt/cleanup. Lifecycle timeout does not cancel admitted work. */ +esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action, + uint32_t target, uint32_t generation); + /* Installs wolfCrypt RNG/PSRAM hooks and starts the sole wolfSSH owner task. */ esp_err_t ssh_transport_init(void); esp_err_t ssh_transport_start(void); diff --git a/src/web_server.c b/src/web_server.c index d69d8d3..9b810a8 100644 --- a/src/web_server.c +++ b/src/web_server.c @@ -28,6 +28,7 @@ #include "web_network_settings.h" #include "web_display_settings.h" #include "web_broker_settings.h" +#include "web_ssh_settings.h" #include "web_admin_transport.h" #include "web_session_store.h" #include "web_cookie_auth.h" @@ -415,6 +416,15 @@ static const httpd_uri_t s_account_generate_password_uri = { static const httpd_uri_t s_network_uri = { .uri = "/api/settings/network", .method = HTTP_GET, .handler = web_network_snapshot_handler, }; +static const httpd_uri_t s_ssh_settings_uri = { + .uri = "/api/settings/ssh", .method = HTTP_GET, .handler = web_ssh_settings_handler, +}; +static const httpd_uri_t s_ssh_operation_get_uri = { + .uri = "/api/settings/ssh-operation", .method = HTTP_GET, .handler = web_ssh_operation_handler, +}; +static const httpd_uri_t s_ssh_operation_post_uri = { + .uri = "/api/settings/ssh-operation", .method = HTTP_POST, .handler = web_ssh_operation_handler, +}; static const httpd_uri_t s_broker_uri = { .uri = "/api/settings/broker", .method = HTTP_GET, .handler = web_broker_settings_handler, }; @@ -655,7 +665,7 @@ esp_err_t web_server_start(void) config.httpd.max_open_sockets = 6; config.httpd.max_uri_handlers = sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) + - sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 19U; + sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 22U; /* Exhaustion rejects new sockets, never evicts an existing serial writer. */ config.httpd.lru_purge_enable = false; config.httpd.recv_wait_timeout = 1; @@ -727,6 +737,10 @@ esp_err_t web_server_start(void) web_httpd_register_optional_get(server, &s_broker_operation_get_uri) == ESP_OK && web_httpd_register_optional(server, &s_broker_operation_post_uri) != ESP_OK) (void)httpd_unregister_uri_handler(server, s_broker_operation_get_uri.uri, HTTP_GET); + if (web_httpd_register_optional_get(server, &s_ssh_settings_uri) == ESP_OK && + web_httpd_register_optional_get(server, &s_ssh_operation_get_uri) == ESP_OK && + web_httpd_register_optional(server, &s_ssh_operation_post_uri) != ESP_OK) + (void)httpd_unregister_uri_handler(server, s_ssh_operation_get_uri.uri, HTTP_GET); } if (error != ESP_OK) { web_cookie_auth_stop(); diff --git a/src/web_ssh_settings.c b/src/web_ssh_settings.c new file mode 100644 index 0000000..e515f54 --- /dev/null +++ b/src/web_ssh_settings.c @@ -0,0 +1,242 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#include "web_ssh_settings.h" + +#include +#include +#include +#include "admin_ssh_console.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "secure_random.h" +#include "ssh_transport.h" +#include "web_cookie_auth.h" +#include "web_httpd_adapter.h" + +enum { IDLE, PENDING, OK, FAILED, CANCELLED, CONFLICT }; +static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "conflict"}; +static const char *const s_actions[] = {"start", "stop", "disconnect"}; +typedef struct { + uint32_t id; + web_session_id_t session; + user_principal_t principal; + int64_t deadline; + uint32_t generation, target; + ssh_transport_management_action_t action; + unsigned state; +} ssh_operation_t; +static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; +static ssh_operation_t s_operation; +static uint32_t s_next_id; + +/* Exact three-field flat JSON; no escapes, duplicates, extra fields or coercion. */ +static bool parse(const char *body, size_t length, ssh_operation_t *operation) +{ + const char *keys[] = {"action", "generation", "target"}; + unsigned seen = 0; + size_t pos = 0; +#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos +#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0) + TAKE('{'); + for (unsigned field = 0; field < 3; ++field) { + if (field) { TAKE(','); } + TAKE('"'); + size_t start = pos; + while (pos < length && body[pos] != '"') ++pos; + if (pos == length) return false; + unsigned key = 0; + for (; key < 3; ++key) + if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break; + if (key == 3 || (seen & (1U << key))) return false; + ++pos; TAKE(':'); SPACE(); + if (key == 0) { + TAKE('"'); start = pos; + while (pos < length && body[pos] != '"') ++pos; + if (pos == length) return false; + unsigned action = 0; + for (; action < 3; ++action) + if (strlen(s_actions[action]) == pos - start && !memcmp(body + start, s_actions[action], pos - start)) break; + if (action == 3) return false; + operation->action = (ssh_transport_management_action_t)action; + ++pos; + } else { + uint32_t number = 0; + start = pos; + while (pos < length && body[pos] >= '0' && body[pos] <= '9') { + unsigned digit = (unsigned)(body[pos++] - '0'); + if (number > (UINT32_MAX - digit) / 10U) return false; + number = number * 10U + digit; + } + if (pos == start || (pos - start > 1 && body[start] == '0')) return false; + if (key == 1) operation->generation = number; + else operation->target = number; + } + seen |= 1U << key; + } + TAKE('}'); SPACE(); +#undef TAKE +#undef SPACE + return pos == length && seen == 7 && operation->generation && + operation->generation != UINT32_MAX && + ((operation->action == SSH_TRANSPORT_MANAGE_DISCONNECT) == (operation->target != 0U)); +} + +void web_ssh_settings_execute(uint32_t id) +{ + ssh_operation_t operation; + taskENTER_CRITICAL(&s_lock); + operation = s_operation; + taskEXIT_CRITICAL(&s_lock); + if (!id || operation.id != id || operation.state != PENDING) { + secure_wipe(&operation, sizeof(operation)); + return; + } + bool current = false; + esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t); + unsigned state = CANCELLED; + if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN && + esp_timer_get_time() < operation.deadline) { + error = ssh_transport_manage_current(operation.action, operation.target, operation.generation); + state = error == ESP_OK ? OK : + (error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED; + } + taskENTER_CRITICAL(&s_lock); + if (s_operation.id == id && s_operation.state == PENDING) { + s_operation.state = state; + secure_wipe(&s_operation.principal, sizeof(s_operation.principal)); + } + taskEXIT_CRITICAL(&s_lock); + secure_wipe(&operation, sizeof(operation)); +} + +static esp_err_t respond(httpd_req_t *request, const char *status, const char *body) +{ + esp_err_t error = httpd_resp_set_status(request, status); + if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8"); + if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store"); + if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff"); + if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer"); + if (error == ESP_OK) error = httpd_resp_sendstr(request, body); + return web_httpd_unread_body(request) ? ESP_FAIL : error; +} + +esp_err_t web_ssh_operation_handler(httpd_req_t *request) +{ + web_session_view_t view = {0}; + bool allowed = false; + bool mutation = request->method == HTTP_POST; + esp_err_t error = mutation + ? web_cookie_auth_require_json(request, 256, &view, &allowed) + : web_cookie_auth_require(request, false, false, &view, &allowed); + if (error != ESP_OK || !allowed) goto done; + if (view.principal.role != USER_ROLE_ADMIN) { + error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); + goto done; + } + ssh_operation_t operation = {0}; + if (mutation) { + char type[40] = {0}, body[256]; + size_t received = 0; + bool valid = request->content_len && request->content_len <= sizeof(body) && + httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK && + (!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8")); + for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) { + int count = httpd_req_recv(request, body + received, request->content_len - received); + if (count <= 0 || (size_t)count > request->content_len - received) valid = false; + else received += (size_t)count; + } + valid = valid && received == request->content_len && parse(body, received, &operation); + secure_wipe(body, sizeof(body)); + if (!valid) { + error = respond(request, "400 Bad Request", "{\"error\":\"invalid_ssh_request\"}"); + goto done; + } + operation.session = view.id; + operation.principal = view.principal; + operation.deadline = esp_timer_get_time() + 30000000LL; + operation.state = PENDING; + taskENTER_CRITICAL(&s_lock); + bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX; + if (!busy) { + operation.id = ++s_next_id; + s_operation = operation; + } + taskEXIT_CRITICAL(&s_lock); + if (busy || admin_ssh_console_submit_ssh_settings(operation.id) != ESP_OK) { + taskENTER_CRITICAL(&s_lock); + if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation)); + taskEXIT_CRITICAL(&s_lock); + error = httpd_resp_set_hdr(request, "Retry-After", "1"); + if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}"); + secure_wipe(&operation, sizeof(operation)); + goto done; + } + } else { + taskENTER_CRITICAL(&s_lock); + if (s_operation.session == view.id) { + operation.id = s_operation.id; + operation.state = s_operation.state; + operation.action = s_operation.action; + } + taskEXIT_CRITICAL(&s_lock); + } + char response[96]; + int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}", + operation.id, operation.id ? s_actions[operation.action] : "none", s_states[operation.state]); + error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL : + respond(request, mutation ? "202 Accepted" : "200 OK", response); + secure_wipe(&operation, sizeof(operation)); +done: + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); + return error; +} + +esp_err_t web_ssh_settings_handler(httpd_req_t *request) +{ + web_session_view_t view = {0}; + bool allowed = false; + esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed); + if (error != ESP_OK || !allowed) goto done; + if (view.principal.role != USER_ROLE_ADMIN) { + error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}"); + goto done; + } + ssh_transport_management_snapshot_t snapshot; + error = ssh_transport_get_management_snapshot(&snapshot); + if (error != ESP_OK) { + error = respond(request, "503 Service Unavailable", "{\"error\":\"ssh_unavailable\"}"); + goto done; + } + char response[768]; + int written = snprintf(response, sizeof(response), + "{\"generation\":%" PRIu32 ",\"running\":%s,\"transitioning\":%s,\"sessions\":[", + snapshot.generation, snapshot.running ? "true" : "false", snapshot.transitioning ? "true" : "false"); + if (written < 0 || (size_t)written >= sizeof(response)) { error = ESP_FAIL; goto done; } + size_t used = (size_t)written; + unsigned count = 0; + for (size_t i = 0; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) { + const ssh_transport_session_snapshot_t *session = &snapshot.sessions[i]; + if (!session->active) continue; + char name[USER_DATABASE_USERNAME_CAPACITY * 2 + 1]; + static const char hex[] = "0123456789abcdef"; + size_t n = 0; + for (; session->principal_valid && n < USER_DATABASE_USERNAME_CAPACITY && session->username[n]; ++n) { + unsigned byte = (unsigned char)session->username[n]; + name[n * 2] = hex[byte >> 4]; name[n * 2 + 1] = hex[byte & 15]; + } + name[n * 2] = 0; + written = snprintf(response + used, sizeof(response) - used, + "%s{\"id\":%" PRIu32 ",\"state\":%u,\"route\":%u,\"name_hex\":\"%s\",\"closing\":%s}", + count++ ? "," : "", session->session_id, (unsigned)session->state, + (unsigned)session->route, name, session->close_requested ? "true" : "false"); + if (written < 0 || (size_t)written >= sizeof(response) - used) { error = ESP_FAIL; goto done; } + used += (size_t)written; + } + written = snprintf(response + used, sizeof(response) - used, "]}"); + error = written < 0 || (size_t)written >= sizeof(response) - used ? ESP_FAIL : + respond(request, "200 OK", response); +done: + secure_wipe(&view, sizeof(view)); + web_httpd_wipe_request(request, web_httpd_unread_body(request)); + return error; +} diff --git a/src/web_ssh_settings.h b/src/web_ssh_settings.h new file mode 100644 index 0000000..e9316fb --- /dev/null +++ b/src/web_ssh_settings.h @@ -0,0 +1,9 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ +#pragma once +#include +#include "esp_http_server.h" + +/* Optional admin-only SSH status and login-isolated ordinary controls. */ +esp_err_t web_ssh_settings_handler(httpd_req_t *request); +esp_err_t web_ssh_operation_handler(httpd_req_t *request); +void web_ssh_settings_execute(uint32_t id); diff --git a/src/web_ui.c b/src/web_ui.c index dfd748d..09f1b1d 100644 --- a/src/web_ui.c +++ b/src/web_ui.c @@ -190,7 +190,8 @@ static const char s_index_html[] = "
" "" "" - "
" + "" + "\n" "\n" "