diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md index e022ef5..1281928 100644 --- a/docs/agent/architecture.md +++ b/docs/agent/architecture.md @@ -157,6 +157,8 @@ admin SSH line editor ----/ | The dispatcher is the sole caller of `esp_console_run()`, serializing UART0 and all admin SSH commands. This is required because the console registry is treated as non-reentrant, but it also means a long command or interactive prompt blocks all administration entry routes. +Phase 8D.4 retains this module and exposes `admin_ssh_console_open_owned()`: copied transport-qualified slot/session/generation identity plus a firmware-lifetime immutable owner adapter. The existing two console slots are shared, not multiplied per frontend; active/executing slots cannot be replaced. Owners serialize per-session input, consume output and enforce transport liveness; completion scratch is claimed nonblockingly across owners. The existing control task calls drain/lifecycle adapters outside console locks. SSH implements its adapter and compatible admission entry point in `ssh_transport.c`, using generation-checked published snapshots and existing control APIs, never wolfSSH from the control task. `SELF_CLOSE` is owner-relative; legacy SSH actions remain SSH-specific and unsupported owner actions are rejected. No browser admin transport is yet implemented. + For SSH, standard output/error is redirected to the invoking session's bounded output ring. `console_input` routes visible or hidden prompts to UART0 or the active SSH session. `exit` and Ctrl+D on an empty admin SSH line use bounded deferred self-disconnect after their acknowledgement drains; role-`user` SSH remains a binary-transparent serial stream. Session tokens include slot and generation so late queued work cannot attach to a reused SSH slot. Only the SSH owner task moves ring output through wolfSSH. Admin SSH `exit`, remote reboot, SSH stop/disconnect, and host-key rotate/reset use deferred control. The control task waits up to ten seconds for command state plus administration and transport application buffers to clear, then adds a short delay; this is a bounded best-effort heuristic, not peer-delivery confirmation. UART0 invokes these actions synchronously. User mutations and their revocations are not part of this mechanism. UART0 linenoise and the SSH editor consume the same manually maintained completion matcher and candidate formatter, so the two administration routes cannot drift in offered or displayed ambiguous completions; the hints can still drift from command registration and are not an authorization list. diff --git a/docs/agent/code-map.md b/docs/agent/code-map.md index 1ec32d1..e82bf20 100644 --- a/docs/agent/code-map.md +++ b/docs/agent/code-map.md @@ -110,6 +110,7 @@ This is a semantic map, not a complete file inventory. Start here, then read the - Ownership: dispatcher is sole `esp_console_run()` caller; the SSH owner exclusively performs post-initialization wolfSSH runtime calls. - Lifecycle: remote session tokens include slot generation; fixed output/history/prompt state is wiped immediately on idle close or after an executing handler returns. Admin SSH `exit` and Ctrl+D on an empty command line request bounded deferred self-disconnect after best-effort output draining. - Constraint: one slow command or prompt serializes all administration. Admin SSH is unavailable until command registration and UART frontend creation complete; supported deferred actions wait only for a bounded application-buffer drain heuristic. +- 8D.4 boundary: `admin_ssh_console_open_owned()` copies transport-qualified identity and retains an immutable firmware-lifetime drain/lifecycle adapter; the existing two slots are shared. SSH compatibility admission/adapter resides in `ssh_transport.c`; no browser admin caller yet. Owners handle liveness/output; completion scratch is nonblockingly serialized. Focused host command: `python3 tests/admin_console_boundary/run.py`. ## Wi-Fi diff --git a/docs/agent/current-state.md b/docs/agent/current-state.md index cc5e7ac..71c4d4a 100644 --- a/docs/agent/current-state.md +++ b/docs/agent/current-state.md @@ -4,6 +4,8 @@ This file is working memory. Update it during active work and before handoff; do ## Development state +- **8D.4 implemented / host-tested / build-verified (2026-09-06), target pending:** User separately requested the small console boundary. Added transport-qualified identity and immutable output-drain/lifecycle owner callbacks in `admin_ssh_console`, retained the SSH admission API in `ssh_transport`, and kept two shared slots, one dispatcher and all capacities/stacks. Shared completion scratch now has nonblocking ownership; occupied-slot replacement and deferred input fail closed. `python3 tests/admin_console_boundary/run.py` and existing policy suite pass; independent review found no defects. `pio run` passes in **43.61 s**, **95,084 B RAM / 1,627,173 B flash** (**-424 / +1,448 B** versus recorded latest 8D.3). See `docs/phase8d4_implementation.md` for contracts, host-test limits, accounting and target checklist. No browser admin routes, uploads or 8D.5 work. Prior M1 sign-off stands; numeric reserves and 8D.4 target regression remain pending. Stop for validation/user decision before separately requested 8D.5. + - **8D.3/M1 completed by explicit user sign-off (2026-09-06):** User closes M1 after post-soak evidence, superseding all older pending/blocked/in-progress statements below. Four broker clients remain active at 115200 baud (SSH writer; USB/two web observers), both SSH routes and two cookie sessions active. Post-soak internal/DMA/PSRAM free **38,656 / 30,900 / 8,138,320 B**, minima **13,756 / 6,000 / 8,072,744 B**, largest **25,600 / 25,600 / 7,995,392 B**; SSH stack minimum-free **16,288 B**. Minima/largest unchanged from earlier loaded sample; no reported transport failures. Record/evidence limits in `docs/phase8d3_implementation.md`. Sample is still connected, not disconnected cleanup; exact soak duration/revision not supplied. Unrecorded detailed checks do not reopen signed-off M1 or become claims of execution. Numeric reserves remain open; empty-Enter fix target retest remains unrecorded separately. **Wait for a separate 8D.4 request; no M2 implementation performed.** - **8D.3 both-role target login confirmed / mixed-client evidence (2026-09-05):** User reports successful HTTPS user+admin login after Origin fix; previous login blocker is resolved. Settled internal/DMA/PSRAM free **71,204 / 63,448 / 8,247,744 B**. Mixed load free **33,868 / 26,112 / 8,089,060 B**, minima **13,756 / 6,000 / 8,072,744 B**, largest **25,600 / 25,600 / 7,995,392 B**; SSH stack minimum-free **16,288 B**. 115200 baud, four broker clients (SSH sole writer, USB + two web observers), user/admin SSH active. No reported web transport or SSH I/O errors; 6 login attempts/3 invalid credentials/1 logout, zero security rejections. Two identical loaded heap samples are not a soak/leak or reserve proof. Full details/provenance in `docs/phase8d3_implementation.md`. **M1 validation in progress, not signed off.** diff --git a/docs/agent/design-decisions.md b/docs/agent/design-decisions.md index e54af83..ee7534d 100644 --- a/docs/agent/design-decisions.md +++ b/docs/agent/design-decisions.md @@ -82,6 +82,8 @@ Phase 8D.2 adds a third identity: non-reused 64-bit originating web-session IDs **Consequence for future changes:** Actions that would invalidate their own SSH transport should integrate with deferred control when acknowledgement preservation matters. Prevent new input while an action is pending, keep the wait bounded, and do not describe it as guaranteed delivery. +Phase 8D.4 routes drain/lifecycle operations through a firmware-lifetime immutable owner adapter on the existing control task, outside console locks. Tokens include a transport namespace; owners revalidate full identity and marshal to their transport APIs. `SELF_CLOSE` targets the invoking frontend while existing SSH action meanings remain unchanged. Unsupported actions must fail before side effects. The two console slots remain a shared bounded pool, with no hypothetical browser capacity allocated. + **Relevant files:** `src/admin_ssh_console.c`, `src/system_console.c`, `src/ssh_console.c`, `src/ssh_transport.c` ## Authentication uses copied principals and fail-safe currentness checks diff --git a/docs/phase8d4_implementation.md b/docs/phase8d4_implementation.md new file mode 100644 index 0000000..59dcadf --- /dev/null +++ b/docs/phase8d4_implementation.md @@ -0,0 +1,39 @@ +# Phase 8D.4 - Small Console Boundary + +Status (2026-09-06): **Implemented / host-tested / build-verified; target validation pending.** M1 and previous phase sign-offs stand. Numeric reserve gates remain open. No 8D.5 implementation or browser admin route is included. + +## Scope And Contract + +Starting revision: `f9ee6eec9cbe06fe5120ee718507eaffd69787a2`, initially clean worktree. Changed production files are `src/admin_ssh_console.{c,h}` and `src/ssh_transport.c`; focused tests are in `tests/admin_console_boundary/`. No console-input changes were necessary because its prompt routing already uses the shared dispatcher. + +- `admin_ssh_console_open_owned()` accepts a copied transport-qualified token/principal and an immutable firmware-lifetime owner adapter. Existing SSH admission remains available through `admin_ssh_console_open()`, now implemented beside its owner in `ssh_transport.c`. +- The adapter supplies nonblocking application-output drain checks and deferred lifecycle handling, called by the existing control task outside console locks. SSH validates slot/session/generation and uses existing transport control APIs, never runtime wolfSSH calls from the control task. +- Two console slots remain shared, not two per transport. Occupied and still-executing slots reject replacement. Future admission must coordinate this pool; this phase does not promise simultaneous browser and full SSH admin capacity. +- The token includes transport, slot, session ID and generation. Owners serialize input per session, exclusively consume output, maintain transport authentication/expiry, and close on disconnect/revocation. The core retains admission/dispatch principal checks and queued-work identity checks. +- Completion scratch has a nonblocking claim so different owners cannot race the shared buffer outside the lock. A competing TAB remains unconsumed for retry; stale completion cannot publish into a reused slot. +- Shell exit uses an owner-relative `SELF_CLOSE` action; existing STOP/DISCONNECT/key actions still mean SSH. Unsupported actions fail before side effects. Deferred input is rejected at both admission and feed, and identity is checked again after the final delay. +- The same dispatcher, canonical registry, queue, editor/history/prompts, UART0 policy and output ring remain. Five-second output backpressure and ten-second deferred drain plus 200 ms remain bounded best-effort heuristics, not delivery confirmation. + +No new tasks, browser endpoints, UI changes, serial/broker changes, generated assets, persistence changes, uploads, erases or commits. + +## Verification And Resources + +- `python3 tests/admin_console_boundary/run.py`: PASS for production console and extracted production SSH adapter with deterministic host fakes. Covers readiness/admission, cross-transport and stale identities, completion contention and close/reopen during completion, history, queued disconnect/revocation, UART dispatch, executing cleanup, hidden/visible/cancelled/disconnected prompts, exit-to-SELF_CLOSE routing, unsupported actions, queue rejection, drain timeout/delay, SSH action routing and slow output. +- `python3 tests/admin_ssh_policy/run.py`: PASS, including quiet empty input and physical-only command restrictions. +- Independent code review found no defects. `git diff --check`: PASS. +- `pio run`: PASS in **43.61 seconds**, PlatformIO espressif32 6.12.0 / ESP-IDF 5.5.0, N16R8 release. Linked RAM **95,084 B**, flash **1,627,173 B**. Against the recorded latest 8D.3 build (95,508 / 1,625,725 B): **-424 B RAM / +1,448 B flash**. Against recorded 8D.0 (94,532 / 1,599,973 B): **+552 B RAM / +27,200 B flash**. These comparisons use recorded builds, not a fresh baseline rebuild. + +Static savings come from removing the full SSH snapshot scratch in the console and reading only the matching published slot under the SSH lock. Added costs are transport tags, owner pointers in sessions/control requests, the immutable adapter, and one completion-claim flag. No payload buffer or capacity increase. Two 4 KiB output rings, four-entry command queue, two-entry control queue, four-line per-session history and task stack sizes (dispatcher 12 KiB, UART frontend 6 KiB, control 4 KiB) are unchanged. No new module heap allocation, socket, TLS connection, HTTP handler, web session or ticket capacity. Runtime heap, PSRAM and stack margins were not measured; no reserve approval is inferred from linked RAM. + +Host fakes do not establish actual FreeRTOS scheduling, task-local stdio redirection, real queue capacities, socket behavior or hardware regression. The command runner and lifecycle operations are doubled; direct production helpers test routing and output separately. Sanitizer execution was unavailable because the host lacks the required runtime library, not a sanitizer pass. + +## Target Handoff + +Do not stack 8D.5 on this unverified runtime change without target sign-off or an explicit user decision. After flashing through the user's normal workflow: + +1. Boot and capture UART0 `memory`, status and `ssh status`. Verify UART0 recovery and empty Enter/normal commands through admin SSH (also covers the separately pending 8D.3 empty-line fix). +2. Exercise UART0/admin-SSH serialization, completion/history, visible/hidden/cancelled prompts, and disconnect/revocation while work is queued or a prompt is active. Check reconnect/slot reuse does not receive old output. +3. Exercise slow-reader backpressure and recovery, `exit`, empty-line Ctrl+D, and the existing deferred SSH stop/disconnect/key/reboot paths as appropriate. Confirm acknowledgement remains best effort and stale sessions cannot act on reused identities. +4. Run the always-on browser login/serial explicit disconnect/reconnect, USB UART1 and user/admin SSH smoke. Repeat five serial lifecycle cycles per role and collect comparable settled/full-client-mix/cleanup `memory` and SSH stack telemetry at the supported 115200-baud workload. + +No target checks above were executed by the agent. Exact next chunk is **8D.5: bounded admin WebSocket backend**, only when separately requested after the applicable validation decision. diff --git a/docs/phase8d_plan.md b/docs/phase8d_plan.md index ad628c4..922522c 100644 --- a/docs/phase8d_plan.md +++ b/docs/phase8d_plan.md @@ -1,6 +1,6 @@ # Phase 8D — Incremental web administration plan -Status: **8D.0–8D.2 validated by user sign-off on 2026-09-05; 8D.3/M1 validated by explicit user sign-off on 2026-09-06 after post-soak evidence. Live login/logout is implemented, host-tested and build-verified. Numeric reserve gates remain open. 8D.4–8D.22 remain planned, each requiring a separate implementation request.** See the [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md). +Status: **8D.0–8D.2 validated by user sign-off on 2026-09-05; 8D.3/M1 validated by explicit user sign-off on 2026-09-06 after post-soak evidence. Live login/logout is implemented, host-tested and build-verified. 8D.4 is implemented, host-tested and build-verified; target regression remains pending. Numeric reserve gates remain open. 8D.5–8D.22 remain planned, each requiring a separate implementation request.** See the [8D.4 implementation record](phase8d4_implementation.md), [8D.3 implementation record](phase8d3_implementation.md) and [8D.0 baseline/M1 contract](phase8d_baseline.md). This is the execution plan for [roadmap Phase 8D](roadmap.md#phase-8--role-based-users-and-administrative-access). The roadmap retains the feature/security requirements; this document defines small work units, dependencies, and release gates. The [administration test matrix](user_administration_tests.md#planned-phase-8d-integrated-web-administration) remains the final acceptance checklist. @@ -102,6 +102,8 @@ If 8D.3 exceeds the work-unit limit, first split out inert login-page rendering ### 8D.4 — Small transport-neutral console boundary +**Implementation checkpoint (2026-09-06):** [8D.4 implementation, resource accounting and target handoff](phase8d4_implementation.md). Implemented / host-tested / build-verified; target regression pending. Owner callbacks and transport-qualified identity retain the existing two shared slots and SSH API, with no browser routes. Build **95,084 B RAM / 1,627,173 B flash**, **-424 / +1,448 B** versus recorded latest 8D.3. Numeric reserves remain open. This supersedes the overview's older planned status for 8D.4 only; stop before 8D.5 pending validation or explicit user decision. + **Start in:** `src/admin_ssh_console.{c,h}`, `src/console_input.{c,h}`, and only the necessary `src/ssh_transport.c` callers. **Scope:** Introduce the minimal transport identity/output/lifecycle boundary needed by a future web frontend. Retain the single dispatcher, fixed queue, line editing/history/completion/prompts, currentness checks, generation tokens, and existing SSH API compatibility where practical. Do not duplicate per-SSH buffers for hypothetical web capacity or rename the whole module. No web endpoint or second dispatcher/task. @@ -167,7 +169,7 @@ Update the roadmap and user/command documentation to distinguish completed featu ## Progress and next-request template -Progress: **8D.0–8D.3 and M1 validated by user sign-off. 8D.3 implemented / host-tested / build-verified; numeric reserves remain open. 8D.4–8D.22 planned.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds. The baseline records user-provided evidence and sign-off; this does not imply completion of later browser-authentication acceptance checks. +Progress: **8D.0–8D.3 and M1 validated by user sign-off. 8D.4 implemented / host-tested / build-verified, target regression pending; numeric reserves remain open. 8D.5–8D.22 planned.** Record incremental results in `docs/agent/current-state.md`, retaining the [baseline](phase8d_baseline.md) and cumulative resource measurements as work proceeds. The baseline records user-provided evidence and sign-off; this does not imply completion of later browser-authentication acceptance checks. Suggested next request: diff --git a/src/admin_ssh_console.c b/src/admin_ssh_console.c index 94770ef..79c98f0 100644 --- a/src/admin_ssh_console.c +++ b/src/admin_ssh_console.c @@ -9,14 +9,12 @@ #include "console_completion.h" #include "esp_console.h" -#include "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/queue.h" #include "freertos/semphr.h" #include "freertos/task.h" #include "linenoise/linenoise.h" #include "secure_random.h" -#include "ssh_transport.h" #include "user_database.h" #define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U @@ -47,6 +45,7 @@ typedef struct { bool executing; bool deferred_action_pending; admin_ssh_console_token_t token; + const admin_console_owner_t *owner; user_principal_t principal; size_t input_length; size_t input_cursor; @@ -87,12 +86,14 @@ typedef struct { typedef struct { admin_ssh_deferred_action_type_t action; admin_ssh_console_token_t token; + const admin_console_owner_t *owner; uint32_t argument; } admin_control_request_t; static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_MAX_SESSIONS]; -/* admin_ssh_console_feed_input() is called only by the sole SSH owner task. */ +/* Claimed under s_lock, used outside it; competing TAB input is backpressured. */ +static bool s_completion_busy; static char s_completion_output[CONSOLE_COMPLETION_OUTPUT_CAPACITY]; static StaticQueue_t s_request_queue_storage; @@ -115,7 +116,6 @@ static bool s_dispatch_remote; static bool s_dispatch_output_previous_cr; static admin_ssh_console_token_t s_dispatch_token; static user_principal_t s_dispatch_principal; -static ssh_transport_snapshot_t s_control_ssh_snapshot; bool admin_ssh_console_dispatch_is_remote(void) { @@ -137,6 +137,8 @@ static bool token_identity_matches(const admin_session_t *session, const admin_ssh_console_token_t *token) { return token_valid(token) && session->token.session_id == token->session_id && + session->token.transport == token->transport && + session->token.slot_index == token->slot_index && session->token.slot_generation == token->slot_generation; } @@ -361,6 +363,12 @@ esp_err_t admin_ssh_console_dispatch_defer( admin_session_t *session = &s_sessions[s_dispatch_token.slot_index]; bool valid = token_matches(session, &s_dispatch_token) && !session->deferred_action_pending; + const admin_console_owner_t *owner = valid ? session->owner : NULL; + if (valid && ((unsigned)action > ADMIN_CONSOLE_DEFER_SELF_CLOSE || + !(owner->supported_actions & (1U << action)))) { + taskEXIT_CRITICAL(&s_lock); + return ESP_ERR_NOT_SUPPORTED; + } if (valid) { session->deferred_action_pending = true; } @@ -371,6 +379,7 @@ esp_err_t admin_ssh_console_dispatch_defer( admin_control_request_t request = { .action = action, .token = s_dispatch_token, + .owner = owner, .argument = argument, }; if (xQueueSend(s_control_queue, &request, 0U) == pdTRUE) { @@ -474,12 +483,14 @@ static int command_exit(int argc, char **argv) } esp_err_t error = admin_ssh_console_dispatch_defer( - ADMIN_SSH_DEFER_DISCONNECT, s_dispatch_token.session_id); + ADMIN_CONSOLE_DEFER_SELF_CLOSE, s_dispatch_token.session_id); if (error != ESP_OK) { - printf("Could not schedule SSH session close: %s\n", esp_err_to_name(error)); + printf("Could not schedule %s session close: %s\n", + s_dispatch_token.transport == 0U ? "SSH" : "remote", esp_err_to_name(error)); return 1; } - printf("SSH session close scheduled after output drains.\n"); + printf("%s session close scheduled after output drains.\n", + s_dispatch_token.transport == 0U ? "SSH" : "Remote"); return 0; } @@ -587,16 +598,18 @@ static void finish_deferred_request(const admin_control_request_t *request, esp_err_t result, bool cancelled) { char message[160]; + const char *transport = request->token.transport == 0U ? "SSH" : "remote"; if (cancelled) { snprintf(message, sizeof(message), - "Deferred action cancelled before SSH output drained.\r\nadmin@serial-tool> "); + "Deferred action cancelled before %s output drained.\r\nadmin@serial-tool> ", + transport); } else if (result == ESP_OK) { snprintf(message, sizeof(message), - "Deferred SSH action completed.\r\nadmin@serial-tool> "); + "Deferred %s action completed.\r\nadmin@serial-tool> ", transport); } else { snprintf(message, sizeof(message), - "Deferred SSH action failed: %s\r\nadmin@serial-tool> ", - esp_err_to_name(result)); + "Deferred %s action failed: %s\r\nadmin@serial-tool> ", + transport, esp_err_to_name(result)); } taskENTER_CRITICAL(&s_lock); admin_session_t *session = &s_sessions[request->token.slot_index]; @@ -627,18 +640,8 @@ static void control_task(void *context) if (!current) { break; } - bool transport_drained = false; - if (console_drained && - ssh_transport_get_snapshot(&s_control_ssh_snapshot) == ESP_OK) { - for (size_t index = 0U; index < SSH_TRANSPORT_MAX_SESSIONS; ++index) { - const ssh_transport_session_snapshot_t *slot = - &s_control_ssh_snapshot.sessions[index]; - if (slot->active && slot->session_id == request.token.session_id) { - transport_drained = !slot->tx_pending; - break; - } - } - } + bool transport_drained = console_drained && + request.owner->drained(&request.token); if (console_drained && transport_drained) { drained = true; break; @@ -651,27 +654,11 @@ static void control_task(void *context) continue; } vTaskDelay(pdMS_TO_TICKS(200U)); - esp_err_t result = ESP_OK; - switch (request.action) { - case ADMIN_SSH_DEFER_REBOOT: - esp_restart(); - break; - case ADMIN_SSH_DEFER_STOP: - result = ssh_transport_stop(); - break; - case ADMIN_SSH_DEFER_DISCONNECT: - result = ssh_transport_disconnect(request.argument); - break; - case ADMIN_SSH_DEFER_HOST_KEY_ROTATE: - result = ssh_transport_replace_host_key(false); - break; - case ADMIN_SSH_DEFER_HOST_KEY_RESET: - result = ssh_transport_replace_host_key(true); - break; - default: - result = ESP_ERR_NOT_SUPPORTED; - break; - } + taskENTER_CRITICAL(&s_lock); + bool current = token_matches(&s_sessions[request.token.slot_index], &request.token); + taskEXIT_CRITICAL(&s_lock); + esp_err_t result = current ? request.owner->perform( + &request.token, request.action, request.argument) : ESP_ERR_NOT_FOUND; finish_deferred_request(&request, result, false); secure_wipe(&request, sizeof(request)); } @@ -751,7 +738,7 @@ esp_err_t admin_ssh_console_register_commands(void) { const esp_console_cmd_t command = { .command = "exit", - .help = "Close the current administrative SSH session", + .help = "Close the current administrative remote session", .hint = NULL, .func = &command_exit, .argtable = NULL, @@ -783,10 +770,12 @@ esp_err_t admin_ssh_console_start_uart_frontend(void) return ESP_OK; } -esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token, - const user_principal_t *principal) +esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token, + const user_principal_t *principal, + const admin_console_owner_t *owner) { - if (!token_valid(token) || principal == NULL || principal->role != USER_ROLE_ADMIN) { + if (!token_valid(token) || principal == NULL || principal->role != USER_ROLE_ADMIN || + owner == NULL || owner->drained == NULL || owner->perform == NULL) { return ESP_ERR_INVALID_ARG; } taskENTER_CRITICAL(&s_lock); @@ -801,7 +790,7 @@ esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token, } taskENTER_CRITICAL(&s_lock); admin_session_t *session = &s_sessions[token->slot_index]; - if (session->executing) { + if (session->active || session->executing) { taskEXIT_CRITICAL(&s_lock); return ESP_ERR_INVALID_STATE; } @@ -809,12 +798,14 @@ esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token, session->active = true; session->history_position = -1; session->token = *token; + session->owner = owner; session->principal = *principal; - static const char banner[] = - "ESP32 Serial Swiss Army Knife administrative SSH shell\r\n"; + const char *banner = token->transport == 0U + ? "ESP32 Serial Swiss Army Knife administrative SSH shell\r\n" + : "ESP32 Serial Swiss Army Knife administrative remote shell\r\n"; static const char prompt[] = "Run 'help' for supported remote administrative commands.\r\nadmin@serial-tool> "; - (void)append_output_locked(session, (const uint8_t *)banner, sizeof(banner) - 1U); + (void)append_output_locked(session, (const uint8_t *)banner, strlen(banner)); (void)append_output_locked(session, (const uint8_t *)prompt, sizeof(prompt) - 1U); taskEXIT_CRITICAL(&s_lock); return ESP_OK; @@ -855,7 +846,8 @@ bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token) bool shell_input = !session->command_pending && !session->deferred_action_pending; bool prompt_input = session->command_pending && session->executing && session->prompt_state == ADMIN_PROMPT_WAITING; - bool accepts = token_matches(session, token) && (shell_input || prompt_input) && + bool accepts = token_matches(session, token) && !session->deferred_action_pending && + (shell_input || prompt_input) && session->output_length <= ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - ADMIN_SSH_CONSOLE_RESPONSE_RESERVE; taskEXIT_CRITICAL(&s_lock); @@ -875,7 +867,7 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token, bool submit = false; taskENTER_CRITICAL(&s_lock); admin_session_t *session = &s_sessions[token->slot_index]; - if (!token_matches(session, token)) { + if (!token_matches(session, token) || session->deferred_action_pending) { taskEXIT_CRITICAL(&s_lock); return *consumed != 0U; } @@ -993,6 +985,10 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token, continue; } if (value == '\t') { + if (s_completion_busy) { + taskEXIT_CRITICAL(&s_lock); + return *consumed != 0U; + } if (session->input_cursor != session->input_length) { (void)append_output_locked(session, (const uint8_t *)"\a", 1U); ++*consumed; @@ -1001,6 +997,7 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token, } char current[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; memcpy(current, session->input, sizeof(current)); + s_completion_busy = true; ++*consumed; taskEXIT_CRITICAL(&s_lock); char completed[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U] = {0}; @@ -1033,6 +1030,8 @@ bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token, (void)append_output_locked(session, (const uint8_t *)"\a", 1U); } } + secure_wipe(s_completion_output, sizeof(s_completion_output)); + s_completion_busy = false; taskEXIT_CRITICAL(&s_lock); secure_wipe(current, sizeof(current)); secure_wipe(completed, sizeof(completed)); diff --git a/src/admin_ssh_console.h b/src/admin_ssh_console.h index c6628f9..8be060c 100644 --- a/src/admin_ssh_console.h +++ b/src/admin_ssh_console.h @@ -1,5 +1,5 @@ /* SPDX-License-Identifier: GPL-3.0-only */ -/* Bounded, transport-neutral administrative command worker for SSH sessions. */ +/* Bounded administrative dispatcher with a small remote-owner boundary. */ #pragma once @@ -21,6 +21,7 @@ typedef struct { uint8_t slot_index; uint32_t session_id; uint32_t slot_generation; + uint8_t transport; /* Zero is SSH, including legacy designated initializers. */ } admin_ssh_console_token_t; typedef enum { @@ -30,8 +31,45 @@ typedef enum { ADMIN_SSH_DEFER_DISCONNECT, ADMIN_SSH_DEFER_HOST_KEY_ROTATE, ADMIN_SSH_DEFER_HOST_KEY_RESET, + ADMIN_CONSOLE_DEFER_SELF_CLOSE, } admin_ssh_deferred_action_type_t; +/* Small owner boundary; module/API names are retained for existing SSH callers. + * Exactly two shared console slots, not two per transport. slot_index addresses + * this pool; owners coordinate admission and must not reuse an identity while + * old work can exist. transport is a firmware-assigned namespace (0 = SSH). + * An occupied or still-executing slot cannot be replaced by open_owned(). + * + * The immutable adapter lives for firmware lifetime. Callbacks run on the + * control task OUTSIDE console locks, never on the dispatcher or socket owner. + * drained must be nonblocking, validate the full identity and include pending + * owner output. perform must revalidate identity and marshal lifecycle work to + * its owner, never call socket libraries here. Neither callback may call console + * handlers. supported_actions is a bitmask (1U << action); reject unsupported + * actions before side effects. Legacy STOP/DISCONNECT/key actions mean SSH; + * SELF_CLOSE means this frontend, with argument ignored. + * + * One owner serializes feed calls per session; different owners may feed in + * parallel. Shared completion scratch is nonblocking/serialized by the core. + * The owner alone consumes output, maintains authentication/session liveness, + * and calls close on disconnect/revocation. Core copies/rechecks principals at + * admission and dispatch, but does not implement transport-specific expiry. + * Close wakes prompts; executing state is retained until the handler returns. + * Output remains bounded (5s write backpressure); deferred work waits at most + * 10s for application drain plus 200ms, NOT peer-delivery confirmation. + * No new tasks, queues, slots, or browser endpoint are provided by this API. + */ +typedef struct { + uint32_t supported_actions; + bool (*drained)(const admin_ssh_console_token_t *token); + esp_err_t (*perform)(const admin_ssh_console_token_t *token, + admin_ssh_deferred_action_type_t action, uint32_t argument); +} admin_console_owner_t; + +esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token, + const user_principal_t *principal, + const admin_console_owner_t *owner); + typedef struct { bool active; bool command_pending; @@ -57,18 +95,21 @@ esp_err_t admin_ssh_console_dispatch_read_input( esp_err_t admin_ssh_console_dispatch_defer( admin_ssh_deferred_action_type_t action, uint32_t argument); -/* The token and principal are copied; no SSH or socket objects cross this boundary. */ +/* SSH compatibility entry point, implemented by the owner in ssh_transport.c. + * Token/principal are copied; no SSH or socket objects cross this boundary. + * Existing feed/close/read/snapshot APIs below also accept open_owned tokens. + */ esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token, const user_principal_t *principal); void admin_ssh_console_close(const admin_ssh_console_token_t *token); -/* Called only by the SSH owner task. Returns false when input must be backpressured. */ +/* Called by the session owner. Returns false when input must be backpressured. */ bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token); bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token, const uint8_t *data, size_t length, size_t *consumed); -/* Called only by the SSH owner task; copies already-produced output without blocking. */ +/* Called by the session owner; copies already-produced output without blocking. */ esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token, uint8_t *data, size_t capacity, size_t *received); diff --git a/src/ssh_transport.c b/src/ssh_transport.c index c896949..39b7b3f 100644 --- a/src/ssh_transport.c +++ b/src/ssh_transport.c @@ -12,6 +12,7 @@ #include "admin_ssh_console.h" #include "esp_heap_caps.h" #include "esp_log.h" +#include "esp_system.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" @@ -179,6 +180,63 @@ static void publish_slot(const ssh_slot_t *slot, size_t slot_index) taskEXIT_CRITICAL(&s_lock); } +/* Control-task adapter: copied snapshots only, no runtime wolfSSH calls. */ +static bool admin_console_drained(const admin_ssh_console_token_t *token) +{ + if (token->transport != 0U || token->slot_index >= SSH_TRANSPORT_MAX_SESSIONS) { + return false; + } + taskENTER_CRITICAL(&s_lock); + const ssh_transport_session_snapshot_t *slot = &s_session_snapshots[token->slot_index]; + bool drained = slot->active && slot->session_id == token->session_id && + slot->generation == token->slot_generation && !slot->tx_pending; + taskEXIT_CRITICAL(&s_lock); + return drained; +} + +static esp_err_t admin_console_perform(const admin_ssh_console_token_t *token, + admin_ssh_deferred_action_type_t action, + uint32_t argument) +{ + if (!admin_console_drained(token)) { + return ESP_ERR_NOT_FOUND; + } + switch (action) { + case ADMIN_SSH_DEFER_REBOOT: + esp_restart(); + return ESP_OK; + case ADMIN_SSH_DEFER_STOP: + return ssh_transport_stop(); + case ADMIN_CONSOLE_DEFER_SELF_CLOSE: + return ssh_transport_disconnect(token->session_id); + case ADMIN_SSH_DEFER_DISCONNECT: + return ssh_transport_disconnect(argument); + case ADMIN_SSH_DEFER_HOST_KEY_ROTATE: + return ssh_transport_replace_host_key(false); + case ADMIN_SSH_DEFER_HOST_KEY_RESET: + return ssh_transport_replace_host_key(true); + default: + return ESP_ERR_NOT_SUPPORTED; + } +} + +esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token, + const user_principal_t *principal) +{ + static const admin_console_owner_t owner = { + .supported_actions = (1U << ADMIN_SSH_DEFER_REBOOT) | + (1U << ADMIN_SSH_DEFER_STOP) | (1U << ADMIN_SSH_DEFER_DISCONNECT) | + (1U << ADMIN_SSH_DEFER_HOST_KEY_ROTATE) | + (1U << ADMIN_SSH_DEFER_HOST_KEY_RESET) | (1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE), + .drained = admin_console_drained, + .perform = admin_console_perform, + }; + if (token == NULL || token->transport != 0U) { + return ESP_ERR_INVALID_ARG; + } + return admin_ssh_console_open_owned(token, principal, &owner); +} + static bool consume_external_close(const ssh_slot_t *slot, size_t slot_index) { taskENTER_CRITICAL(&s_lock); diff --git a/tests/admin_console_boundary/adapter.c b/tests/admin_console_boundary/adapter.c new file mode 100644 index 0000000..ef5d783 --- /dev/null +++ b/tests/admin_console_boundary/adapter.c @@ -0,0 +1,35 @@ + +#define SSH_TRANSPORT_MAX_SESSIONS 2U +typedef struct { bool active, tx_pending; uint32_t session_id, generation; } ssh_transport_session_snapshot_t; +static ssh_transport_session_snapshot_t s_session_snapshots[2]; +static unsigned stopped, disconnected, rotated, reset, restarted; +static esp_err_t ssh_transport_stop(void) { ++stopped; return ESP_OK; } +static esp_err_t ssh_transport_disconnect(uint32_t id) { disconnected=id; return ESP_OK; } +static esp_err_t ssh_transport_replace_host_key(bool r) { if(r) ++reset; else ++rotated; return ESP_OK; } +static void esp_restart(void) { ++restarted; } +static bool admin_console_drained(const admin_ssh_console_token_t *); +static esp_err_t admin_console_perform(const admin_ssh_console_token_t *, admin_ssh_deferred_action_type_t, uint32_t); +static void test_adapter(void) +{ + admin_ssh_console_token_t token={ .slot_index=0, .session_id=7, .slot_generation=3 }; + user_principal_t admin={ .role=USER_ROLE_ADMIN }; + assert(admin_ssh_console_init()==ESP_OK); + assert(admin_ssh_console_start_uart_frontend()==ESP_OK); + assert(admin_ssh_console_open(&token,&admin)==ESP_OK); + assert(!admin_console_drained(&token)); + s_session_snapshots[0]=(ssh_transport_session_snapshot_t){ .active=true, .session_id=7, .generation=3 }; + assert(admin_console_drained(&token)); + token.transport=1; assert(!admin_console_drained(&token)); + assert(admin_ssh_console_open(&token,&admin)==ESP_ERR_INVALID_ARG); + token.transport=0; token.slot_generation=4; assert(!admin_console_drained(&token)); + assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_FOUND && stopped==0); + token.slot_generation=3; s_session_snapshots[0].tx_pending=true; + assert(!admin_console_drained(&token)); s_session_snapshots[0].tx_pending=false; + assert(admin_console_perform(&token,ADMIN_CONSOLE_DEFER_SELF_CLOSE,99)==ESP_OK && disconnected==7); + assert(admin_console_perform(&token,ADMIN_SSH_DEFER_DISCONNECT,99)==ESP_OK && disconnected==99); + assert(admin_console_perform(&token,ADMIN_SSH_DEFER_STOP,0)==ESP_OK && stopped==1); + assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_ROTATE,0)==ESP_OK && rotated==1); + assert(admin_console_perform(&token,ADMIN_SSH_DEFER_HOST_KEY_RESET,0)==ESP_OK && reset==1); + assert(admin_console_perform(&token,ADMIN_SSH_DEFER_REBOOT,0)==ESP_OK && restarted==1); + puts("PASS: actual SSH adapter identity/drain checks, legacy admission and lifecycle action routing"); +} diff --git a/tests/admin_console_boundary/fakes.h b/tests/admin_console_boundary/fakes.h new file mode 100644 index 0000000..4f94c19 --- /dev/null +++ b/tests/admin_console_boundary/fakes.h @@ -0,0 +1,77 @@ +#include +#include +#include +#include +#include +#include +#include +#include +typedef int esp_err_t; +enum { ESP_OK, ESP_FAIL, ESP_ERR_INVALID_ARG, ESP_ERR_INVALID_STATE, + ESP_ERR_NO_MEM, ESP_ERR_TIMEOUT, ESP_ERR_NOT_SUPPORTED, ESP_ERR_NOT_FOUND }; +enum { USER_ROLE_USER, USER_ROLE_ADMIN }; +typedef struct { int role; } user_principal_t; +typedef unsigned TickType_t; +typedef void *TaskHandle_t; +typedef int portMUX_TYPE; +typedef struct { size_t size; unsigned count; unsigned char bytes[2048]; } StaticQueue_t; +typedef StaticQueue_t *QueueHandle_t; +typedef int StaticSemaphore_t; +typedef int *SemaphoreHandle_t; +#define portMUX_INITIALIZER_UNLOCKED 0 +#define pdTRUE 1 +#define pdPASS 1 +#define portMAX_DELAY UINT32_MAX +#define pdMS_TO_TICKS(x) (x) +#define CONSOLE_COMPLETION_OUTPUT_CAPACITY 1024U +static unsigned lock_depth, ticks, runs, actions; +static bool principal_current = true, queue_full, owner_drained = true; +static TaskHandle_t current_task = (void *)1; +static jmp_buf loop_done; +static void (*delay_hook)(void), (*prompt_hook)(void), (*completion_hook)(void); +static void (*command_hook)(void); +#define taskENTER_CRITICAL(p) ((void)(p), ++lock_depth) +#define taskEXIT_CRITICAL(p) ((void)(p), --lock_depth) +static void secure_wipe(void *p, size_t n) { memset(p, 0, n); } +static size_t strlcpy(char *d, const char *s, size_t n) { + size_t len = strlen(s); if (n) { size_t k = len < n-1 ? len : n-1; + memcpy(d, s, k); d[k] = 0; } return len; +} +static esp_err_t user_database_principal_is_current(const user_principal_t *p, bool *c) +{ (void)p; *c = principal_current; return ESP_OK; } +static const char *esp_err_to_name(int e) { (void)e; return "fake"; } +static TaskHandle_t xTaskGetCurrentTaskHandle(void) { return current_task; } +static unsigned xTaskGetTickCount(void) { return ticks; } +static void vTaskDelay(unsigned n) { assert(!lock_depth); ticks += n; if (delay_hook) delay_hook(); } +static int xTaskCreate(void (*f)(void *), const char *n, unsigned s, void *c, + unsigned p, TaskHandle_t *t) +{ (void)f; (void)n; (void)s; (void)c; (void)p; *t = (void *)1; return pdPASS; } +static void vTaskDelete(TaskHandle_t t) { (void)t; } +static void xTaskNotifyGive(TaskHandle_t t) { (void)t; } +static unsigned ulTaskNotifyTake(int b, unsigned t) { (void)b; (void)t; return 1; } +static QueueHandle_t xQueueCreateStatic(unsigned n, size_t s, uint8_t *b, StaticQueue_t *q) +{ (void)n; (void)b; q->size = s; return q; } +static int xQueueSend(QueueHandle_t q, const void *p, unsigned t) +{ (void)t; if (queue_full) return 0; assert(!q->count); memcpy(q->bytes,p,q->size); q->count=1; return 1; } +static int xQueueReceive(QueueHandle_t q, void *p, unsigned t) +{ (void)t; if (!q->count) longjmp(loop_done,1); memcpy(p,q->bytes,q->size); q->count=0; return 1; } +static SemaphoreHandle_t xSemaphoreCreateBinaryStatic(StaticSemaphore_t *s) { return s; } +static int xSemaphoreTake(SemaphoreHandle_t s, unsigned t) +{ if (t && prompt_hook) prompt_hook(); int r=*s; *s=0; return r; } +static int xSemaphoreGive(SemaphoreHandle_t s) { *s=1; return 1; } +static void linenoiseSetMaxLineLen(unsigned n) { (void)n; } +static char *linenoise(const char *p) { (void)p; return NULL; } +static int linenoiseHistoryAdd(const char *p) { (void)p; return 1; } +static void linenoiseFree(char *p) { (void)p; } +static bool console_completion_expand(const char *s, char *d, size_t n) +{ (void)s; (void)d; (void)n; if (completion_hook) completion_hook(); return false; } +static bool console_completion_format_matches(const char *s, char *d, size_t n, size_t *len) +{ (void)s; *len=strlcpy(d,"help\r\n",n); return true; } +static size_t esp_console_split_argv(char *s, char **v, size_t n) +{ (void)s; (void)v; (void)n; return 0; } /* Real parser tested by admin_ssh_policy. */ +static esp_err_t esp_console_run(const char *s, int *r) +{ (void)s; ++runs; if (command_hook) command_hook(); *r=0; return ESP_OK; } +typedef struct { const char *command, *help, *hint; int (*func)(int,char **); void *argtable; } esp_console_cmd_t; +static int esp_console_cmd_register(const esp_console_cmd_t *c) { (void)c; return 0; } +static FILE *funopen(void *c, void *r, int (*w)(void *,const char *,int), void *s, void *f) +{ (void)c; (void)r; (void)w; (void)s; (void)f; return tmpfile(); } diff --git a/tests/admin_console_boundary/run.py b/tests/admin_console_boundary/run.py new file mode 100644 index 0000000..9a6890c --- /dev/null +++ b/tests/admin_console_boundary/run.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Compile actual console implementation with deterministic host RTOS/IO fakes. + +No target scheduler, socket library, or hardware execution is claimed. +""" +from pathlib import Path +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +source = (ROOT / "src/admin_ssh_console.c").read_text() +header = (ROOT / "src/admin_ssh_console.h").read_text() +def strip_includes(text): + return "\n".join(line for line in text.splitlines() + if not line.startswith(("#include", "#pragma once"))) + +with tempfile.TemporaryDirectory(prefix="admin-console-boundary-") as directory: + path = Path(directory) + unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text() + + strip_includes(header) + "\n" + strip_includes(source) + + (ROOT / "tests/admin_console_boundary/test.c").read_text()) + (path / "test.c").write_text(unit) + subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", + "-g", str(path / "test.c"), + "-o", str(path / "test")], check=True, timeout=30) + subprocess.run([str(path / "test")], check=True, timeout=10) + ssh = (ROOT / "src/ssh_transport.c").read_text() + adapter = ssh[ssh.index("static bool admin_console_drained("): + ssh.index("static bool consume_external_close(")] + unit = ((ROOT / "tests/admin_console_boundary/fakes.h").read_text() + + strip_includes(header) + "\n" + strip_includes(source) + + (ROOT / "tests/admin_console_boundary/adapter.c").read_text() + + adapter + "\nint main(void) { test_adapter(); }\n") + (path / "adapter.c").write_text(unit) + subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror", + "-Wno-unused-variable", str(path / "adapter.c"), + "-o", str(path / "adapter")], check=True, timeout=30) + subprocess.run([str(path / "adapter")], check=True, timeout=10) diff --git a/tests/admin_console_boundary/test.c b/tests/admin_console_boundary/test.c new file mode 100644 index 0000000..8570d7d --- /dev/null +++ b/tests/admin_console_boundary/test.c @@ -0,0 +1,132 @@ + +static admin_ssh_console_token_t a = { .slot_index=0, .session_id=7, .slot_generation=1 }; +static admin_ssh_console_token_t b = { .slot_index=1, .session_id=7, .slot_generation=1, .transport=1 }; +static user_principal_t admin = { .role=USER_ROLE_ADMIN }; +static bool drained(const admin_ssh_console_token_t *t) +{ assert(!lock_depth); assert(t->session_id==7); return owner_drained; } +static esp_err_t perform(const admin_ssh_console_token_t *t, + admin_ssh_deferred_action_type_t action, uint32_t arg) +{ (void)t; (void)arg; assert(!lock_depth); assert(action==ADMIN_CONSOLE_DEFER_SELF_CLOSE); ++actions; return ESP_OK; } +static const admin_console_owner_t owner = { + .supported_actions=1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE, .drained=drained, .perform=perform, +}; +static void pump(void (*task)(void *)) { if (!setjmp(loop_done)) task(NULL); } +static void feed(const admin_ssh_console_token_t *t, const char *s) +{ size_t n=0; assert(admin_ssh_console_feed_input(t,(const uint8_t *)s,strlen(s),&n)); assert(n==strlen(s)); } +static void clear_output(const admin_ssh_console_token_t *t) +{ uint8_t out[4096]; size_t n; assert(admin_ssh_console_read_output(t,out,sizeof(out),&n)==ESP_OK); } +static void competing_completion(void) +{ + size_t n=99; + assert(!admin_ssh_console_feed_input(&b,(const uint8_t *)"\t",1,&n)); + assert(n==0 && s_completion_busy); +} +static void reopen_during_completion(void) +{ + assert(!lock_depth && s_completion_busy); + admin_ssh_console_close(&a); + ++a.slot_generation; + assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK); + clear_output(&a); +} +static void hidden_reply(void) { feed(&a,"secret\r"); } +static void cancel_reply(void) { feed(&a,"secret\x03"); } +static void close_prompt(void) { admin_ssh_console_close(&a); } +static void close_during_delay(void) { if (ticks>=200) admin_ssh_console_close(&a); } +static void close_during_command(void) +{ + assert(s_sessions[0].executing); + admin_ssh_console_close(&a); + assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE); +} +static void setup_dispatch(void) +{ s_dispatch_remote=true; s_dispatch_token=a; s_sessions[0].executing=true; s_sessions[0].command_pending=true; } +int main(void) +{ + assert(admin_ssh_console_init()==ESP_OK); + assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE); + assert(admin_ssh_console_start_uart_frontend()==ESP_OK); + principal_current=false; + assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE); + principal_current=true; + assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK); + assert(admin_ssh_console_open_owned(&b,&admin,&owner)==ESP_OK); + admin_ssh_console_token_t other=a; other.transport=1; + assert(admin_ssh_console_open_owned(&other,&admin,&owner)==ESP_ERR_INVALID_STATE); + admin_ssh_console_close(&other); + assert(!admin_ssh_console_accepts_input(&other)); + other=a; ++other.slot_generation; admin_ssh_console_close(&other); + assert(admin_ssh_console_accepts_input(&a)); + clear_output(&a); clear_output(&b); + completion_hook=competing_completion; feed(&a,"\t"); completion_hook=NULL; + assert(!s_completion_busy && s_sessions[0].output_length && !s_sessions[1].output_length); + /* Keep the in-flight token unchanged; only the reopened session advances. */ + admin_ssh_console_token_t completing=a; + completion_hook=reopen_during_completion; feed(&completing,"\t"); completion_hook=NULL; + assert(a.slot_generation==completing.slot_generation+1); + assert(!s_completion_busy && !s_sessions[0].output_length && !s_sessions[0].input_length); + assert(!admin_ssh_console_accepts_input(&completing)); + feed(&a,"\t"); assert(s_sessions[0].output_length); clear_output(&a); + feed(&a,"help\r"); assert(runs==0); pump(worker_task); assert(runs==1); + feed(&a,"\x1b[A"); assert(!strcmp((char *)s_sessions[0].input,"help")); + feed(&a,"\x03"); + feed(&a,"stale\r"); admin_ssh_console_close(&a); + ++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK); + pump(worker_task); assert(runs==1); + feed(&a,"revoked\r"); principal_current=false; pump(worker_task); assert(runs==1); principal_current=true; + admin_request_t uart={ .origin=ADMIN_REQUEST_UART0 }; + assert(xQueueSend(s_request_queue,&uart,0)); pump(worker_task); assert(runs==2); + feed(&a,"close\r"); command_hook=close_during_command; pump(worker_task); command_hook=NULL; + admin_session_t empty={0}; assert(!memcmp(&empty,&s_sessions[0],sizeof(empty))); + ++a.slot_generation; assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK); + setup_dispatch(); clear_output(&a); prompt_hook=hidden_reply; + uint8_t secret[32]; size_t n; + assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_OK); + assert(n==6 && !memcmp(secret,"secret",6)); + assert(s_sessions[0].output_length==strlen("Password: \r\n")); + clear_output(&a); + assert(admin_ssh_console_dispatch_read_input("Visible: ",secret,sizeof(secret),false,&n)==ESP_OK); + assert(s_sessions[0].output_length==strlen("Visible: secret\r\n")); + prompt_hook=cancel_reply; + assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_ERR_INVALID_STATE); + assert(n==0 && secret[0]==0 && s_sessions[0].prompt_input[0]==0); + prompt_hook=close_prompt; + assert(admin_ssh_console_dispatch_read_input("Password: ",secret,sizeof(secret),true,&n)==ESP_ERR_NOT_FOUND); + assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_ERR_INVALID_STATE); + secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); prompt_hook=NULL; + assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK); + setup_dispatch(); clear_output(&a); + assert(admin_ssh_console_dispatch_defer(ADMIN_SSH_DEFER_STOP,0)==ESP_ERR_NOT_SUPPORTED); + assert(!s_sessions[0].deferred_action_pending); + queue_full=true; + assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_ERR_TIMEOUT); + assert(!s_sessions[0].deferred_action_pending); queue_full=false; + assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK); + assert(!admin_ssh_console_feed_input(&a,(const uint8_t *)"x",1,&n) && n==0); + s_sessions[0].command_pending=false; owner_drained=false; ticks=0; + pump(control_task); assert(ticks==10000 && actions==0); + clear_output(&a); owner_drained=true; + /* The fake esp_console_run does not invoke registered command callbacks. */ + assert(command_exit(1,NULL)==0); + assert(s_control_queue->count==1); + admin_control_request_t exit_request; + memcpy(&exit_request,s_control_queue->bytes,sizeof(exit_request)); + assert(exit_request.action==ADMIN_CONSOLE_DEFER_SELF_CLOSE); + assert(token_matches(&s_sessions[0],&exit_request.token)); + assert(exit_request.owner==&owner && exit_request.argument==a.session_id); + ticks=0; pump(control_task); assert(ticks==200 && actions==1); + clear_output(&a); + assert(admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_SELF_CLOSE,0)==ESP_OK); + ticks=0; delay_hook=close_during_delay; pump(control_task); delay_hook=NULL; assert(actions==1); + secure_wipe(&s_sessions[0],sizeof(s_sessions[0])); + assert(admin_ssh_console_open_owned(&a,&admin,&owner)==ESP_OK); + s_sessions[0].output_length=4096; ticks=0; + assert(ssh_output_write(&a,"x",1)==-1 && errno==EAGAIN && ticks==5000); + clear_output(&a); s_dispatch_output_previous_cr=false; + assert(ssh_output_write(&a,"a\nb\r\n",6)==6); + assert(s_sessions[0].output_length==7); + admin_ssh_console_close(&a); + assert(ssh_output_write(&a,"x",1)==-1 && errno==EPIPE); + assert(!lock_depth); + 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"); +}