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:
+53
-54
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user