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:
@@ -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`.
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
+16
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+67
-2
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+15
-1
@@ -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();
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_ssh_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "secure_random.h"
|
||||
#include "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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#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);
|
||||
+84
-2
@@ -190,7 +190,8 @@ static const char s_index_html[] =
|
||||
"<div id=\"settings-navigation\" class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
|
||||
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
|
||||
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
|
||||
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button><button id=\"settings-broker\" class=\"button\" type=\"button\" aria-pressed=\"false\">Broker</button></div>"
|
||||
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button><button id=\"settings-broker\" class=\"button\" type=\"button\" aria-pressed=\"false\">Broker</button><button id=\"settings-ssh\" class=\"button\" type=\"button\" aria-pressed=\"false\">SSH</button></div>"
|
||||
"<div id=\"ssh-settings\" hidden><h2>SSH service and sessions</h2><p class=\"connection-detail\">SSH only, TCP port 22. Start/Stop do not change saved settings or host identity. Stop closes all SSH sessions, including any admitted before execution; an SSH administrator's already executing command may finish. HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped. Targeted disconnect affects only the selected SSH connection, not all logins for its account. Viewing or selecting never changes services or writer ownership.</p><button id=\"ssh-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"ssh-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"ssh-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Disconnect SSH session<select id=\"ssh-target\"><option value=\"\">Select a session</option><option id=\"ssh-option-0\" hidden disabled></option><option id=\"ssh-option-1\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"ssh-start\" class=\"button\" type=\"button\">Start SSH…</button><button id=\"ssh-stop\" class=\"button\" type=\"button\">Stop SSH…</button><button id=\"ssh-disconnect\" class=\"button\" type=\"button\">Disconnect selected…</button><button id=\"ssh-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"ssh-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. After submission use Check Operation Result, then Refresh. Navigation or timeout does not cancel admitted work. No automatic mutation retry.</p></div>\n"
|
||||
"<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh retains explicit selection without renewing its lease token. Stale selections require choosing the blank option then the target again. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</p></div>\n"
|
||||
"<div id=\"display-settings\" hidden><h2>Display</h2>\n"
|
||||
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 0–86400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
|
||||
@@ -453,7 +454,7 @@ static const char s_app_js[] =
|
||||
"window.addEventListener('keydown', event => { if (quick && event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); closeQuick(true); } });\n"
|
||||
"function clearSettings() {\n"
|
||||
" resetQuick();\n"
|
||||
" clearAccounts(); clearNetwork(); clearDisplay(); clearBroker();\n"
|
||||
" clearAccounts(); clearNetwork(); clearDisplay(); clearBroker(); clearSsh();\n"
|
||||
" if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n"
|
||||
" stopSerialAuto(true);\n"
|
||||
" if (settingsAbort) settingsAbort.abort();\n"
|
||||
@@ -469,6 +470,7 @@ static const char s_app_js[] =
|
||||
" if (settingsDomain === 'network') return refreshNetwork();\n"
|
||||
" if (settingsDomain === 'display') return refreshDisplay();\n"
|
||||
" if (settingsDomain === 'broker') return refreshBroker();\n"
|
||||
" if (settingsDomain === 'ssh') return refreshSsh();\n"
|
||||
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
|
||||
" settingsHost.hidden = false;\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
|
||||
@@ -883,6 +885,85 @@ static const char s_app_js[] =
|
||||
" if (brokerSelection) brokerDetail.textContent = 'Explicit selection: ' + brokerLabel(client) + '. Confirm assignment separately; refresh never renews this lease token.';\n"
|
||||
" brokerButtons();\n"
|
||||
"});\n"
|
||||
"const sshActions = ['start','stop','disconnect'];\n"
|
||||
"let sshSnapshot = null, sshSelection = null, sshAbort = null, sshPending = false, sshAwaitingAck = false, sshId = 0, sshAction = '';\n"
|
||||
"const sshLabel = s => String(s.id) + ' / ' + ['Handshake','Serial','Admin console'][s.route] + ' / ' + (brokerName(s) || 'not authenticated');\n"
|
||||
"function sshButtons() {\n"
|
||||
" const busy = !!sshAbort, available = sshSnapshot && !sshSnapshot.transitioning && sshSnapshot.generation < 4294967295;\n"
|
||||
" element('ssh-refresh').disabled = element('ssh-result').disabled = busy;\n"
|
||||
" element('ssh-target').disabled = busy || sshPending || !available;\n"
|
||||
" element('ssh-start').disabled = busy || sshPending || !available || sshSnapshot.running;\n"
|
||||
" element('ssh-stop').disabled = busy || sshPending || !available || !sshSnapshot.running;\n"
|
||||
" element('ssh-disconnect').disabled = busy || sshPending || !available || !sshSelection || sshSelection.stale;\n"
|
||||
"}\n"
|
||||
"function clearSsh() {\n"
|
||||
" if (sshAbort) sshAbort.abort(); sshAbort = null; sshSnapshot = null; sshSelection = null;\n"
|
||||
" element('ssh-target').value = ''; element('ssh-values').textContent = '';\n"
|
||||
" for (let i = 0; i < 2; ++i) { const o = element('ssh-option-' + i); o.textContent = ''; o.hidden = o.disabled = true; }\n"
|
||||
" element('ssh-detail').textContent = 'Select Refresh to read current SSH state.';\n"
|
||||
" if (sshPending) element('ssh-operation-detail').textContent = 'Outcome pending or unknown. Check Result on return; navigation does not cancel admitted work.';\n"
|
||||
" sshButtons();\n"
|
||||
"}\n"
|
||||
"function sshValid(v) {\n"
|
||||
" return v && Object.keys(v).length === 4 && brokerUint(v.generation) && v.generation > 0 && typeof v.running === 'boolean' && typeof v.transitioning === 'boolean' &&\n"
|
||||
" Array.isArray(v.sessions) && v.sessions.length <= 2 && new Set(v.sessions.map(s => s.id)).size === v.sessions.length && v.sessions.every(s =>\n"
|
||||
" s && Object.keys(s).length === 5 && brokerUint(s.id) && s.id > 0 && Number.isInteger(s.state) && s.state >= 1 && s.state <= 3 &&\n"
|
||||
" Number.isInteger(s.route) && s.route >= 0 && s.route <= 2 && typeof s.closing === 'boolean' && typeof s.name_hex === 'string' && /^(?:[0-9a-f]{2}){0,16}$/.test(s.name_hex));\n"
|
||||
"}\n"
|
||||
"async function sshRequest(action, snapshotRead = false) {\n"
|
||||
" if (settingsDomain !== 'ssh' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || sshAbort || (action && sshPending)) return;\n"
|
||||
" let body; const detail = element(snapshotRead ? 'ssh-detail' : 'ssh-operation-detail');\n"
|
||||
" if (action) {\n"
|
||||
" if (!sshActions.includes(action) || !sshSnapshot || sshSnapshot.transitioning || sshSnapshot.generation === 4294967295) return;\n"
|
||||
" const target = Number(element('ssh-target').value), row = sshSnapshot.sessions.find(s => s.id === target);\n"
|
||||
" if (action === 'disconnect' && (!sshSelection || sshSelection.stale || !row || row.closing || row.state === 3 || sshSelection.target !== target || sshSelection.generation !== sshSnapshot.generation)) return;\n"
|
||||
" if ((action === 'start' && sshSnapshot.running) || (action === 'stop' && !sshSnapshot.running)) return;\n"
|
||||
" const value = {action, generation: action === 'disconnect' ? sshSelection.generation : sshSnapshot.generation, target: action === 'disconnect' ? target : 0};\n"
|
||||
" const scope = action === 'disconnect' ? 'Disconnect only SSH session ' + sshLabel(row) + '?' : action === 'stop' ? 'Stop SSH and close ALL SSH sessions, including sessions admitted before execution?' : 'Start the SSH listener on TCP port 22?';\n"
|
||||
" if (!window.confirm(scope + ' Settings and host identity are unchanged. HTTPS, Wi-Fi, USB and UART0 remain available. Already executing SSH commands may finish.')) return;\n"
|
||||
" body = JSON.stringify(value);\n"
|
||||
" }\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; sshAbort = controller; sshButtons();\n"
|
||||
" const current = () => sshAbort === controller && settingsDomain === 'ssh' && selected === 'settings';\n"
|
||||
" const deadline = window.setTimeout(() => { if (!current()) return; controller.abort(); sshAbort = null; sshSnapshot = null; if (sshSelection) sshSelection.stale = true; detail.textContent = 'Request timed out. Outcome may be unknown. Check Result and Refresh; no automatic retry.'; sshButtons(); }, 15000);\n"
|
||||
" controller.signal.addEventListener('abort', () => window.clearTimeout(deadline), {once:true});\n"
|
||||
" detail.textContent = snapshotRead ? 'Reading SSH; previous snapshot is stale until refreshed.' : 'Reading/submitting once. No automatic mutation retry.';\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
||||
" if (action) { sshPending = true; sshAwaitingAck = true; if (sshSelection) sshSelection.stale = true; }\n"
|
||||
" const {status, payload: v} = await api(snapshotRead ? '/api/settings/ssh' : '/api/settings/ssh-operation', generation, {method: action ? 'POST' : 'GET', body, signal: controller.signal, limit: snapshotRead ? 768 : 96, current});\n"
|
||||
" if (snapshotRead) {\n"
|
||||
" if (status !== 200 || !sshValid(v)) throw new Error('Invalid SSH snapshot');\n"
|
||||
" sshSnapshot = v;\n"
|
||||
" if (sshSelection && (sshSelection.generation !== v.generation || v.transitioning || !v.sessions.some(s => s.id === sshSelection.target && s.route === sshSelection.route && s.name_hex === sshSelection.name_hex && !s.closing && s.state !== 3))) sshSelection.stale = true;\n"
|
||||
" const list = element('ssh-values'); list.textContent = '';\n"
|
||||
" for (let i = 0; i < 2; ++i) {\n"
|
||||
" const s = v.sessions[i], o = element('ssh-option-' + i); o.hidden = !s; o.disabled = !s || s.closing || s.state === 3; o.value = s ? String(s.id) : ''; o.textContent = s ? sshLabel(s) : '';\n"
|
||||
" if (s) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = sshLabel(s); dd.textContent = s.closing ? 'Close requested' : ['Free','Handshake','Active','Closing'][s.state]; list.appendChild(dt); list.appendChild(dd); }\n"
|
||||
" }\n"
|
||||
" element('ssh-target').value = sshSelection && !sshSelection.stale ? String(sshSelection.target) : '';\n"
|
||||
" detail.textContent = (v.running ? 'SSH running' : 'SSH stopped') + (v.transitioning ? ' — transitioning or cleanup pending; use the admin shell for recovery.' : '') + '. ' + v.sessions.length + ' sessions. Refresh never renews selected target identity. ' + (sshSelection?.stale ? 'Selection stale; explicitly reselect.' : '') + (v.generation === 4294967295 ? ' Control generation exhausted; use the admin shell.' : '');\n"
|
||||
" } else {\n"
|
||||
" if (status !== (action ? 202 : 200) || !v || Object.keys(v).length !== 3 || !brokerUint(v.id) || !['none',...sshActions].includes(v.action) || !['idle','pending','ok','failed','cancelled','conflict'].includes(v.state) || ((v.id === 0) !== (v.state === 'idle')) || ((v.id === 0) !== (v.action === 'none')) || (action && (!v.id || v.action !== action || v.state !== 'pending')) || (!action && sshId && sshId === v.id && sshAction !== v.action)) throw new Error('Invalid SSH result');\n"
|
||||
" const warning = !action && sshAwaitingAck ? 'Acknowledgement was lost; latest result may belong to another tab or earlier operation. ' : !action && sshId && sshId !== v.id ? 'Previous result replaced/unavailable; previous outcome unknown. ' : '';\n"
|
||||
" sshId = v.id; sshAction = v.action; sshPending = v.state === 'pending'; sshAwaitingAck = false; sshSnapshot = null; if (sshSelection) sshSelection.stale = true;\n"
|
||||
" const messages = {idle:'No retained result; inspect SSH before retrying.',pending:'Queued or executing. Select Check Operation Result; do not resubmit.',ok:'Accepted/completed at execution time. Disconnect acknowledgement is an owner close request, not proof of peer closure. Refresh to inspect.',failed:'Failed or lifecycle timed out. Admitted work may still finish. Refresh and inspect before retrying.',conflict:'Service changed, unavailable, or target absent/closing. No action admitted; Refresh and reselect.',cancelled:'Rejected before execution: login/currentness or queue deadline expired.'};\n"
|
||||
" detail.textContent = warning + v.action + ': ' + messages[v.state];\n"
|
||||
" element('ssh-detail').textContent = 'Snapshot stale. Select Refresh to inspect current service state.';\n"
|
||||
" }\n"
|
||||
" } catch (error) {\n"
|
||||
" if (live(generation) && current()) { sshSnapshot = null; if (sshSelection) sshSelection.stale = true; detail.textContent = (error.status ? error.message : 'SSH request unavailable or outcome unknown.') + ' Check Result and Refresh before any explicit retry. No automatic retry.'; }\n"
|
||||
" } finally { window.clearTimeout(deadline); if (current()) { sshAbort = null; sshButtons(); } }\n"
|
||||
"}\n"
|
||||
"function refreshSsh() { return sshRequest(null, true); }\n"
|
||||
"element('settings-ssh').addEventListener('click', () => selectSettingsDomain('ssh'));\n"
|
||||
"element('ssh-refresh').addEventListener('click', refreshSsh);\n"
|
||||
"element('ssh-result').addEventListener('click', () => sshRequest(null));\n"
|
||||
"for (const action of sshActions) element('ssh-' + action).addEventListener('click', () => sshRequest(action));\n"
|
||||
"element('ssh-target').addEventListener('change', () => {\n"
|
||||
" const s = sshSnapshot?.sessions.find(s => s.id === Number(element('ssh-target').value));\n"
|
||||
" sshSelection = s && !s.closing && s.state !== 3 && !sshAbort && !sshPending && !sshSnapshot.transitioning ? {target:s.id, generation:sshSnapshot.generation, route:s.route, name_hex:s.name_hex, stale:false} : null; sshButtons();\n"
|
||||
"});\n"
|
||||
"let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n"
|
||||
"let keysAbort = null, accountKeys = [], keysIdentity = '';\n"
|
||||
"function keyIdentity() { const t = accounts[Number(element('account-target').value)]; return t ? JSON.stringify([t.username,t.user_id,t.auth_generation]) : ''; }\n"
|
||||
@@ -962,6 +1043,7 @@ static const char s_app_js[] =
|
||||
" clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\n"
|
||||
" element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts'; element('network-settings').hidden = domain !== 'network';\n"
|
||||
" element('display-settings').hidden = domain !== 'display'; element('settings-display').setAttribute('aria-pressed', String(domain === 'display'));\n"
|
||||
" element('ssh-settings').hidden = domain !== 'ssh'; element('settings-ssh').setAttribute('aria-pressed', String(domain === 'ssh'));\n"
|
||||
" element('broker-settings').hidden = domain !== 'broker'; element('settings-broker').setAttribute('aria-pressed', String(domain === 'broker'));\n"
|
||||
" element('settings-network').setAttribute('aria-pressed', String(domain === 'network'));\n"
|
||||
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
|
||||
|
||||
@@ -31,6 +31,8 @@ typedef int *SemaphoreHandle_t;
|
||||
#define pdMS_TO_TICKS(x) (x)
|
||||
#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U
|
||||
static unsigned lock_depth, ticks, runs, actions;
|
||||
static uint32_t ssh_settings_executed;
|
||||
static void web_ssh_settings_execute(uint32_t id) { assert(!lock_depth); ssh_settings_executed = id; }
|
||||
static uint32_t broker_settings_executed;
|
||||
static void web_broker_settings_execute(uint32_t id) { assert(!lock_depth); broker_settings_executed = id; }
|
||||
static uint32_t serial_settings_executed, account_settings_executed, network_settings_executed, display_settings_executed;
|
||||
|
||||
@@ -394,5 +394,20 @@ int main(void)
|
||||
assert(broker_settings_executed == 51 && display_settings_executed == 52 && network_settings_executed == 53 && account_settings_executed == 54);
|
||||
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
|
||||
puts("PASS: Broker typed IDs, not-ready/full queue, routing and unchanged dispatcher capacity");
|
||||
assert(admin_ssh_console_submit_ssh_settings(0) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = false;
|
||||
assert(admin_ssh_console_submit_ssh_settings(1) == ESP_ERR_INVALID_STATE);
|
||||
s_dispatch_ready = true; queue_full = true;
|
||||
assert(admin_ssh_console_submit_ssh_settings(1) == ESP_ERR_TIMEOUT && queue_send_wait == 0);
|
||||
queue_full = false;
|
||||
assert(admin_ssh_console_submit_ssh_settings(61) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_broker_settings(62) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_network_settings(63) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_account_settings(64) == ESP_OK);
|
||||
assert(admin_ssh_console_submit_ssh_settings(65) == ESP_ERR_TIMEOUT);
|
||||
pump(worker_task);
|
||||
assert(ssh_settings_executed == 61 && broker_settings_executed == 62 && network_settings_executed == 63 && account_settings_executed == 64);
|
||||
assert(runs == before_serial + 5 && s_request_queue->capacity == 4);
|
||||
puts("PASS: SSH typed ID dispatcher routing, zero-wait/full/not-ready, no command runner or capacity growth");
|
||||
puts("PASS: admission/identity, two owners, completion contention/reopen, history, queued stale/revoked work, UART dispatch, hidden/disconnected prompts, exit-to-SELF_CLOSE, deferred rejection/drain/close, 5s output backpressure");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exact SSH management/lifecycle/close/slot-selection functions, deterministic RTOS.
|
||||
No wolfSSH, sockets, real scheduling or target execution is claimed.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
source = (ROOT / 'src/ssh_transport.c').read_text()
|
||||
def function(name):
|
||||
match = re.search(r'^(?:static )?[^\n]+\b' + name + r'\([^;]*?\n\{.*?^\}', source, re.M | re.S)
|
||||
assert match, name
|
||||
return match.group() + '\n'
|
||||
header = '\n'.join(line for line in (ROOT / 'src/ssh_transport.h').read_text().splitlines() if not line.startswith(('#include', '#pragma once')))
|
||||
constants = '\n'.join(re.search(r'^#define ' + name + r' .+$', source, re.M).group() for name in ('SSH_TRANSPORT_GENERATION_MAX', 'SSH_TRANSPORT_COMMAND_TIMEOUT_MS', 'SSH_TRANSPORT_MAX_PENDING_HANDSHAKES'))
|
||||
username = re.search(r'^#define USER_DATABASE_USERNAME_CAPACITY .+$', (ROOT / 'src/user_database.h').read_text(), re.M).group()
|
||||
fakes = r'''
|
||||
#include <assert.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
typedef int esp_err_t;
|
||||
enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE, ESP_ERR_TIMEOUT, ESP_ERR_NOT_FOUND };
|
||||
typedef uint32_t session_broker_client_id_t;
|
||||
typedef int user_role_t;
|
||||
typedef int user_auth_method_t;
|
||||
'''
|
||||
state = r'''
|
||||
typedef struct { ssh_transport_session_state_t state; uint32_t generation, session_id; } ssh_slot_t;
|
||||
static ssh_slot_t s_slots[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static ssh_transport_session_snapshot_t s_session_snapshots[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static uint32_t s_external_close_id[SSH_TRANSPORT_MAX_SESSIONS];
|
||||
static unsigned depth, mutex_storage, notifications, ticks;
|
||||
static unsigned *s_command_mutex = &mutex_storage;
|
||||
static bool s_initialized, s_running, s_transitioning, s_cleanup_pending, s_desired_running;
|
||||
static uint32_t s_management_generation, s_requested_sequence, s_completed_sequence;
|
||||
static int s_command_result;
|
||||
static bool owner_stalled, owner_fail;
|
||||
static int s_lock;
|
||||
#define taskENTER_CRITICAL(p) do { (void)(p); assert(depth++ == 0); } while(0)
|
||||
#define taskEXIT_CRITICAL(p) do { (void)(p); assert(--depth == 0); } while(0)
|
||||
#define pdTRUE 1
|
||||
#define portMAX_DELAY 99999U
|
||||
#define pdMS_TO_TICKS(n) (n)
|
||||
typedef unsigned TickType_t;
|
||||
static int xSemaphoreTake(unsigned *m, unsigned wait) { assert(!depth); (void)wait; if (*m) return 0; *m = 1; return 1; }
|
||||
static void xSemaphoreGive(unsigned *m) { assert(!depth && *m); *m = 0; }
|
||||
static void notify_task(void) { assert(!depth); ++notifications; }
|
||||
static unsigned xTaskGetTickCount(void) { return ticks; }
|
||||
static void vTaskDelay(unsigned n) {
|
||||
assert(!depth && mutex_storage); ticks += n;
|
||||
if (!owner_stalled) {
|
||||
s_completed_sequence = s_requested_sequence; s_command_result = owner_fail ? ESP_FAIL : ESP_OK;
|
||||
s_running = owner_fail ? false : s_desired_running; s_transitioning = false; s_cleanup_pending = owner_fail;
|
||||
}
|
||||
}
|
||||
'''
|
||||
tests = r'''
|
||||
static void reset(void) {
|
||||
memset(s_slots, 0, sizeof(s_slots)); memset(s_session_snapshots, 0, sizeof(s_session_snapshots));
|
||||
memset(s_external_close_id, 0, sizeof(s_external_close_id));
|
||||
s_initialized = s_running = true; s_transitioning = s_cleanup_pending = owner_stalled = owner_fail = false;
|
||||
mutex_storage = notifications = ticks = 0; s_management_generation = 7; s_requested_sequence = s_completed_sequence = 0;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
s_slots[i] = (ssh_slot_t){SSH_TRANSPORT_SESSION_ACTIVE, 2, make_session_id(i, 2)};
|
||||
s_session_snapshots[i] = (ssh_transport_session_snapshot_t){.active=true, .session_id=s_slots[i].session_id, .generation=2, .state=SSH_TRANSPORT_SESSION_ACTIVE};
|
||||
}
|
||||
}
|
||||
int main(void) {
|
||||
reset(); ssh_transport_management_snapshot_t v;
|
||||
assert(ssh_transport_get_management_snapshot(NULL) == ESP_ERR_INVALID_ARG);
|
||||
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.generation == 7 && v.running && !v.transitioning);
|
||||
assert(!notifications && !depth && !mutex_storage);
|
||||
s_initialized = false; assert(ssh_transport_get_management_snapshot(&v) == ESP_ERR_INVALID_STATE); s_initialized = true;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_OK);
|
||||
assert(s_external_close_id[0] == 9 && !s_external_close_id[1] && notifications == 1);
|
||||
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.sessions[0].close_requested && !v.sessions[1].close_requested);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_NOT_FOUND);
|
||||
assert(consume_external_close(&s_slots[0], 0) && !s_external_close_id[0] && s_session_snapshots[0].close_requested);
|
||||
assert(!consume_external_close(&s_slots[1], 1));
|
||||
puts("PASS SSH atomic published snapshot/target close, duplicate rejection and unrelated-slot isolation");
|
||||
reset();
|
||||
s_external_close_id[0] = 5; assert(!consume_external_close(&s_slots[0], 0));
|
||||
s_slots[0].state = SSH_TRANSPORT_SESSION_FREE; assert(!consume_external_close(&s_slots[0], 0) && !s_external_close_id[0]);
|
||||
s_session_snapshots[0].active = false;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_NOT_FOUND);
|
||||
s_session_snapshots[0].active = true; s_session_snapshots[0].session_id = 13;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_NOT_FOUND);
|
||||
assert(!s_external_close_id[0] && !notifications);
|
||||
size_t index; s_slots[0].generation = SSH_TRANSPORT_GENERATION_MAX; s_slots[1].state = SSH_TRANSPORT_SESSION_FREE;
|
||||
assert(find_free_slot(&index) == &s_slots[1] && index == 1);
|
||||
s_slots[1].generation = SSH_TRANSPORT_GENERATION_MAX; assert(find_free_slot(&index) == NULL);
|
||||
assert(make_session_id(1, SSH_TRANSPORT_GENERATION_MAX) != 0);
|
||||
puts("PASS SSH disconnect/reuse/late owner close safety and generation exhaustion retires slots");
|
||||
reset();
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 6) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 7) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 9, 7) == ESP_ERR_INVALID_ARG);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 0, 7) == ESP_ERR_INVALID_ARG);
|
||||
assert(ssh_transport_manage_current(99, 0, 7) == ESP_ERR_INVALID_ARG);
|
||||
mutex_storage = 1; assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_ERR_TIMEOUT); mutex_storage = 0;
|
||||
s_transitioning = true; assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_INVALID_STATE); s_transitioning = false;
|
||||
assert(!notifications);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_OK && !s_running && s_management_generation == 8);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 7) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 8) == ESP_OK && s_running && s_management_generation == 9);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_DISCONNECT, 9, 7) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_stop() == ESP_OK && s_management_generation == 10);
|
||||
assert(ssh_transport_start() == ESP_OK && s_management_generation == 11);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 9) == ESP_ERR_INVALID_STATE);
|
||||
puts("PASS SSH conditional start/stop, command mutex, CLI transitions and stop/start ABA fencing");
|
||||
reset(); owner_stalled = true;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_ERR_TIMEOUT && s_transitioning && !mutex_storage && s_management_generation == 8);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 8) == ESP_ERR_INVALID_STATE);
|
||||
assert(ssh_transport_get_management_snapshot(&v) == ESP_OK && v.transitioning);
|
||||
reset(); owner_fail = true;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, 7) == ESP_FAIL && s_cleanup_pending);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, 8) == ESP_ERR_INVALID_STATE);
|
||||
owner_fail = false; assert(ssh_transport_stop() == ESP_OK && !s_cleanup_pending);
|
||||
s_management_generation = UINT32_MAX - 1;
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_START, 0, UINT32_MAX - 1) == ESP_OK && s_management_generation == UINT32_MAX);
|
||||
assert(ssh_transport_manage_current(SSH_TRANSPORT_MANAGE_STOP, 0, UINT32_MAX) == ESP_ERR_INVALID_ARG);
|
||||
assert(ssh_transport_stop() == ESP_OK && s_management_generation == UINT32_MAX);
|
||||
puts("PASS SSH admitted timeout is not cancellation; failed cleanup and saturated versions preserve CLI recovery");
|
||||
}
|
||||
'''
|
||||
names = ('next_generation', 'make_session_id', 'consume_external_close', 'find_free_slot', 'request_running_locked', 'request_running', 'ssh_transport_start', 'ssh_transport_stop', 'ssh_transport_get_management_snapshot', 'ssh_transport_manage_current')
|
||||
# Guard the accept path, which is not executed with the socket double here.
|
||||
assert 'uint32_t generation = slot->generation + 1U;' in function('accept_connections')
|
||||
assert 'next_generation(slot->generation)' not in source
|
||||
with tempfile.TemporaryDirectory(prefix='ssh-management-') as directory:
|
||||
tmp = Path(directory)
|
||||
unit = fakes + username + '\n' + header + '\n' + constants + '\n' + state + '\n'.join(function(n) for n in names) + tests
|
||||
(tmp / 'test.c').write_text(unit)
|
||||
subprocess.run(['cc', '-std=c11', '-Wall', '-Wextra', '-Werror', str(tmp / 'test.c'), '-o', str(tmp / 'test')], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / 'test')], check=True, timeout=10)
|
||||
@@ -37,8 +37,8 @@ def define(path, name):
|
||||
uri_tables = re.findall(r'^static const httpd_uri_t(?: \*const)? \w+\[?\]? = \{.*?^\};',
|
||||
source, re.M | re.S)
|
||||
# Non-array declarations have no brackets; explicit shape avoids silent omission.
|
||||
if len(uri_tables) != 30:
|
||||
raise RuntimeError('Review URI extraction: expected 28 descriptors and two tables')
|
||||
if len(uri_tables) != 33:
|
||||
raise RuntimeError('Review URI extraction: expected 31 descriptors and two tables')
|
||||
state = source[source.index('static SemaphoreHandle_t s_server_mutex;'):
|
||||
source.index('static esp_err_t ensure_mutex(void)')]
|
||||
header = (ROOT / 'src/web_server.h').read_text()
|
||||
@@ -154,6 +154,26 @@ static esp_err_t display_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
registered[registered_count++] = uri;
|
||||
return ESP_OK;
|
||||
}
|
||||
HANDLER(web_ssh_settings_handler) HANDLER(web_ssh_operation_handler)
|
||||
static unsigned ssh_calls, ssh_allocations, ssh_fail_at;
|
||||
static esp_err_t ssh_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(s == SERVER && auth_live && ssl_live && !locked);
|
||||
assert(!uri->is_websocket && !uri->handle_ws_control_frames && !uri->user_ctx);
|
||||
++ssh_calls;
|
||||
if (ssh_calls == 1) {
|
||||
assert(!strcmp(uri->uri, "/api/settings/ssh") && uri->method == HTTP_GET);
|
||||
assert(uri->handler == web_ssh_settings_handler);
|
||||
} else {
|
||||
assert(!strcmp(uri->uri, "/api/settings/ssh-operation"));
|
||||
assert(uri->method == (ssh_calls == 2 ? HTTP_GET : HTTP_POST));
|
||||
assert(uri->handler == web_ssh_operation_handler && ssh_calls <= 3);
|
||||
}
|
||||
/* Model the adapter's staged descriptor/name allocations, before publication. */
|
||||
for (unsigned allocation = 0; allocation < 2; ++allocation)
|
||||
if (++ssh_allocations == ssh_fail_at) return ESP_ERR_NO_MEM;
|
||||
registered[registered_count++] = uri;
|
||||
return ESP_OK;
|
||||
}
|
||||
HANDLER(web_broker_settings_handler) HANDLER(web_broker_operation_handler)
|
||||
static unsigned broker_calls, broker_allocations, broker_fail_at;
|
||||
static esp_err_t broker_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
@@ -209,7 +229,7 @@ static esp_err_t web_security_copy_tls_material(uint8_t *cert, size_t nc, size_t
|
||||
static esp_err_t httpd_ssl_start(httpd_handle_t *server, const httpd_ssl_config_t *config) {
|
||||
assert(!locked && auth_live && !ssl_live); ++ssl_starts;
|
||||
assert(config->httpd.max_open_sockets == 6 && !config->httpd.lru_purge_enable);
|
||||
assert(config->httpd.max_uri_handlers == 33 && config->port_secure == 443);
|
||||
assert(config->httpd.max_uri_handlers == 36 && config->port_secure == 443);
|
||||
assert(config->httpd.recv_wait_timeout == 1 && config->httpd.send_wait_timeout == 1);
|
||||
assert(config->tls_handshake_timeout_ms == 5000);
|
||||
assert(config->user_cb == tls_session_callback);
|
||||
@@ -247,6 +267,7 @@ static esp_err_t account_register(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
}
|
||||
static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
assert(uri->method == HTTP_GET);
|
||||
if (uri->handler == web_ssh_settings_handler || uri->handler == web_ssh_operation_handler) return ssh_register(s, uri);
|
||||
if (uri->handler == web_broker_settings_handler || uri->handler == web_broker_operation_handler) return broker_register(s, uri);
|
||||
if (uri->handler == web_display_settings_handler || uri->handler == web_display_operation_handler) return display_register(s, uri);
|
||||
if (uri->handler == web_network_snapshot_handler || uri->handler == web_network_operation_handler)
|
||||
@@ -256,6 +277,7 @@ static esp_err_t web_httpd_register_optional_get(httpd_handle_t s, const httpd_u
|
||||
return httpd_register_uri_handler(s, uri);
|
||||
}
|
||||
static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t *uri) {
|
||||
if (uri->handler == web_ssh_settings_handler || uri->handler == web_ssh_operation_handler) return ssh_register(s, uri);
|
||||
if (uri->handler == web_broker_settings_handler || uri->handler == web_broker_operation_handler) return broker_register(s, uri);
|
||||
if (uri->handler == web_display_operation_handler) return display_register(s, uri);
|
||||
if (uri->handler == web_network_operation_handler) return network_register(s, uri);
|
||||
@@ -284,7 +306,7 @@ static esp_err_t web_httpd_register_optional(httpd_handle_t s, const httpd_uri_t
|
||||
static esp_err_t httpd_unregister_uri_handler(httpd_handle_t s, const char *uri, int method) {
|
||||
assert(!locked && s == SERVER && ssl_live && auth_live && serial_live);
|
||||
assert((registration_calls == 18 && !strcmp(uri, "/api/admin/ws-ticket") && method == HTTP_POST) ||
|
||||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation") || !strcmp(uri, "/api/settings/broker-operation")) && method == HTTP_GET));
|
||||
((!strcmp(uri, "/api/settings/serial-operation") || !strcmp(uri, "/api/settings/account-operation") || !strcmp(uri, "/api/settings/network-operation") || !strcmp(uri, "/api/settings/display-operation") || !strcmp(uri, "/api/settings/broker-operation") || !strcmp(uri, "/api/settings/ssh-operation")) && method == HTTP_GET));
|
||||
++unregister_calls;
|
||||
for (unsigned i = 0; i < registered_count; ++i) {
|
||||
if (!strcmp(registered[i]->uri, uri) && registered[i]->method == method) {
|
||||
@@ -355,6 +377,7 @@ static void reset(void) {
|
||||
network_calls = network_allocations = network_fail_at = 0;
|
||||
display_calls = display_allocations = display_fail_at = 0;
|
||||
broker_calls = broker_allocations = broker_fail_at = 0;
|
||||
ssh_calls = ssh_allocations = ssh_fail_at = 0;
|
||||
account_calls = account_fail_at = generation_calls = keys_calls = 0;
|
||||
generation_fail = keys_fail = false;
|
||||
}
|
||||
@@ -363,6 +386,7 @@ static void fresh_registration(void) {
|
||||
network_calls = network_allocations = 0;
|
||||
display_calls = display_allocations = 0;
|
||||
broker_calls = broker_allocations = 0;
|
||||
ssh_calls = ssh_allocations = 0;
|
||||
}
|
||||
static void start(void) {
|
||||
assert(web_server_start() == ESP_OK);
|
||||
@@ -392,6 +416,14 @@ static void display_complete(void) {
|
||||
assert(r && r->handler == web_display_operation_handler);
|
||||
}
|
||||
}
|
||||
static void ssh_complete(void) {
|
||||
assert(ssh_calls == 3 && ssh_allocations == 6);
|
||||
assert(route("/api/settings/ssh")->handler == web_ssh_settings_handler);
|
||||
for (int method = HTTP_GET; method <= HTTP_POST; ++method) {
|
||||
const httpd_uri_t *r = method_route("/api/settings/ssh-operation", method);
|
||||
assert(r && r->handler == web_ssh_operation_handler);
|
||||
}
|
||||
}
|
||||
static void broker_complete(void) {
|
||||
assert(broker_calls == 3 && broker_allocations == 6);
|
||||
assert(route("/api/settings/broker")->handler == web_broker_settings_handler);
|
||||
@@ -442,7 +474,7 @@ int main(void) {
|
||||
}
|
||||
puts("PASS optional admin init/attach failures do not disable M1 auth or serial attachment");
|
||||
|
||||
reset(); start(); assert(registered_count == 33 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
reset(); start(); assert(registered_count == 36 && registration_calls == 18 && settings_calls == 1 && operation_calls == 2);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
@@ -496,7 +528,7 @@ int main(void) {
|
||||
assert(s_serial_transport_attached && !s_admin_transport_owned && !admin_owned);
|
||||
assert(!admin_inits && !admin_attaches && !auth_stops && !ssl_stops);
|
||||
assert(!s_transitioning && s_last_error == ESP_OK && s_counters.starts == 1 && !s_counters.start_failures);
|
||||
assert(registered_count == 31 && unregister_calls == failure - 17);
|
||||
assert(registered_count == 34 && unregister_calls == failure - 17);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/admin/ws-ticket") && strcmp(registered[i]->uri, "/ws/admin"));
|
||||
assert(route("/ws/serial")->handler == traced_websocket_handler);
|
||||
@@ -505,13 +537,13 @@ int main(void) {
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||
assert(!admin_detaches && !admin_stoppeds);
|
||||
registration_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(registered_count == 36 && admin_attaches == 1 && s_counters.starts == 2);
|
||||
assert(web_server_stop() == ESP_OK && admin_stoppeds == 1);
|
||||
}
|
||||
puts("PASS optional positions 17..18 preserve M1, roll back ticket when needed and recover after stop/restart");
|
||||
|
||||
reset(); registration_fail_at = 18; unregister_fail = true;
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 32);
|
||||
assert(web_server_start() == ESP_OK && unregister_calls == 1 && registered_count == 35);
|
||||
assert(auth_live && ssl_live && serial_live && s_serial_transport_attached);
|
||||
assert(!admin_inits && !admin_attaches && !admin_owned && !s_admin_transport_owned);
|
||||
ticket = route("/api/admin/ws-ticket");
|
||||
@@ -523,7 +555,7 @@ int main(void) {
|
||||
clear_events(); assert(web_server_stop() == ESP_OK && !strcmp(events, "ASH"));
|
||||
assert(!admin_detaches && !admin_stoppeds);
|
||||
unregister_fail = false; registration_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
assert(registered_count == 36 && admin_attaches == 1 && web_server_stop() == ESP_OK);
|
||||
puts("PASS failed unregister retains only original ticket handler, no admin attachment, and permits restart");
|
||||
|
||||
reset(); registration_fail_at = 6; ssl_stop_error = ESP_FAIL;
|
||||
@@ -545,7 +577,7 @@ int main(void) {
|
||||
assert(web_server_stop() == ESP_ERR_INVALID_STATE && !auth_stops);
|
||||
puts("PASS auth/start failure gates and invalid/transitioning lifecycle rejection");
|
||||
reset(); settings_fail = true; start();
|
||||
assert(settings_calls == 1 && registered_count == 32);
|
||||
assert(settings_calls == 1 && registered_count == 35);
|
||||
assert(auth_live && serial_live && admin_owned && web_server_stop() == ESP_OK);
|
||||
settings_fail = false; fresh_registration(); start();
|
||||
assert(route("/api/settings/serial")->handler == serial_settings_handler);
|
||||
@@ -553,7 +585,7 @@ int main(void) {
|
||||
puts("PASS optional Settings registration failure preserves auth and both transports; restart recovers");
|
||||
for (unsigned failure = 1; failure <= 2; ++failure) {
|
||||
reset(); operation_fail_at = failure; start();
|
||||
assert(registered_count == 31 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(registered_count == 34 && operation_calls == failure && unregister_calls == failure - 1);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i) assert(strcmp(registered[i]->uri, "/api/settings/serial-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
@@ -561,7 +593,7 @@ int main(void) {
|
||||
puts("PASS optional Serial operation GET/POST failure never publishes a mutation-only route or disables transports");
|
||||
for (unsigned failure = 1; failure <= 3; ++failure) {
|
||||
reset(); account_calls = 0; account_fail_at = failure; start();
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 30 : 31));
|
||||
assert(account_calls == failure && registered_count == (failure == 1 ? 33 : 34));
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(generation_calls == 1 && route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(auth_live && serial_live && admin_owned);
|
||||
@@ -569,17 +601,17 @@ int main(void) {
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation"));
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
account_fail_at = 0; account_calls = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33 && account_calls == 3);
|
||||
assert(registered_count == 36 && account_calls == 3);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
reset(); account_calls = 0; account_fail_at = 3; unregister_fail = true; start();
|
||||
assert(registered_count == 32 && auth_live && serial_live && admin_owned);
|
||||
assert(registered_count == 35 && auth_live && serial_live && admin_owned);
|
||||
for (unsigned i = 0; i < registered_count; ++i)
|
||||
assert(strcmp(registered[i]->uri, "/api/settings/account-operation") || registered[i]->method == HTTP_GET);
|
||||
assert(web_server_stop() == ESP_OK); account_fail_at = 0;
|
||||
puts("PASS optional Accounts list/result/mutation allocation failures preserve transports and never expose mutation without reads (including failed unregister)");
|
||||
reset(); generation_fail = true; start();
|
||||
assert(generation_calls == 1 && registered_count == 32 && account_calls == 3);
|
||||
assert(generation_calls == 1 && registered_count == 35 && account_calls == 3);
|
||||
assert(keys_calls == 1 && route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
@@ -591,12 +623,12 @@ int main(void) {
|
||||
}
|
||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||
generation_fail = false; fresh_registration(); start();
|
||||
assert(generation_calls == 2 && registered_count == 33);
|
||||
assert(generation_calls == 2 && registered_count == 36);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS optional password generation allocation failure preserves account routes/auth/transports; restart recovers");
|
||||
reset(); keys_fail = true; start();
|
||||
assert(keys_calls == 1 && registered_count == 32 && account_calls == 3 && generation_calls == 1);
|
||||
assert(keys_calls == 1 && registered_count == 35 && account_calls == 3 && generation_calls == 1);
|
||||
assert(!auth_stops && !ssl_stops && !unregister_calls && !s_counters.start_failures);
|
||||
assert(route("/api/settings/accounts")->handler == web_account_settings_handler);
|
||||
assert(route("/api/settings/accounts/generate-password")->handler == web_account_generate_password_handler);
|
||||
@@ -611,7 +643,7 @@ int main(void) {
|
||||
}
|
||||
assert(account_mutations == 1 && web_server_stop() == ESP_OK);
|
||||
keys_fail = false; fresh_registration(); start();
|
||||
assert(keys_calls == 2 && registered_count == 33);
|
||||
assert(keys_calls == 2 && registered_count == 36);
|
||||
assert(route("/api/settings/accounts/keys")->handler == web_account_keys_handler);
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
puts("PASS optional account keys allocation failure preserves account/generation/auth/transports; restart recovers");
|
||||
@@ -636,7 +668,7 @@ int main(void) {
|
||||
reset(); network_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(network_calls == failed_route && network_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 30 : 31));
|
||||
assert(registered_count == (failed_route == 1 ? 33 : 34));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||
@@ -644,13 +676,13 @@ int main(void) {
|
||||
other_domains_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
network_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33); network_complete();
|
||||
assert(registered_count == 36); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Network descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); network_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 32 && unregister_calls == 1);
|
||||
assert(registered_count == 35 && unregister_calls == 1);
|
||||
assert(route("/api/settings/network")->handler == web_network_snapshot_handler);
|
||||
assert(method_route("/api/settings/network-operation", HTTP_GET)->handler == web_network_operation_handler);
|
||||
assert(!method_route("/api/settings/network-operation", HTTP_POST));
|
||||
@@ -660,7 +692,7 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; network_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33); network_complete();
|
||||
assert(registered_count == 36); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Network result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
@@ -668,7 +700,7 @@ int main(void) {
|
||||
reset(); display_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(display_calls == failed_route && display_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 30 : 31));
|
||||
assert(registered_count == (failed_route == 1 ? 33 : 34));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/display-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
||||
@@ -676,13 +708,13 @@ int main(void) {
|
||||
other_domains_complete(); network_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
display_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33); display_complete();
|
||||
assert(registered_count == 36); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Display descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); display_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 32 && unregister_calls == 1);
|
||||
assert(registered_count == 35 && unregister_calls == 1);
|
||||
assert(route("/api/settings/display")->handler == web_display_settings_handler);
|
||||
assert(method_route("/api/settings/display-operation", HTTP_GET)->handler == web_display_operation_handler);
|
||||
assert(!method_route("/api/settings/display-operation", HTTP_POST));
|
||||
@@ -692,7 +724,7 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; display_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33); display_complete();
|
||||
assert(registered_count == 36); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Display result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
@@ -700,7 +732,7 @@ int main(void) {
|
||||
reset(); broker_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(broker_calls == failed_route && broker_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 30 : 31));
|
||||
assert(registered_count == (failed_route == 1 ? 33 : 34));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/broker-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
|
||||
@@ -708,13 +740,13 @@ int main(void) {
|
||||
other_domains_complete(); network_complete(); display_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
broker_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33); broker_complete();
|
||||
assert(registered_count == 36); broker_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six Broker descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); broker_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 32 && unregister_calls == 1);
|
||||
assert(registered_count == 35 && unregister_calls == 1);
|
||||
assert(route("/api/settings/broker")->handler == web_broker_settings_handler);
|
||||
assert(method_route("/api/settings/broker-operation", HTTP_GET)->handler == web_broker_operation_handler);
|
||||
assert(!method_route("/api/settings/broker-operation", HTTP_POST));
|
||||
@@ -724,10 +756,42 @@ int main(void) {
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; broker_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 33); broker_complete();
|
||||
assert(registered_count == 36); broker_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed Broker result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
for (unsigned failure = 1; failure <= 6; ++failure) {
|
||||
reset(); ssh_fail_at = failure; start();
|
||||
unsigned failed_route = (failure + 1) / 2;
|
||||
assert(ssh_calls == failed_route && ssh_allocations == failure);
|
||||
assert(registered_count == (failed_route == 1 ? 33 : 34));
|
||||
assert(unregister_calls == (failed_route == 3 ? 1 : 0));
|
||||
assert(!method_route("/api/settings/ssh-operation", HTTP_GET));
|
||||
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
|
||||
assert(!!method_route("/api/settings/ssh", HTTP_GET) == (failed_route != 1));
|
||||
other_domains_complete(); network_complete(); display_complete(); broker_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
ssh_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 36); ssh_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS all six SSH descriptor/name allocation positions isolate failures and recover after restart");
|
||||
for (unsigned failure = 5; failure <= 6; ++failure) {
|
||||
reset(); ssh_fail_at = failure; unregister_fail = true; start();
|
||||
assert(registered_count == 35 && unregister_calls == 1);
|
||||
assert(route("/api/settings/ssh")->handler == web_ssh_settings_handler);
|
||||
assert(method_route("/api/settings/ssh-operation", HTTP_GET)->handler == web_ssh_operation_handler);
|
||||
assert(!method_route("/api/settings/ssh-operation", HTTP_POST));
|
||||
other_domains_complete(); network_complete(); display_complete(); broker_complete();
|
||||
ssl_stop_error = ESP_FAIL;
|
||||
assert(web_server_stop() == ESP_FAIL && s_server == SERVER);
|
||||
assert(web_server_start() == ESP_ERR_INVALID_STATE && ssl_starts == 1);
|
||||
ssl_stop_error = ESP_OK; assert(web_server_stop() == ESP_OK);
|
||||
unregister_fail = false; ssh_fail_at = 0; fresh_registration(); start();
|
||||
assert(registered_count == 36); ssh_complete();
|
||||
assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS failed SSH result unregister leaves reads only and preserves stop-failure ownership/restart");
|
||||
for (unsigned failure = 0; failure < 8; ++failure) {
|
||||
reset();
|
||||
if (failure == 0) settings_fail = true;
|
||||
@@ -735,10 +799,10 @@ int main(void) {
|
||||
else if (failure <= 5) account_fail_at = failure - 2;
|
||||
else if (failure == 6) generation_fail = true;
|
||||
else keys_fail = true;
|
||||
start(); network_complete(); display_complete(); broker_complete(); assert(web_server_stop() == ESP_OK);
|
||||
start(); network_complete(); display_complete(); broker_complete(); ssh_complete(); assert(web_server_stop() == ESP_OK);
|
||||
}
|
||||
puts("PASS every other settings route failure leaves the complete Network domain available");
|
||||
puts("25 lifecycle groups passed (16 required fatal positions, 19 optional routes, Network/Display/Broker allocation positions and failed unregister)");
|
||||
puts("27 lifecycle groups passed (16 required fatal positions, 22 optional routes, Network/Display/Broker/SSH allocation positions and failed unregister)");
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
@@ -60,6 +60,7 @@ settings = "--settings" in sys.argv
|
||||
serial_settings = "--serial-settings" in sys.argv
|
||||
accounts = "--accounts" in sys.argv
|
||||
broker = "--broker" in sys.argv
|
||||
ssh_settings = "--ssh" in sys.argv
|
||||
display = "--display" in sys.argv
|
||||
if display:
|
||||
HEADERS["nvs_flash.h"] = '#pragma once\n#include "esp_err.h"\nesp_err_t nvs_flash_init(void);\n'
|
||||
@@ -249,6 +250,7 @@ with tempfile.TemporaryDirectory(prefix="web-cookie-auth-") as directory:
|
||||
*(["-DHOST_NETWORK"] if network else []),
|
||||
*(["-DHOST_DISPLAY"] if display else []),
|
||||
*(["-DHOST_BROKER"] if broker else []),
|
||||
*(["-DHOST_SSH_SETTINGS"] if ssh_settings else []),
|
||||
"-I" + str(tmp), "-I" + str(ROOT / "src"), *map(str, sources), "-lcrypto",
|
||||
"-o", str(tmp / "test")], check=True, timeout=30)
|
||||
subprocess.run([str(tmp / "test")], check=True, timeout=20)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/* Production HTTP policy/store/settings; deterministic dispatcher/SSH owner doubles. */
|
||||
#include "../../src/web_ssh_settings.c"
|
||||
static bool on_dispatcher, queue_fail;
|
||||
static uint32_t queued_id;
|
||||
static unsigned mutations, snapshots;
|
||||
static esp_err_t owner_error;
|
||||
static ssh_transport_management_snapshot_t owner_snapshot;
|
||||
esp_err_t ssh_transport_get_management_snapshot(ssh_transport_management_snapshot_t *out) {
|
||||
assert(!host_lock_depth && !on_dispatcher); ++snapshots;
|
||||
*out = owner_snapshot; return owner_error;
|
||||
}
|
||||
esp_err_t ssh_transport_manage_current(ssh_transport_management_action_t action, uint32_t target, uint32_t generation) {
|
||||
assert(on_dispatcher && !host_lock_depth && generation == 7);
|
||||
assert(action <= SSH_TRANSPORT_MANAGE_DISCONNECT && target == (action == SSH_TRANSPORT_MANAGE_DISCONNECT ? 9U : 0U));
|
||||
++mutations; return owner_error;
|
||||
}
|
||||
esp_err_t admin_ssh_console_submit_ssh_settings(uint32_t id) {
|
||||
assert(id && !on_dispatcher && !host_lock_depth);
|
||||
if (queue_fail) return ESP_FAIL;
|
||||
queued_id = id; return ESP_OK;
|
||||
}
|
||||
static void operation_begin(const issued_t *identity, const char *body) {
|
||||
begin("/api/settings/ssh-operation", body ? HTTP_POST : HTTP_GET, body);
|
||||
same_origin(); if (body) add("Content-Type", "application/json");
|
||||
if (identity) {
|
||||
char cookie[100]; snprintf(cookie, sizeof(cookie), "__Host-sak-session=%s", identity->token);
|
||||
add("Cookie", cookie); if (body) add("X-CSRF-Token", identity->view.csrf);
|
||||
}
|
||||
}
|
||||
static void expect_ssh(const char *status, bool snapshot) {
|
||||
unsigned before = mutations;
|
||||
esp_err_t e = snapshot ? web_ssh_settings_handler(&req) : web_ssh_operation_handler(&req);
|
||||
assert(e == (send_fail || aux.remaining_len ? ESP_FAIL : ESP_OK));
|
||||
if (strcmp(response_status, status)) fprintf(stderr, "expected %s got %s: %s\n", status, response_status, output);
|
||||
assert(!strcmp(response_status, status) && mutations == before);
|
||||
assert(strlen(output) < (snapshot ? 768 : 96)); zero(scratch, sizeof(scratch));
|
||||
}
|
||||
static void execute(void) { on_dispatcher = true; web_ssh_settings_execute(queued_id); on_dispatcher = false; }
|
||||
static const char *disconnect_body = "{\"action\":\"disconnect\",\"generation\":7,\"target\":9}";
|
||||
static void submit(const issued_t *who) {
|
||||
operation_begin(who, disconnect_body); expect_ssh("202 Accepted", false); assert(s_operation.state == PENDING);
|
||||
}
|
||||
static void ssh_settings_tests(void) {
|
||||
auth_reset(); issued_t admin = mint(&alice), user = mint(&bob), other = mint(&alice);
|
||||
receive_fragment = 64;
|
||||
operation_begin(NULL, disconnect_body); expect_ssh("401 Unauthorized", false);
|
||||
operation_begin(&user, disconnect_body); expect_ssh("403 Forbidden", false);
|
||||
operation_begin(&user, NULL); expect_ssh("403 Forbidden", false);
|
||||
for (unsigned mode = 0; mode < 8; ++mode) {
|
||||
operation_begin(&admin, disconnect_body);
|
||||
if (mode == 0) req.content_len = aux.remaining_len = 257;
|
||||
if (mode == 1) req.uri = "/api/settings/ssh-operation?x=1";
|
||||
if (mode == 2) req.method = HTTP_GET;
|
||||
if (mode == 3) add("X-CSRF-Token", "duplicate");
|
||||
if (mode == 4) add("Origin", "https://evil.example");
|
||||
if (mode == 5) add("Transfer-Encoding", "chunked");
|
||||
if (mode == 6) add("Content-Type", "text/plain");
|
||||
if (mode == 7) add("Sec-Fetch-Site", "cross-site");
|
||||
(void)web_ssh_operation_handler(&req);
|
||||
assert(response_status[0] == '4' && !s_next_id && !mutations);
|
||||
}
|
||||
puts("PASS SSH admin/cookie/Origin/CSRF and query/body/framing bounds");
|
||||
const char *invalid[] = {"{}", "[]", "{\"action\":\"disconnect\"}",
|
||||
"{\"action\":\"disconnect\",\"generation\":0,\"target\":9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":4294967295,\"target\":9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"start\",\"generation\":7,\"target\":9}",
|
||||
"{\"action\":\"stop\",\"generation\":7,\"target\":9}",
|
||||
"{\"action\":\"reboot\",\"generation\":7,\"target\":0}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":4294967296}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":09}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":9.0}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":9e0}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":-9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"generation\":9}",
|
||||
"{\"action\":\"disconnect\",\"generation\":7,\"target\":9,\"service\":\"web\"}"};
|
||||
for (unsigned i = 0; i < sizeof(invalid)/sizeof(*invalid); ++i) {
|
||||
operation_begin(&admin, invalid[i]); expect_ssh("400 Bad Request", false);
|
||||
}
|
||||
ssh_operation_t parsed = {0};
|
||||
for (size_t n = 0; n < strlen(disconnect_body); ++n) assert(!parse(disconnect_body, n, &parsed));
|
||||
assert(parse(disconnect_body, strlen(disconnect_body), &parsed));
|
||||
assert(!parse(disconnect_body, strlen(disconnect_body)+1, &parsed));
|
||||
const char *reordered = " { \"target\":4294967295, \"generation\":4294967294, \"action\":\"disconnect\" } ";
|
||||
assert(parse(reordered, strlen(reordered), &parsed));
|
||||
receive_fragment = 1; operation_begin(&admin, disconnect_body); expect_ssh("400 Bad Request", false); assert(body_offset == 4); receive_fragment = 64;
|
||||
char full[257]; memset(full, ' ', 256); memcpy(full, disconnect_body, strlen(disconnect_body)); full[256] = 0;
|
||||
queue_fail = true; operation_begin(&admin, full); expect_ssh("503 Service Unavailable", false); queue_fail = false;
|
||||
assert(body_offset == 256 && s_operation.state == IDLE);
|
||||
puts("PASS SSH strict typed actions/parser, integer/order/truncation and exact request/receive limits");
|
||||
owner_snapshot.generation = 7; owner_snapshot.running = true;
|
||||
for (unsigned i = 0; i < 2; ++i) {
|
||||
ssh_transport_session_snapshot_t *s = &owner_snapshot.sessions[i];
|
||||
s->active = s->principal_valid = true; s->session_id = i + 8; s->state = SSH_TRANSPORT_SESSION_ACTIVE;
|
||||
s->route = i ? SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE : SSH_TRANSPORT_ROUTE_BROKER;
|
||||
memset(s->username, '"', USER_DATABASE_USERNAME_CAPACITY);
|
||||
}
|
||||
for (unsigned mode = 0; mode < 4; ++mode) {
|
||||
unsigned before = snapshots;
|
||||
operation_begin(mode == 0 ? NULL : mode == 1 ? &user : &admin, NULL); req.uri = "/api/settings/ssh";
|
||||
owner_error = mode == 3 ? ESP_FAIL : ESP_OK;
|
||||
expect_ssh(mode == 0 ? "401 Unauthorized" : mode == 1 ? "403 Forbidden" : mode == 3 ? "503 Service Unavailable" : "200 OK", true);
|
||||
if (mode < 2) assert(snapshots == before);
|
||||
if (mode == 2) assert(strstr(output, "22222222222222222222222222222222") && !strstr(output, "socket") && !strstr(output, "principal"));
|
||||
}
|
||||
owner_error = ESP_OK;
|
||||
puts("PASS SSH bounded two-row safe snapshot and unavailable owner isolation");
|
||||
submit(&admin); uint32_t first = queued_id;
|
||||
operation_begin(&other, disconnect_body); expect_ssh("503 Service Unavailable", false);
|
||||
operation_begin(&other, NULL); expect_ssh("200 OK", false); assert(strstr(output, "idle"));
|
||||
execute(); assert(s_operation.state == OK && mutations == 1); zero(&s_operation.principal, sizeof(s_operation.principal));
|
||||
execute(); assert(mutations == 1);
|
||||
for (unsigned action = 0; action < 2; ++action) {
|
||||
char body[80]; snprintf(body, sizeof(body), "{\"action\":\"%s\",\"generation\":7,\"target\":0}", s_actions[action]);
|
||||
operation_begin(&admin, body); expect_ssh("202 Accepted", false); execute();
|
||||
operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, s_actions[action]));
|
||||
}
|
||||
esp_err_t failures[] = {ESP_ERR_INVALID_STATE, ESP_ERR_NOT_FOUND, ESP_FAIL};
|
||||
for (unsigned i = 0; i < 3; ++i) { owner_error = failures[i]; submit(&admin); execute(); assert(s_operation.state == (i < 2 ? CONFLICT : FAILED)); }
|
||||
owner_error = ESP_OK;
|
||||
puts("PASS SSH all three actions on dispatcher only, duplicate/stale IDs, conflicts and login-isolated results");
|
||||
submit(&admin); unsigned before = mutations;
|
||||
web_ssh_settings_execute(0); web_ssh_settings_execute(first); assert(mutations == before && s_operation.state == PENDING);
|
||||
now += 30000000; execute(); assert(s_operation.state == CANCELLED && mutations == before);
|
||||
submit(&admin); web_session_store_invalidate(admin.view.id); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); db_fail = true; execute(); db_fail = false; assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); stale_user = alice.user_id; execute(); stale_user = 0; assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); now = admin.view.expires_at_us - 1; submit(&admin); now = admin.view.expires_at_us; execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); submit(&admin); web_cookie_auth_stop(); assert(web_cookie_auth_start() == ESP_OK); execute(); assert(s_operation.state == CANCELLED);
|
||||
admin = mint(&alice); operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, "idle"));
|
||||
assert(mutations == before);
|
||||
puts("PASS SSH queue deadline, expiry/revocation/currentness failure, HTTPS auth lifecycle fencing");
|
||||
send_fail = true; submit(&admin); send_fail = false; execute(); assert(s_operation.state == OK);
|
||||
operation_begin(&admin, NULL); expect_ssh("200 OK", false); assert(strstr(output, "ok"));
|
||||
s_next_id = UINT32_MAX; operation_begin(&admin, disconnect_body); expect_ssh("503 Service Unavailable", false);
|
||||
puts("PASS SSH lost acknowledgement retained result and nonwrapping operation IDs");
|
||||
}
|
||||
@@ -149,6 +149,9 @@ static void auth_reset(void) {
|
||||
#ifdef HOST_BROKER
|
||||
#include "broker_settings_test.c"
|
||||
#endif
|
||||
#ifdef HOST_SSH_SETTINGS
|
||||
#include "ssh_settings_test.c"
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
assert(store_tests() == 0); auth_reset();
|
||||
@@ -314,6 +317,9 @@ int main(void) {
|
||||
#endif
|
||||
#ifdef HOST_BROKER
|
||||
broker_settings_tests();
|
||||
#endif
|
||||
#ifdef HOST_SSH_SETTINGS
|
||||
ssh_settings_tests();
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
|
||||
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
|
||||
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
|
||||
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': []};
|
||||
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': [], '/api/settings/network': [], '/api/settings/network-operation': [], '/api/settings/display': [], '/api/settings/display-operation': [], '/api/settings/broker': [], '/api/settings/broker-operation': [], '/api/settings/ssh': [], '/api/settings/ssh-operation': []};
|
||||
const fits = [];
|
||||
let serial = 0, now = Date.now();
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
@@ -1371,5 +1371,6 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
|
||||
await require('./network.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./display.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./broker.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
await require('./ssh.cjs')({test, browser, adminBrowser, tick, json, session, failure, deferred, token, html});
|
||||
console.log(`PASS ${passed} browser behavior groups (production C-rendered JS)`);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
@@ -56,10 +56,10 @@ def check_layout(html):
|
||||
assert 'hidden' in ids['quick-header']['attrs']
|
||||
assert ids['network-password']['parent'] is ids['network-password-label']
|
||||
assert ids['network-password-mode']['parent'] is ids['network-password-mode-label']
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values'):
|
||||
for ident in ('settings-values', 'accounts-list', 'account-keys-list', 'network-summary', 'display-values', 'broker-values', 'ssh-values'):
|
||||
assert ids[ident]['tag'] == 'dl'
|
||||
assert 'settings-values' in classes(ids[ident])
|
||||
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings'):
|
||||
for ident in ('serial-settings-content', 'account-settings', 'network-settings', 'display-settings', 'broker-settings', 'ssh-settings'):
|
||||
nodes = list(descendants(ids[ident]))
|
||||
assert not any(n['tag'] == 'pre' for n in nodes)
|
||||
assert all('connection-detail' in classes(n) for n in nodes if n['tag'] == 'p')
|
||||
@@ -70,9 +70,9 @@ def check_layout(html):
|
||||
ancestor(n, 'settings-edit')
|
||||
except AssertionError:
|
||||
ancestor(n, 'serial-edit')
|
||||
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh'):
|
||||
for ident in ('refresh-settings', 'refresh-accounts', 'network-refresh', 'display-refresh', 'broker-refresh', 'ssh-refresh'):
|
||||
assert ids[ident]['text'] == 'Refresh'
|
||||
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result'):
|
||||
for ident in ('serial-result', 'account-result', 'network-result', 'display-result', 'broker-result', 'ssh-result'):
|
||||
assert ids[ident]['text'] == 'Check Operation Result'
|
||||
for ident in ('network-boot', 'network-enabled', 'account-password-saved'):
|
||||
assert 'settings-check' in classes(ids[ident]['parent'])
|
||||
@@ -101,7 +101,7 @@ def check_layout(html):
|
||||
):
|
||||
assert rule in css, rule
|
||||
assert '.settings-edit textarea{font:inherit;width:100%;min-width:0;' in css
|
||||
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all five settings views')
|
||||
print('PASS HTML layout: parsed structure, shared styles, labels, wrapping, checkbox sizing and action order across all six settings views')
|
||||
|
||||
|
||||
def check_browser_layout(html, tmp, executable):
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
const assert = require('node:assert/strict');
|
||||
module.exports = async ({test, browser, adminBrowser, tick, json, session, failure, deferred, html}) => {
|
||||
const path='/api/settings/ssh', op=path+'-operation';
|
||||
const row=(id=9, extra={})=>({id,state:2,route:1,name_hex:'3c696d673e',closing:false,...extra});
|
||||
const fixture=(extra={})=>({generation:7,running:true,transitioning:false,sessions:[row(),row(10,{route:2})],...extra});
|
||||
const reply=(state='pending',id=42,status=200,action='disconnect')=>new Response(JSON.stringify({id,action,state}),{status});
|
||||
const n=(b,id)=>b.nodes['ssh-'+id], posts=b=>b.calls.filter(c=>c.url===op&&c.method==='POST');
|
||||
async function open(v=fixture()) {const b=await adminBrowser();b.click('select-settings');await tick();b.queues[path].push(json(v));b.click('settings-ssh');await tick();return b;}
|
||||
function select(b,id=9){n(b,'target').value=String(id);n(b,'target').change();}
|
||||
async function refresh(b,v=fixture()){b.queues[path].push(json(v));b.click('ssh-refresh');await tick();}
|
||||
async function submit(b,action='disconnect'){if(action==='disconnect')select(b);b.window.confirm=()=>true;b.queues[op].push(reply('pending',42,202,action));b.click('ssh-'+action);await tick();}
|
||||
await test('SSH admin-only view, safe rows, no navigation/selection mutation and both terminal isolation',async()=>{
|
||||
const u=browser();u.start();await tick();u.click('settings-ssh');await tick();assert.equal(u.calls.filter(c=>c.url===path).length,0);
|
||||
const b=await open();assert.equal(b.nodes['ssh-settings'].hidden,false);
|
||||
assert.equal(n(b,'values').children[0].textContent,'9 / Serial / <img>');select(b);assert.equal(posts(b).length,0);
|
||||
for(let i=0;i<2;++i){b.sockets[i].emit('message',{data:Uint8Array.of(0,255,i).buffer});assert.deepEqual(b.terminals[i].writes.at(-1),[0,255,i]);b.terminals[i].input('blocked');assert.equal(b.sockets[i].sent.length,0);}
|
||||
assert.match(html,/Stop closes all SSH sessions/);assert.match(html,/HTTPS login, browser terminals, Wi-Fi, USB and UART0 are not stopped/);
|
||||
b.click('settings-broker');await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(posts(b).length,0);
|
||||
});
|
||||
await test('SSH exact confirmed start stop targeted disconnect requests and manual bounded result flow',async()=>{
|
||||
for(const action of ['start','stop','disconnect']){
|
||||
const b=await open(fixture({running:action!=='start'}));if(action==='disconnect')select(b);
|
||||
let confirmation='';b.window.confirm=text=>{confirmation=text;return false;};b.click('ssh-'+action);await tick();assert.equal(posts(b).length,0);
|
||||
assert.match(confirmation,/Settings and host identity are unchanged/);assert.match(confirmation,action==='stop'?/ALL SSH sessions/:action==='start'?/port 22/:/only SSH session 9/);
|
||||
await submit(b,action);assert.deepEqual(JSON.parse(posts(b)[0].body),{action,generation:7,target:action==='disconnect'?9:0});assert.equal(posts(b)[0].headers['X-CSRF-Token'],'a'.repeat(64));
|
||||
b.click('ssh-'+action);await tick();assert.equal(posts(b).length,1);assert.match(n(b,'operation-detail').textContent,/Check Operation Result/);
|
||||
b.queues[op].push(reply('ok',42,200,action));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/execution time/);assert.ok(n(b,'start').disabled&&n(b,'stop').disabled&&n(b,'disconnect').disabled);
|
||||
await refresh(b);assert.equal(posts(b).length,1);assert.equal(n(b,'target').value,'');
|
||||
}
|
||||
});
|
||||
await test('SSH refresh never rebases explicit identity or resurrects stale and absent selections',async()=>{
|
||||
for(const v of [fixture({generation:8}),fixture({sessions:[row(13)]}),fixture({sessions:[row(9,{closing:true})]}),fixture({sessions:[row(9,{name_hex:'61'})]}),fixture({transitioning:true})]){
|
||||
const b=await open();select(b);await refresh(b,v);assert.ok(n(b,'disconnect').disabled);await refresh(b);assert.ok(n(b,'disconnect').disabled);assert.equal(n(b,'target').value,'');
|
||||
b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);select(b);assert.equal(n(b,'disconnect').disabled,false);
|
||||
}
|
||||
const b=await open();select(b);await refresh(b,fixture({sessions:[row(10,{route:2}),row()]}));assert.equal(n(b,'target').value,'9');assert.equal(n(b,'disconnect').disabled,false);
|
||||
});
|
||||
await test('SSH invalid/unavailable/transition/exhausted snapshots fail closed without affecting terminals',async()=>{
|
||||
const invalid=[{},fixture({generation:0}),fixture({generation:4294967296}),fixture({running:1}),fixture({sessions:[row(),row()]}),fixture({sessions:[row(9,{name_hex:'zz'})]}),fixture({sessions:[row(9,{route:3})]}),fixture({sessions:[row(9,{id:0})]})];
|
||||
for(const v of invalid){const b=await open(v);assert.ok(n(b,'disconnect').disabled&&n(b,'stop').disabled&&n(b,'start').disabled);assert.match(n(b,'detail').textContent,/unavailable/);assert.equal(posts(b).length,0);}
|
||||
for(const extra of [{transitioning:true},{generation:4294967295}]){const b=await open(fixture(extra));assert.ok(n(b,'stop').disabled&&n(b,'start').disabled);}
|
||||
const b=await open();select(b);b.queues[path].push(failure(503));b.click('ssh-refresh');await tick();assert.ok(n(b,'disconnect').disabled);assert.equal(b.sockets.length,2);
|
||||
});
|
||||
await test('SSH single-flight whole-read deadline and navigation fence late snapshots without replay',async()=>{
|
||||
for(const stage of ['session','snapshot']){
|
||||
const b=await open(), d=deferred();select(b);b.queues[stage==='session'?'/api/session':path].push(d.promise);b.click('ssh-refresh');await tick();b.click('ssh-stop');b.click('ssh-refresh');await tick();assert.equal(posts(b).length,0);
|
||||
b.fire(15000);await tick();assert.match(n(b,'detail').textContent,/timed out/);await refresh(b);d.resolve(stage==='session'?session({role:'admin'}):json(fixture({generation:99})));await tick();select(b);await submit(b);assert.equal(JSON.parse(posts(b)[0].body).generation,7);
|
||||
}
|
||||
const b=await open(),d=deferred();b.queues[path].push(d.promise);b.click('ssh-refresh');await tick();b.click('settings-serial');await tick();d.resolve(json(fixture()));await tick();assert.equal(b.nodes['ssh-settings'].hidden,true);assert.equal(n(b,'values').children.length,0);assert.equal(posts(b).length,0);
|
||||
});
|
||||
await test('SSH captures confirmation before delayed auth and handles conflict failed cancelled without replay',async()=>{
|
||||
for(const state of ['conflict','failed','cancelled']){
|
||||
const b=await open(),d=deferred();select(b);b.window.confirm=()=>true;b.queues['/api/session'].push(d.promise);b.queues[op].push(reply('pending',42,202));b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);d.resolve(session({role:'admin'}));await tick();assert.equal(JSON.parse(posts(b)[0].body).generation,7);
|
||||
b.queues[op].push(reply(state));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,state==='conflict'?/No action admitted/:state==='failed'?/may still finish/:/Rejected before execution/);assert.equal(posts(b).length,1);
|
||||
}
|
||||
});
|
||||
await test('SSH lost acknowledgement/result replacement and timeout retain uncertainty across navigation',async()=>{
|
||||
const b=await open();select(b);b.window.confirm=()=>true;b.queues[op].push(()=>{throw Error('lost');});b.click('ssh-disconnect');await tick();assert.ok(n(b,'disconnect').disabled);b.click('select-serial');b.queues[path].push(json(fixture()));b.click('select-settings');await tick();assert.equal(posts(b).length,1);
|
||||
b.queues[op].push(reply('ok',41));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/Acknowledgement was lost/);
|
||||
b.queues[op].push(reply('ok',43));b.click('ssh-result');await tick();assert.match(n(b,'operation-detail').textContent,/Previous result replaced/);
|
||||
await refresh(b);const d=deferred();select(b);b.queues[op].push(d.promise);b.click('ssh-disconnect');await tick();b.fire(15000);await tick();assert.match(n(b,'operation-detail').textContent,/timed out/);assert.ok(n(b,'stop').disabled);d.resolve(reply('pending',44,202));await tick();assert.match(n(b,'operation-detail').textContent,/timed out/);assert.equal(posts(b).length,2);
|
||||
});
|
||||
await test('SSH revoked session and pagehide cancel UI work without affecting newer context via late401',async()=>{
|
||||
const b=await open();select(b);b.queues['/api/session'].push(failure(401));b.click('ssh-disconnect');await tick();assert.equal(posts(b).length,0);
|
||||
const c=await open(),d=deferred();c.queues[path].push(d.promise);c.click('ssh-refresh');await tick();c.emit('pagehide');d.resolve(failure(401));await tick();assert.equal(posts(c).length,0);assert.equal(n(c,'values').children.length,0);
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user