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));