Add Serialized SSH Administrative Console

This commit is contained in:
2026-08-30 18:07:02 +02:00
parent 44e3962444
commit 0a1bbd6782
17 changed files with 1115 additions and 79 deletions
+628
View File
@@ -0,0 +1,628 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* Serialized, bounded administrative SSH command worker. */
#include "admin_ssh_console.h"
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include "esp_console.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#include "freertos/task.h"
#include "linenoise/linenoise.h"
#include "secure_random.h"
#include "user_database.h"
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
#define ADMIN_SSH_CONSOLE_RESPONSE_RESERVE 512U
#define ADMIN_SSH_CONSOLE_REQUEST_QUEUE_LENGTH 4U
#define ADMIN_SSH_CONSOLE_TASK_STACK_SIZE 12288U
#define ADMIN_SSH_CONSOLE_TASK_PRIORITY 4U
#define ADMIN_UART_CONSOLE_TASK_STACK_SIZE 6144U
#define ADMIN_UART_CONSOLE_TASK_PRIORITY 3U
#define ADMIN_SSH_CONSOLE_MAX_ARGUMENTS 10U
typedef struct {
bool active;
bool command_pending;
bool executing;
admin_ssh_console_token_t token;
user_principal_t principal;
size_t input_length;
uint8_t input[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
size_t output_start;
size_t output_length;
uint8_t output[ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY];
} admin_session_t;
typedef enum {
ADMIN_REQUEST_SSH = 0,
ADMIN_REQUEST_UART0,
} admin_request_origin_t;
typedef struct {
admin_request_origin_t origin;
admin_ssh_console_token_t token;
user_principal_t principal;
TaskHandle_t completion_task;
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
} admin_request_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_MAX_SESSIONS];
static StaticQueue_t s_request_queue_storage;
static uint8_t s_request_queue_bytes[ADMIN_SSH_CONSOLE_REQUEST_QUEUE_LENGTH *
sizeof(admin_request_t)];
static QueueHandle_t s_request_queue;
static TaskHandle_t s_task;
static TaskHandle_t s_uart_task;
static bool s_initialized;
static bool s_dispatch_ready;
/* Accessed only by the single dispatcher task while a callback is running. */
static bool s_dispatch_remote;
static bool s_dispatch_output_previous_cr;
static user_principal_t s_dispatch_principal;
bool admin_ssh_console_dispatch_is_remote(void)
{
return xTaskGetCurrentTaskHandle() == s_task && s_dispatch_remote;
}
const user_principal_t *admin_ssh_console_dispatch_principal(void)
{
return admin_ssh_console_dispatch_is_remote() ? &s_dispatch_principal : NULL;
}
static bool token_valid(const admin_ssh_console_token_t *token)
{
return token != NULL && token->slot_index < ADMIN_SSH_CONSOLE_MAX_SESSIONS &&
token->session_id != 0U && token->slot_generation != 0U;
}
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.slot_generation == token->slot_generation;
}
static bool token_matches(const admin_session_t *session,
const admin_ssh_console_token_t *token)
{
return session->active && token_identity_matches(session, token);
}
static bool append_output_locked(admin_session_t *session,
const uint8_t *data, size_t length)
{
if (data == NULL || length > ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_length) {
return false;
}
size_t write_offset = (session->output_start + session->output_length) %
ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY;
size_t first = length;
if (first > ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - write_offset) {
first = ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - write_offset;
}
memcpy(session->output + write_offset, data, first);
if (length > first) {
memcpy(session->output, data + first, length - first);
}
session->output_length += length;
return true;
}
static bool worker_write(const admin_ssh_console_token_t *token,
const char *text)
{
if (!token_valid(token) || text == NULL) {
return false;
}
size_t length = strlen(text);
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
bool written = token_matches(session, token) &&
append_output_locked(session, (const uint8_t *)text, length);
taskEXIT_CRITICAL(&s_lock);
return written;
}
static void print_prompt(const admin_ssh_console_token_t *token)
{
(void)worker_write(token, "admin@serial-tool> ");
}
static int ssh_output_write(void *cookie, const char *buffer, int length)
{
const admin_ssh_console_token_t *token = cookie;
if (!token_valid(token) || buffer == NULL || length <= 0) {
return length == 0 ? 0 : -1;
}
size_t offset = 0U;
TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(5000U);
while (offset < (size_t)length) {
const uint8_t value = (uint8_t)buffer[offset];
uint8_t translated[2] = {value, 0U};
size_t translated_length = 1U;
if (value == '\n' && !s_dispatch_output_previous_cr) {
translated[0] = '\r';
translated[1] = '\n';
translated_length = 2U;
}
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
bool active = token_matches(session, token);
size_t available = active
? ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_length
: 0U;
bool written = active && available >= translated_length &&
append_output_locked(session, translated, translated_length);
taskEXIT_CRITICAL(&s_lock);
if (!active) {
errno = EPIPE;
return offset == 0U ? -1 : (int)offset;
}
if (written) {
s_dispatch_output_previous_cr = value == '\r';
++offset;
continue;
}
if ((int32_t)(xTaskGetTickCount() - deadline) >= 0) {
errno = EAGAIN;
return offset == 0U ? -1 : (int)offset;
}
vTaskDelay(pdMS_TO_TICKS(5U));
}
return length;
}
static bool remote_command_allowed(const admin_request_t *request)
{
char copy[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
memcpy(copy, request->line, sizeof(copy));
char *argv[ADMIN_SSH_CONSOLE_MAX_ARGUMENTS] = {0};
/* Use exactly the same quote/escape parser as esp_console_run(). */
size_t argc = esp_console_split_argv(copy, argv, ADMIN_SSH_CONSOLE_MAX_ARGUMENTS);
bool allowed = argc > 0U;
if (allowed && strcmp(argv[0], "reboot") == 0) {
allowed = false;
} else if (allowed && strcmp(argv[0], "ping") == 0) {
allowed = false;
} else if (allowed && strcmp(argv[0], "user") == 0 && argc >= 2U) {
if (strcmp(argv[1], "bootstrap") == 0 || strcmp(argv[1], "recover") == 0) {
allowed = false;
} else if (strcmp(argv[1], "add") == 0) {
allowed = argc == 5U && strcmp(argv[4], "--generate") == 0;
} else if (strcmp(argv[1], "password") == 0) {
allowed = argc == 4U && strcmp(argv[3], "--generate") == 0;
} else if (strcmp(argv[1], "key") == 0 && argc >= 3U &&
strcmp(argv[2], "add") == 0) {
allowed = argc == 6U;
}
} else if (allowed && strcmp(argv[0], "wifi") == 0 && argc >= 2U) {
if (strcmp(argv[1], "ping") == 0) {
allowed = false;
} else if (strcmp(argv[1], "profile") == 0 && argc >= 3U &&
strcmp(argv[2], "secret") == 0) {
allowed = false;
} else if (strcmp(argv[1], "ap") == 0 && argc >= 3U &&
(strcmp(argv[2], "secret") == 0 ||
strcmp(argv[2], "show-secret") == 0)) {
allowed = false;
}
} else if (allowed && strcmp(argv[0], "web") == 0 && argc >= 2U) {
allowed = strcmp(argv[1], "credentials") != 0 &&
strcmp(argv[1], "certificate") != 0 &&
strcmp(argv[1], "reset") != 0;
} else if (allowed && strcmp(argv[0], "ssh") == 0 && argc >= 2U) {
allowed = strcmp(argv[1], "start") != 0 && strcmp(argv[1], "stop") != 0 &&
strcmp(argv[1], "disconnect") != 0 && strcmp(argv[1], "reset") != 0;
if (allowed && strcmp(argv[1], "host-key") == 0) {
allowed = argc == 3U && strcmp(argv[2], "info") == 0;
}
}
secure_wipe(copy, sizeof(copy));
return allowed;
}
static void report_command_result(esp_err_t error, int command_result)
{
if (error == ESP_ERR_NOT_FOUND) {
printf("Unrecognized command\n");
} else if (error == ESP_OK && command_result != 0) {
printf("Command returned non-zero error code: 0x%x (%s)\n",
command_result, esp_err_to_name(command_result));
} else if (error != ESP_OK && error != ESP_ERR_INVALID_ARG) {
printf("Internal error: %s\n", esp_err_to_name(error));
}
}
static void dispatch_registered_command(admin_request_t *request)
{
FILE *saved_stdout = stdout;
FILE *saved_stderr = stderr;
FILE *remote_stream = NULL;
if (request->origin == ADMIN_REQUEST_SSH) {
remote_stream = funopen(&request->token, NULL, ssh_output_write, NULL, NULL);
if (remote_stream == NULL) {
(void)worker_write(&request->token, "Could not create command output stream.\r\n");
return;
}
setvbuf(remote_stream, NULL, _IONBF, 0);
stdout = remote_stream;
stderr = remote_stream;
s_dispatch_output_previous_cr = false;
s_dispatch_remote = true;
s_dispatch_principal = request->principal;
} else {
s_dispatch_remote = false;
secure_wipe(&s_dispatch_principal, sizeof(s_dispatch_principal));
}
int command_result = 0;
esp_err_t error = esp_console_run((const char *)request->line, &command_result);
report_command_result(error, command_result);
fflush(stdout);
s_dispatch_remote = false;
s_dispatch_output_previous_cr = false;
secure_wipe(&s_dispatch_principal, sizeof(s_dispatch_principal));
if (remote_stream != NULL) {
stdout = saved_stdout;
stderr = saved_stderr;
fclose(remote_stream);
}
}
static void worker_task(void *context)
{
(void)context;
for (;;) {
admin_request_t request;
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
continue;
}
if (request.origin == ADMIN_REQUEST_UART0) {
dispatch_registered_command(&request);
if (request.completion_task != NULL) {
xTaskNotifyGive(request.completion_task);
}
secure_wipe(&request, sizeof(request));
continue;
}
bool current = false;
esp_err_t auth_error = user_database_principal_is_current(&request.principal, &current);
bool active;
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[request.token.slot_index];
active = token_matches(session, &request.token) && session->command_pending &&
!session->executing;
if (active) {
session->executing = true;
}
taskEXIT_CRITICAL(&s_lock);
bool authorized = active && auth_error == ESP_OK && current &&
request.principal.role == USER_ROLE_ADMIN &&
remote_command_allowed(&request);
if (authorized) {
dispatch_registered_command(&request);
} else if (active) {
(void)worker_write(&request.token,
auth_error == ESP_OK && current
? "Command is restricted to physical UART0.\r\n"
: "Administrative authorization is no longer current; closing session.\r\n");
}
bool prompt = false;
taskENTER_CRITICAL(&s_lock);
session = &s_sessions[request.token.slot_index];
if (token_matches(session, &request.token)) {
session->executing = false;
session->command_pending = false;
prompt = auth_error == ESP_OK && current &&
request.principal.role == USER_ROLE_ADMIN;
} else if (!session->active && session->executing &&
token_identity_matches(session, &request.token)) {
/* A disconnect invalidated this executing request; erase buffered secrets. */
secure_wipe(session, sizeof(*session));
}
taskEXIT_CRITICAL(&s_lock);
if (prompt) {
print_prompt(&request.token);
}
secure_wipe(&request, sizeof(request));
}
}
static void uart_frontend_task(void *context)
{
(void)context;
setvbuf(stdin, NULL, _IONBF, 0);
linenoiseSetMaxLineLen(ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY);
printf("\r\nType 'help' to get the list of commands.\r\n"
"Use UP/DOWN arrows for history and TAB for completion.\r\n");
for (;;) {
char *line = linenoise("serial-tool> ");
if (line == NULL) {
continue;
}
if (line[0] != '\0') {
(void)linenoiseHistoryAdd(line);
}
admin_request_t request = {
.origin = ADMIN_REQUEST_UART0,
.completion_task = xTaskGetCurrentTaskHandle(),
};
strlcpy((char *)request.line, line, sizeof(request.line));
linenoiseFree(line);
(void)ulTaskNotifyTake(pdTRUE, 0U);
if (xQueueSend(s_request_queue, &request, portMAX_DELAY) == pdTRUE) {
(void)ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
} else {
printf("Administrative command queue unavailable.\n");
}
secure_wipe(&request, sizeof(request));
}
}
esp_err_t admin_ssh_console_init(void)
{
taskENTER_CRITICAL(&s_lock);
if (s_initialized) {
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
taskEXIT_CRITICAL(&s_lock);
s_request_queue = xQueueCreateStatic(ADMIN_SSH_CONSOLE_REQUEST_QUEUE_LENGTH,
sizeof(admin_request_t), s_request_queue_bytes,
&s_request_queue_storage);
if (s_request_queue == NULL) {
return ESP_ERR_NO_MEM;
}
if (xTaskCreate(worker_task, "admin_ssh_console", ADMIN_SSH_CONSOLE_TASK_STACK_SIZE,
NULL, ADMIN_SSH_CONSOLE_TASK_PRIORITY, &s_task) != pdPASS) {
s_task = NULL;
return ESP_ERR_NO_MEM;
}
taskENTER_CRITICAL(&s_lock);
s_initialized = true;
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t admin_ssh_console_start_uart_frontend(void)
{
taskENTER_CRITICAL(&s_lock);
bool initialized = s_initialized;
bool already_started = s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!initialized) {
return ESP_ERR_INVALID_STATE;
}
if (already_started) {
return ESP_OK;
}
if (xTaskCreate(uart_frontend_task, "admin_uart_console",
ADMIN_UART_CONSOLE_TASK_STACK_SIZE, NULL,
ADMIN_UART_CONSOLE_TASK_PRIORITY, &s_uart_task) != pdPASS) {
s_uart_task = NULL;
return ESP_ERR_NO_MEM;
}
taskENTER_CRITICAL(&s_lock);
s_dispatch_ready = true;
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
if (!token_valid(token) || principal == NULL || principal->role != USER_ROLE_ADMIN) {
return ESP_ERR_INVALID_ARG;
}
taskENTER_CRITICAL(&s_lock);
bool ready = s_initialized && s_dispatch_ready;
taskEXIT_CRITICAL(&s_lock);
if (!ready) {
return ESP_ERR_INVALID_STATE;
}
bool current = false;
if (user_database_principal_is_current(principal, &current) != ESP_OK || !current) {
return ESP_ERR_INVALID_STATE;
}
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
if (session->executing) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_INVALID_STATE;
}
secure_wipe(session, sizeof(*session));
session->active = true;
session->token = *token;
session->principal = *principal;
static const char banner[] =
"ESP32 Serial Swiss Army Knife administrative SSH 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 *)prompt, sizeof(prompt) - 1U);
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
void admin_ssh_console_close(const admin_ssh_console_token_t *token)
{
if (!token_valid(token)) {
return;
}
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
bool matched = token_matches(session, token);
if (matched) {
session->active = false;
if (!session->executing) {
secure_wipe(session, sizeof(*session));
}
}
taskEXIT_CRITICAL(&s_lock);
}
bool admin_ssh_console_accepts_input(const admin_ssh_console_token_t *token)
{
if (!token_valid(token)) {
return false;
}
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
bool accepts = token_matches(session, token) && !session->command_pending &&
session->output_length <= ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY -
ADMIN_SSH_CONSOLE_RESPONSE_RESERVE;
taskEXIT_CRITICAL(&s_lock);
return accepts;
}
bool admin_ssh_console_feed_input(const admin_ssh_console_token_t *token,
const uint8_t *data, size_t length,
size_t *consumed)
{
if (consumed == NULL || !token_valid(token) || (data == NULL && length != 0U)) {
return false;
}
*consumed = 0U;
for (size_t index = 0U; index < length; ++index) {
admin_request_t request = {0};
bool submit = false;
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
if (!token_matches(session, token) || session->command_pending) {
taskEXIT_CRITICAL(&s_lock);
return *consumed != 0U;
}
uint8_t value = data[index];
if (session->output_length >= ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY -
ADMIN_SSH_CONSOLE_RESPONSE_RESERVE) {
taskEXIT_CRITICAL(&s_lock);
return *consumed != 0U;
}
if (value == '\r' || value == '\n') {
if (value == '\n' && session->input_length == 0U) {
++*consumed;
taskEXIT_CRITICAL(&s_lock);
continue;
}
memcpy(request.line, session->input, session->input_length);
request.origin = ADMIN_REQUEST_SSH;
request.token = *token;
request.principal = session->principal;
session->input_length = 0U;
session->command_pending = true;
(void)append_output_locked(session, (const uint8_t *)"\r\n",
sizeof("\r\n") - 1U);
submit = true;
} else if (value == 0x03U) {
session->input_length = 0U;
(void)append_output_locked(session, (const uint8_t *)"^C\r\n",
sizeof("^C\r\n") - 1U);
(void)append_output_locked(session, (const uint8_t *)"admin@serial-tool> ",
sizeof("admin@serial-tool> ") - 1U);
} else if (value == 0x08U || value == 0x7fU) {
if (session->input_length > 0U) {
--session->input_length;
(void)append_output_locked(session, (const uint8_t *)"\b \b",
sizeof("\b \b") - 1U);
}
} else if (value >= 0x20U && value <= 0x7eU) {
if (session->input_length >= ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY) {
session->input_length = 0U;
(void)append_output_locked(session, (const uint8_t *)
"\r\nCommand too long; discarded.\r\nadmin@serial-tool> ",
sizeof("\r\nCommand too long; discarded.\r\nadmin@serial-tool> ") - 1U);
} else {
session->input[session->input_length++] = value;
(void)append_output_locked(session, &value, 1U);
}
}
++*consumed;
taskEXIT_CRITICAL(&s_lock);
if (submit && xQueueSend(s_request_queue, &request, 0U) != pdTRUE) {
taskENTER_CRITICAL(&s_lock);
session = &s_sessions[token->slot_index];
if (token_matches(session, token)) {
session->command_pending = false;
(void)append_output_locked(session, (const uint8_t *)
"Administrative command queue is busy.\r\nadmin@serial-tool> ",
sizeof("Administrative command queue is busy.\r\nadmin@serial-tool> ") - 1U);
}
taskEXIT_CRITICAL(&s_lock);
}
secure_wipe(&request, sizeof(request));
}
return true;
}
esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
uint8_t *data, size_t capacity,
size_t *received)
{
if (received == NULL || data == NULL || capacity == 0U || !token_valid(token)) {
return ESP_ERR_INVALID_ARG;
}
*received = 0U;
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
if (!token_matches(session, token)) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_NOT_FOUND;
}
size_t copied = session->output_length < capacity ? session->output_length : capacity;
size_t first = copied;
if (first > ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_start) {
first = ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_start;
}
memcpy(data, session->output + session->output_start, first);
if (copied > first) {
memcpy(data + first, session->output, copied - first);
}
session->output_start = (session->output_start + copied) %
ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY;
session->output_length -= copied;
*received = copied;
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t admin_ssh_console_get_session_snapshot(
const admin_ssh_console_token_t *token,
admin_ssh_console_session_snapshot_t *snapshot)
{
if (snapshot == NULL || !token_valid(token)) {
return ESP_ERR_INVALID_ARG;
}
memset(snapshot, 0, sizeof(*snapshot));
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
if (!token_matches(session, token)) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_NOT_FOUND;
}
snapshot->active = true;
snapshot->command_pending = session->command_pending;
snapshot->input_pending = session->input_length != 0U;
snapshot->output_pending = session->output_length != 0U;
snapshot->input_length = session->input_length;
snapshot->output_length = session->output_length;
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}