Add SSH Console Ownership Boundary (Phase 8D.4)

Implement transport-qualified session identity and immutable owner
adapters
for SSH console lifecycle and output-drain operations. Add focused host
tests
covering admission, stale identities, deferred actions, completion
races,
prompts, backpressure, and slot reuse. Update Phase 8D documentation and
current-state tracking.
This commit is contained in:
2026-09-06 09:07:11 +02:00
parent f9ee6eec9c
commit 117c694cd4
13 changed files with 488 additions and 60 deletions
+53 -54
View File
@@ -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));
+45 -4
View File
@@ -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);
+58
View File
@@ -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);