/* SPDX-License-Identifier: GPL-3.0-only */ /* Serialized, bounded administrative SSH command worker. */ #include "admin_ssh_console.h" #include #include #include #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 #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 #define ADMIN_SSH_CONSOLE_HISTORY_DEPTH 4U #define ADMIN_SSH_CONTROL_QUEUE_LENGTH 2U #define ADMIN_SSH_CONTROL_TASK_STACK_SIZE 4096U #define ADMIN_SSH_CONTROL_TASK_PRIORITY 3U typedef enum { ADMIN_PROMPT_NONE = 0, ADMIN_PROMPT_WAITING, ADMIN_PROMPT_SUBMITTED, ADMIN_PROMPT_CANCELLED, ADMIN_PROMPT_DISCONNECTED, } admin_prompt_state_t; typedef struct { bool active; bool command_pending; bool executing; bool deferred_action_pending; admin_ssh_console_token_t token; user_principal_t principal; size_t input_length; size_t input_cursor; uint8_t input[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; uint8_t history[ADMIN_SSH_CONSOLE_HISTORY_DEPTH] [ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; uint8_t history_count; int8_t history_position; uint8_t draft[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; size_t draft_length; uint8_t escape_state; uint8_t escape_parameters[4]; size_t escape_parameter_length; bool discard_next_lf; admin_prompt_state_t prompt_state; bool prompt_hidden; size_t prompt_capacity; size_t prompt_length; uint8_t prompt_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; typedef struct { admin_ssh_deferred_action_type_t action; admin_ssh_console_token_t token; 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. */ static char s_completion_output[CONSOLE_COMPLETION_OUTPUT_CAPACITY]; 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 StaticQueue_t s_control_queue_storage; static uint8_t s_control_queue_bytes[ADMIN_SSH_CONTROL_QUEUE_LENGTH * sizeof(admin_control_request_t)]; static QueueHandle_t s_control_queue; static StaticSemaphore_t s_prompt_done_storage; static SemaphoreHandle_t s_prompt_done; static TaskHandle_t s_task; static TaskHandle_t s_uart_task; static TaskHandle_t s_control_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 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) { 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 bool redraw_line_locked(admin_session_t *session) { static const char prefix[] = "\r\x1b[2Kadmin@serial-tool> "; char cursor_back[16] = {0}; size_t tail_length = session->input_length - session->input_cursor; size_t cursor_back_length = 0U; if (tail_length > 0U) { int written = snprintf(cursor_back, sizeof(cursor_back), "\x1b[%uD", (unsigned int)tail_length); if (written < 0 || (size_t)written >= sizeof(cursor_back)) { return false; } cursor_back_length = (size_t)written; } size_t required = sizeof(prefix) - 1U + session->input_length + cursor_back_length; if (required > ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_length) { return append_output_locked(session, (const uint8_t *)"\a", 1U); } (void)append_output_locked(session, (const uint8_t *)prefix, sizeof(prefix) - 1U); (void)append_output_locked(session, session->input, session->input_length); return cursor_back_length == 0U || append_output_locked(session, (const uint8_t *)cursor_back, cursor_back_length); } static void history_commit_locked(admin_session_t *session) { if (session->input_length == 0U || (session->history_count > 0U && strcmp((const char *)session->history[0], (const char *)session->input) == 0)) { return; } for (size_t index = ADMIN_SSH_CONSOLE_HISTORY_DEPTH - 1U; index > 0U; --index) { memcpy(session->history[index], session->history[index - 1U], sizeof(session->history[index])); } memcpy(session->history[0], session->input, sizeof(session->history[0])); if (session->history_count < ADMIN_SSH_CONSOLE_HISTORY_DEPTH) { ++session->history_count; } } static void history_move_locked(admin_session_t *session, bool older); static void editor_key_locked(admin_session_t *session, uint8_t key) { if (key == 'A' || key == 'B') { history_move_locked(session, key == 'A'); return; } if (key == 'C' && session->input_cursor < session->input_length) { ++session->input_cursor; } else if (key == 'D' && session->input_cursor > 0U) { --session->input_cursor; } else if (key == 'H') { session->input_cursor = 0U; } else if (key == 'F') { session->input_cursor = session->input_length; } else if (key == 'X' && session->input_cursor < session->input_length) { memmove(session->input + session->input_cursor, session->input + session->input_cursor + 1U, session->input_length - session->input_cursor); --session->input_length; session->history_position = -1; } else { (void)append_output_locked(session, (const uint8_t *)"\a", 1U); return; } (void)redraw_line_locked(session); } static void history_move_locked(admin_session_t *session, bool older) { if (older) { if (session->history_count == 0U || session->history_position + 1 >= (int8_t)session->history_count) { (void)append_output_locked(session, (const uint8_t *)"\a", 1U); return; } if (session->history_position < 0) { memcpy(session->draft, session->input, sizeof(session->draft)); session->draft_length = session->input_length; } ++session->history_position; memcpy(session->input, session->history[session->history_position], sizeof(session->input)); session->input_length = strlen((const char *)session->input); session->input_cursor = session->input_length; } else { if (session->history_position < 0) { (void)append_output_locked(session, (const uint8_t *)"\a", 1U); return; } --session->history_position; if (session->history_position < 0) { memcpy(session->input, session->draft, sizeof(session->input)); session->input_length = session->draft_length; session->input_cursor = session->input_length; } else { memcpy(session->input, session->history[session->history_position], sizeof(session->input)); session->input_length = strlen((const char *)session->input); session->input_cursor = session->input_length; } } (void)redraw_line_locked(session); } esp_err_t admin_ssh_console_dispatch_read_input( const char *prompt, uint8_t *output, size_t capacity, bool hidden, size_t *output_length) { if (!admin_ssh_console_dispatch_is_remote() || prompt == NULL || output == NULL || output_length == NULL || capacity == 0U || capacity > ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U) { return ESP_ERR_INVALID_ARG; } *output_length = 0U; memset(output, 0, capacity); (void)xSemaphoreTake(s_prompt_done, 0U); taskENTER_CRITICAL(&s_lock); admin_session_t *session = &s_sessions[s_dispatch_token.slot_index]; if (!token_matches(session, &s_dispatch_token) || !session->executing || session->prompt_state != ADMIN_PROMPT_NONE) { taskEXIT_CRITICAL(&s_lock); return ESP_ERR_INVALID_STATE; } secure_wipe(session->prompt_input, sizeof(session->prompt_input)); session->prompt_length = 0U; session->prompt_capacity = capacity; session->prompt_hidden = hidden; session->prompt_state = ADMIN_PROMPT_WAITING; bool published = append_output_locked(session, (const uint8_t *)prompt, strlen(prompt)); if (!published) { session->prompt_state = ADMIN_PROMPT_NONE; session->prompt_capacity = 0U; } taskEXIT_CRITICAL(&s_lock); if (!published) { return ESP_ERR_NO_MEM; } if (xSemaphoreTake(s_prompt_done, portMAX_DELAY) != pdTRUE) { return ESP_FAIL; } esp_err_t result = ESP_ERR_INVALID_STATE; taskENTER_CRITICAL(&s_lock); session = &s_sessions[s_dispatch_token.slot_index]; if (session->prompt_state == ADMIN_PROMPT_SUBMITTED) { memcpy(output, session->prompt_input, session->prompt_length); *output_length = session->prompt_length; result = ESP_OK; } else if (session->prompt_state == ADMIN_PROMPT_DISCONNECTED) { result = ESP_ERR_NOT_FOUND; } secure_wipe(session->prompt_input, sizeof(session->prompt_input)); session->prompt_length = 0U; session->prompt_capacity = 0U; session->prompt_hidden = false; session->prompt_state = ADMIN_PROMPT_NONE; taskEXIT_CRITICAL(&s_lock); return result; } esp_err_t admin_ssh_console_dispatch_defer( admin_ssh_deferred_action_type_t action, uint32_t argument) { if (!admin_ssh_console_dispatch_is_remote() || action == ADMIN_SSH_DEFER_NONE) { return ESP_ERR_INVALID_STATE; } taskENTER_CRITICAL(&s_lock); admin_session_t *session = &s_sessions[s_dispatch_token.slot_index]; bool valid = token_matches(session, &s_dispatch_token) && !session->deferred_action_pending; if (valid) { session->deferred_action_pending = true; } taskEXIT_CRITICAL(&s_lock); if (!valid) { return ESP_ERR_INVALID_STATE; } admin_control_request_t request = { .action = action, .token = s_dispatch_token, .argument = argument, }; if (xQueueSend(s_control_queue, &request, 0U) == pdTRUE) { return ESP_OK; } taskENTER_CRITICAL(&s_lock); session = &s_sessions[s_dispatch_token.slot_index]; if (token_matches(session, &s_dispatch_token)) { session->deferred_action_pending = false; } taskEXIT_CRITICAL(&s_lock); return ESP_ERR_TIMEOUT; } 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], "user") == 0 && argc >= 2U && (strcmp(argv[1], "bootstrap") == 0 || strcmp(argv[1], "recover") == 0)) { allowed = false; } 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 int command_exit(int argc, char **argv) { (void)argv; if (argc != 1) { printf("Usage: exit\n"); return 1; } if (!admin_ssh_console_dispatch_is_remote()) { printf("The exit command is available only from an administrative SSH session.\n"); return 1; } esp_err_t error = admin_ssh_console_dispatch_defer( ADMIN_SSH_DEFER_DISCONNECT, s_dispatch_token.session_id); if (error != ESP_OK) { printf("Could not schedule SSH session close: %s\n", esp_err_to_name(error)); return 1; } printf("SSH session close scheduled after output drains.\n"); return 0; } 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_token = request->token; 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_token, sizeof(s_dispatch_token)); 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, ¤t); 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 && !session->deferred_action_pending; } 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 finish_deferred_request(const admin_control_request_t *request, esp_err_t result, bool cancelled) { char message[160]; if (cancelled) { snprintf(message, sizeof(message), "Deferred action cancelled before SSH output drained.\r\nadmin@serial-tool> "); } else if (result == ESP_OK) { snprintf(message, sizeof(message), "Deferred SSH action completed.\r\nadmin@serial-tool> "); } else { snprintf(message, sizeof(message), "Deferred SSH action failed: %s\r\nadmin@serial-tool> ", esp_err_to_name(result)); } taskENTER_CRITICAL(&s_lock); admin_session_t *session = &s_sessions[request->token.slot_index]; if (token_matches(session, &request->token)) { (void)append_output_locked(session, (const uint8_t *)message, strlen(message)); session->deferred_action_pending = false; } taskEXIT_CRITICAL(&s_lock); } static void control_task(void *context) { (void)context; for (;;) { admin_control_request_t request; if (xQueueReceive(s_control_queue, &request, portMAX_DELAY) != pdTRUE) { continue; } TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(10000U); bool drained = false; while ((int32_t)(xTaskGetTickCount() - deadline) < 0) { taskENTER_CRITICAL(&s_lock); admin_session_t *session = &s_sessions[request.token.slot_index]; bool current = token_matches(session, &request.token); bool console_drained = current && !session->command_pending && session->output_length == 0U; taskEXIT_CRITICAL(&s_lock); 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; } } } if (console_drained && transport_drained) { drained = true; break; } vTaskDelay(pdMS_TO_TICKS(10U)); } if (!drained) { finish_deferred_request(&request, ESP_ERR_TIMEOUT, true); secure_wipe(&request, sizeof(request)); 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; } finish_deferred_request(&request, result, false); 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); s_control_queue = xQueueCreateStatic(ADMIN_SSH_CONTROL_QUEUE_LENGTH, sizeof(admin_control_request_t), s_control_queue_bytes, &s_control_queue_storage); s_prompt_done = xSemaphoreCreateBinaryStatic(&s_prompt_done_storage); if (s_request_queue == NULL || s_control_queue == NULL || s_prompt_done == 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; } if (xTaskCreate(control_task, "admin_ssh_control", ADMIN_SSH_CONTROL_TASK_STACK_SIZE, NULL, ADMIN_SSH_CONTROL_TASK_PRIORITY, &s_control_task) != pdPASS) { s_control_task = NULL; vTaskDelete(s_task); 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_register_commands(void) { const esp_console_cmd_t command = { .command = "exit", .help = "Close the current administrative SSH session", .hint = NULL, .func = &command_exit, .argtable = NULL, }; return esp_console_cmd_register(&command); } 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, ¤t) != 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->history_position = -1; 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); bool wake_prompt = false; if (matched) { if (session->prompt_state == ADMIN_PROMPT_WAITING) { session->prompt_state = ADMIN_PROMPT_DISCONNECTED; wake_prompt = true; } session->active = false; if (!session->executing) { secure_wipe(session, sizeof(*session)); } } taskEXIT_CRITICAL(&s_lock); if (wake_prompt) { (void)xSemaphoreGive(s_prompt_done); } } 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 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) && 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)) { taskEXIT_CRITICAL(&s_lock); return *consumed != 0U; } uint8_t value = data[index]; if (session->command_pending) { if (!session->executing || session->prompt_state != ADMIN_PROMPT_WAITING) { taskEXIT_CRITICAL(&s_lock); return *consumed != 0U; } bool wake_prompt = false; if (session->discard_next_lf && value == '\n') { session->discard_next_lf = false; } else { session->discard_next_lf = false; if (value == '\r' || value == '\n') { session->discard_next_lf = value == '\r'; session->prompt_state = ADMIN_PROMPT_SUBMITTED; (void)append_output_locked(session, (const uint8_t *)"\r\n", 2U); wake_prompt = true; } else if (value == 0x03U) { secure_wipe(session->prompt_input, sizeof(session->prompt_input)); session->prompt_length = 0U; session->prompt_state = ADMIN_PROMPT_CANCELLED; (void)append_output_locked(session, (const uint8_t *)"^C\r\n", 4U); wake_prompt = true; } else if (value == 0x08U || value == 0x7fU) { if (session->prompt_length > 0U) { session->prompt_input[--session->prompt_length] = 0U; if (!session->prompt_hidden) { (void)append_output_locked(session, (const uint8_t *)"\b \b", 3U); } } } else if (value >= 0x20U && value <= 0x7eU) { if (session->prompt_length + 1U < session->prompt_capacity) { session->prompt_input[session->prompt_length++] = value; if (!session->prompt_hidden) { (void)append_output_locked(session, &value, 1U); } } else { (void)append_output_locked(session, (const uint8_t *)"\a", 1U); } } } ++*consumed; taskEXIT_CRITICAL(&s_lock); if (wake_prompt) { (void)xSemaphoreGive(s_prompt_done); } continue; } if (session->output_length >= ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - ADMIN_SSH_CONSOLE_RESPONSE_RESERVE) { taskEXIT_CRITICAL(&s_lock); return *consumed != 0U; } if (session->discard_next_lf && value == '\n') { session->discard_next_lf = false; ++*consumed; taskEXIT_CRITICAL(&s_lock); continue; } session->discard_next_lf = false; if (session->escape_state != 0U) { if (session->escape_state == 1U) { if (value == '[') { session->escape_state = 2U; session->escape_parameter_length = 0U; } else if (value == 'O') { session->escape_state = 3U; } else { session->escape_state = 0U; } } else if (session->escape_state == 3U) { editor_key_locked(session, value); session->escape_state = 0U; } else if (value >= 'A' && value <= 'Z') { editor_key_locked(session, value); session->escape_state = 0U; } else if (value == '~') { uint8_t key = 0U; if (session->escape_parameter_length > 0U) { switch (session->escape_parameters[0]) { case '1': case '7': key = 'H'; break; case '3': key = 'X'; break; case '4': case '8': key = 'F'; break; default: break; } } editor_key_locked(session, key); session->escape_state = 0U; } else if ((value == ';' || (value >= '0' && value <= '9')) && session->escape_parameter_length < sizeof(session->escape_parameters)) { session->escape_parameters[session->escape_parameter_length++] = value; } else { session->escape_state = 0U; } ++*consumed; taskEXIT_CRITICAL(&s_lock); continue; } if (value == 0x1bU) { session->escape_state = 1U; ++*consumed; taskEXIT_CRITICAL(&s_lock); continue; } if (value == '\t') { if (session->input_cursor != session->input_length) { (void)append_output_locked(session, (const uint8_t *)"\a", 1U); ++*consumed; taskEXIT_CRITICAL(&s_lock); continue; } char current[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; memcpy(current, session->input, sizeof(current)); ++*consumed; taskEXIT_CRITICAL(&s_lock); char completed[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U] = {0}; size_t candidates_length = 0U; bool expanded = console_completion_expand(current, completed, sizeof(completed)); bool candidates_formatted = !expanded && console_completion_format_matches(current, s_completion_output, sizeof(s_completion_output), &candidates_length); taskENTER_CRITICAL(&s_lock); session = &s_sessions[token->slot_index]; if (token_matches(session, token) && !session->command_pending && memcmp(current, session->input, sizeof(current)) == 0) { if (expanded) { strlcpy((char *)session->input, completed, sizeof(session->input)); session->input_length = strlen((const char *)session->input); session->input_cursor = session->input_length; session->history_position = -1; (void)redraw_line_locked(session); } else if (candidates_formatted && candidates_length > 0U && candidates_length + 2U + sizeof("admin@serial-tool> ") - 1U + session->input_length <= ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY - session->output_length) { (void)append_output_locked(session, (const uint8_t *)"\r\n", 2U); (void)append_output_locked(session, (const uint8_t *)s_completion_output, candidates_length); (void)redraw_line_locked(session); } else { (void)append_output_locked(session, (const uint8_t *)"\a", 1U); } } taskEXIT_CRITICAL(&s_lock); secure_wipe(current, sizeof(current)); secure_wipe(completed, sizeof(completed)); continue; } bool exit_requested = value == 0x04U && session->input_length == 0U; if (value == '\r' || value == '\n' || exit_requested) { session->discard_next_lf = value == '\r'; if (exit_requested) { memcpy(request.line, "exit", sizeof("exit")); } else { history_commit_locked(session); memcpy(request.line, session->input, session->input_length); } request.origin = ADMIN_REQUEST_SSH; request.token = *token; request.principal = session->principal; secure_wipe(session->input, sizeof(session->input)); session->input_length = 0U; session->input_cursor = 0U; session->history_position = -1; session->command_pending = true; (void)append_output_locked(session, (const uint8_t *)(exit_requested ? "^D\r\n" : "\r\n"), exit_requested ? sizeof("^D\r\n") - 1U : sizeof("\r\n") - 1U); submit = true; } else if (value == 0x03U) { secure_wipe(session->input, sizeof(session->input)); session->input_length = 0U; session->input_cursor = 0U; session->history_position = -1; (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_cursor > 0U) { memmove(session->input + session->input_cursor - 1U, session->input + session->input_cursor, session->input_length - session->input_cursor + 1U); --session->input_cursor; --session->input_length; session->history_position = -1; (void)redraw_line_locked(session); } } else if (value >= 0x20U && value <= 0x7eU) { if (session->input_length >= ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY) { session->input_length = 0U; session->input_cursor = 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 { memmove(session->input + session->input_cursor + 1U, session->input + session->input_cursor, session->input_length - session->input_cursor + 1U); session->input[session->input_cursor++] = value; ++session->input_length; session->history_position = -1; if (session->input_cursor == session->input_length) { (void)append_output_locked(session, &value, 1U); } else { (void)redraw_line_locked(session); } } } ++*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; }