/* 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 "freertos/FreeRTOS.h" #include "freertos/queue.h" #include "freertos/semphr.h" #include "freertos/task.h" #include "linenoise/linenoise.h" #include "secure_random.h" #include "user_database.h" #include "web_serial_settings.h" #include "web_account_settings.h" #include "web_network_settings.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; const admin_console_owner_t *owner; 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 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; typedef enum { ADMIN_REQUEST_SSH = 0, ADMIN_REQUEST_UART0, ADMIN_REQUEST_DEFERRED, ADMIN_REQUEST_SERIAL_SETTINGS, ADMIN_REQUEST_ACCOUNT_SETTINGS, ADMIN_REQUEST_NETWORK_SETTINGS, } 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; union { uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U]; admin_control_request_t deferred; uint32_t serial_settings_id; uint32_t account_settings_id; uint32_t network_settings_id; }; } admin_request_t; static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED; static admin_session_t s_sessions[ADMIN_SSH_CONSOLE_MAX_SESSIONS]; /* 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; 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; bool admin_ssh_console_dispatch_is_remote(void) { return xTaskGetCurrentTaskHandle() == s_task && s_dispatch_remote; } bool admin_ssh_console_dispatch_is_web(void) { return admin_ssh_console_dispatch_is_remote() && s_dispatch_token.transport == ADMIN_CONSOLE_TRANSPORT_WEB; } 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.transport == token->transport && session->token.slot_index == token->slot_index && 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 session_is_current(const admin_ssh_console_token_t *token, const user_principal_t *principal) { taskENTER_CRITICAL(&s_lock); admin_session_t *session = &s_sessions[token->slot_index]; const admin_console_owner_t *owner = token_matches(session, token) ? session->owner : NULL; taskEXIT_CRITICAL(&s_lock); if (owner == NULL) { return false; } bool account_current = false; bool current = principal->role == USER_ROLE_ADMIN && user_database_principal_is_current(principal, &account_current) == ESP_OK && account_current && owner->is_current(token, principal); /* External checks may close/reuse a slot. Never act on its replacement. */ taskENTER_CRITICAL(&s_lock); bool matched = token_matches(session, token) && session->owner == owner; taskEXIT_CRITICAL(&s_lock); if (matched && !current) { admin_ssh_console_close(token); } return matched && current; } bool admin_ssh_console_dispatch_is_current(void) { return xTaskGetCurrentTaskHandle() == s_task && (!s_dispatch_remote || session_is_current(&s_dispatch_token, &s_dispatch_principal)); } 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); if (!session_is_current(&s_dispatch_token, &s_dispatch_principal)) { return ESP_ERR_NOT_FOUND; } 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; } for (;;) { /* The semaphore is only a hint: delayed/stale wakes cannot submit input. */ (void)xSemaphoreTake(s_prompt_done, pdMS_TO_TICKS(250U)); bool current = session_is_current(&s_dispatch_token, &s_dispatch_principal); esp_err_t result = ESP_ERR_INVALID_STATE; taskENTER_CRITICAL(&s_lock); session = &s_sessions[s_dispatch_token.slot_index]; if (!token_identity_matches(session, &s_dispatch_token)) { taskEXIT_CRITICAL(&s_lock); return ESP_ERR_NOT_FOUND; } if (current && session->active && session->prompt_state == ADMIN_PROMPT_WAITING) { taskEXIT_CRITICAL(&s_lock); continue; } if (!current || !session->active || session->prompt_state == ADMIN_PROMPT_DISCONNECTED) { result = ESP_ERR_NOT_FOUND; } else if (session->prompt_state == ADMIN_PROMPT_SUBMITTED) { memcpy(output, session->prompt_input, session->prompt_length); *output_length = session->prompt_length; result = ESP_OK; } 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; const admin_console_owner_t *owner = valid ? session->owner : NULL; if (valid && ((unsigned)action > ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE || !(owner->supported_actions & (1U << action)))) { taskEXIT_CRITICAL(&s_lock); return ESP_ERR_NOT_SUPPORTED; } 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, .owner = owner, .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; } bool admin_ssh_console_web_user_command_allowed( size_t argc, char **argv, const user_principal_t *principal) { if (argc == 0U || strcmp(argv[0], "user") != 0) return false; if (argc == 1U || (argc == 2U && (strcmp(argv[1], "status") == 0 || strcmp(argv[1], "list") == 0)) || (argc == 3U && strcmp(argv[1], "show") == 0)) return true; bool role_valid = argc >= 4U && (strcmp(argv[3], "user") == 0 || strcmp(argv[3], "admin") == 0); bool mutation = (argc == 4U && strcmp(argv[1], "add") == 0 && role_valid) || (argc == 3U && strcmp(argv[1], "password") == 0) || (argc == 4U && strcmp(argv[1], "delete") == 0 && strcmp(argv[3], "--force") == 0) || (argc == 5U && strcmp(argv[1], "role") == 0 && role_valid && strcmp(argv[4], "--force") == 0); /* Parsed names use the database's exact, case-sensitive identity. Reject * self even for no-op role changes; their handler still requests revocation. * Generation/output and key workflows remain outside this bounded slice. */ return mutation && principal != NULL && principal->role == USER_ROLE_ADMIN && principal->username_length > 0U && principal->username_length <= USER_DATABASE_USERNAME_CAPACITY && !(strlen(argv[2]) == principal->username_length && memcmp(argv[2], principal->username, principal->username_length) == 0); } 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); /* Empty input is handled quietly by esp_console_run(), not UART0 policy. */ bool allowed = true; if (argc >= 2U && strcmp(argv[0], "user") == 0 && strcmp(argv[1], "recover") == 0) { allowed = false; } /* Temporary browser policy until lifecycle acknowledgements/revocation are * coordinated (8D.7). Classify parsed canonical arguments, not raw prefixes. * User mutations remain available through UART0/SSH, subject to their policy. */ if (request->token.transport == ADMIN_CONSOLE_TRANSPORT_WEB && argc > 0U) { if (strcmp(argv[0], "web") == 0) { allowed = (argc == 2U && (strcmp(argv[1], "status") == 0 || strcmp(argv[1], "stop") == 0)) || (argc == 4U && strcmp(argv[1], "certificate") == 0 && strcmp(argv[2], "rotate") == 0 && strcmp(argv[3], "--force") == 0); } else if (strcmp(argv[0], "wifi") == 0 || strcmp(argv[0], "mdns") == 0) { allowed = argc == 2U && strcmp(argv[1], "status") == 0; } else if (strcmp(argv[0], "user") == 0) { allowed = admin_ssh_console_web_user_command_allowed( argc, argv, &request->principal); } else if (strcmp(argv[0], "reboot") == 0) { allowed = argc == 1U; } else if (strcmp(argv[0], "ssh") == 0 && argc >= 2U) { /* SSH-specific deferred actions are not yet supported by WEB. */ if (strcmp(argv[1], "stop") == 0 || strcmp(argv[1], "disconnect") == 0 || strcmp(argv[1], "reset") == 0 || (strcmp(argv[1], "host-key") == 0 && !(argc == 3U && strcmp(argv[2], "info") == 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_CONSOLE_DEFER_SELF_CLOSE, s_dispatch_token.session_id); if (error != ESP_OK) { printf("Could not schedule %s session close: %s\n", s_dispatch_token.transport == 0U ? "SSH" : "remote", esp_err_to_name(error)); return 1; } printf("%s session close scheduled after output drains.\n", s_dispatch_token.transport == 0U ? "SSH" : "Remote"); 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; if (request->origin == ADMIN_REQUEST_UART0 || session_is_current(&request->token, &request->principal)) { 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 dispatch_deferred_request(admin_request_t *request); esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id) { taskENTER_CRITICAL(&s_lock); bool ready = s_dispatch_ready; taskEXIT_CRITICAL(&s_lock); if (!ready || !id) return ESP_ERR_INVALID_STATE; admin_request_t request = {.origin = ADMIN_REQUEST_SERIAL_SETTINGS, .serial_settings_id = id}; return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; } esp_err_t admin_ssh_console_submit_account_settings(uint32_t id) { taskENTER_CRITICAL(&s_lock); bool ready = s_dispatch_ready; taskEXIT_CRITICAL(&s_lock); if (!ready || !id) return ESP_ERR_INVALID_STATE; admin_request_t request = {.origin = ADMIN_REQUEST_ACCOUNT_SETTINGS, .account_settings_id = id}; return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; } esp_err_t admin_ssh_console_submit_network_settings(uint32_t id) { taskENTER_CRITICAL(&s_lock); bool ready = s_dispatch_ready; taskEXIT_CRITICAL(&s_lock); if (!ready || !id) return ESP_ERR_INVALID_STATE; admin_request_t request = {.origin = ADMIN_REQUEST_NETWORK_SETTINGS, .network_settings_id = id}; return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT; } 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_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS || request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) { if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id); else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id); else web_network_settings_execute(request.network_settings_id); secure_wipe(&request, sizeof(request)); continue; } if (request.origin == ADMIN_REQUEST_DEFERRED) { dispatch_deferred_request(&request); secure_wipe(&request, sizeof(request)); 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 = session_is_current(&request.token, &request.principal); bool active; taskENTER_CRITICAL(&s_lock); admin_session_t *session = &s_sessions[request.token.slot_index]; active = current && token_matches(session, &request.token) && session->command_pending && !session->executing; if (active) { session->executing = true; } taskEXIT_CRITICAL(&s_lock); bool authorized = active && remote_command_allowed(&request); if (authorized) { dispatch_registered_command(&request); } else if (active) { (void)worker_write(&request.token, request.token.transport == ADMIN_CONSOLE_TRANSPORT_WEB ? "Command is unavailable from the web console; use UART0 or SSH where permitted. Recovery requires UART0.\r\n" : "Command is restricted to physical UART0.\r\n"); } current = session_is_current(&request.token, &request.principal); 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 = 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]; const char *transport = request->token.transport == 0U ? "SSH" : "remote"; if (cancelled) { snprintf(message, sizeof(message), "Deferred action cancelled before %s output drained.\r\nadmin@serial-tool> ", transport); } else if (result == ESP_OK) { snprintf(message, sizeof(message), "Deferred %s action completed.\r\nadmin@serial-tool> ", transport); } else { snprintf(message, sizeof(message), "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]; 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 dispatch_deferred_request(admin_request_t *request) { const admin_control_request_t *action = &request->deferred; bool current = session_is_current(&action->token, &request->principal); taskENTER_CRITICAL(&s_lock); admin_session_t *session = &s_sessions[action->token.slot_index]; bool active = current && token_matches(session, &action->token) && session->owner == action->owner && session->deferred_action_pending && !session->command_pending && !session->executing; if (active) session->executing = true; taskEXIT_CRITICAL(&s_lock); /* Keep the slot reserved across lifecycle callbacks, including self-detach. * Recheck after reservation just as the canonical runner does. */ esp_err_t result = ESP_ERR_NOT_FOUND; if (active && session_is_current(&action->token, &request->principal)) { result = action->owner->perform(&action->token, action->action, action->argument); } taskENTER_CRITICAL(&s_lock); session = &s_sessions[action->token.slot_index]; if (active && token_identity_matches(session, &action->token)) { if (session->active) session->executing = false; else secure_wipe(session, sizeof(*session)); } taskEXIT_CRITICAL(&s_lock); finish_deferred_request(action, result, false); } 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 = console_drained && request.owner->drained(&request.token); 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)); taskENTER_CRITICAL(&s_lock); admin_session_t *session = &s_sessions[request.token.slot_index]; bool current = token_matches(session, &request.token) && session->owner == request.owner && session->deferred_action_pending; admin_request_t queued = { .origin = ADMIN_REQUEST_DEFERRED, .deferred = request, }; if (current) queued.principal = session->principal; taskEXIT_CRITICAL(&s_lock); if (current && (request.owner->dispatcher_actions & (1U << request.action))) { /* Nonblocking handoff: a full dispatcher queue fails before mutation. * Pending remains set until execution completes, not merely enqueue. */ if (xQueueSend(s_request_queue, &queued, 0U) != pdTRUE) { finish_deferred_request(&request, ESP_ERR_TIMEOUT, false); } secure_wipe(&queued, sizeof(queued)); secure_wipe(&request, sizeof(request)); continue; } secure_wipe(&queued, sizeof(queued)); 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)); } } 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 remote 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; } static esp_err_t open_session(admin_ssh_console_token_t *token, const user_principal_t *principal, const admin_console_owner_t *owner, bool available) { if (token == NULL || token->session_id == 0U || token->slot_generation == 0U || (!available && !token_valid(token)) || principal == NULL || principal->role != USER_ROLE_ADMIN || owner == NULL || owner->is_current == NULL || owner->drained == NULL || owner->perform == NULL) { 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); size_t index = token->slot_index; if (available) { for (index = 0U; index < ADMIN_SSH_CONSOLE_MAX_SESSIONS; ++index) { if (!s_sessions[index].active && !s_sessions[index].executing) { break; } } if (index == ADMIN_SSH_CONSOLE_MAX_SESSIONS) { taskEXIT_CRITICAL(&s_lock); return ESP_ERR_INVALID_STATE; } } admin_session_t *session = &s_sessions[index]; if (session->active || session->executing) { taskEXIT_CRITICAL(&s_lock); return ESP_ERR_INVALID_STATE; } secure_wipe(session, sizeof(*session)); session->active = true; session->history_position = -1; token->slot_index = (uint8_t)index; session->token = *token; session->owner = owner; session->principal = *principal; 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, strlen(banner)); (void)append_output_locked(session, (const uint8_t *)prompt, sizeof(prompt) - 1U); taskEXIT_CRITICAL(&s_lock); return ESP_OK; } esp_err_t admin_ssh_console_open_available(admin_ssh_console_token_t *token, const user_principal_t *principal, const admin_console_owner_t *owner) { return open_session(token, principal, owner, true); } 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 == NULL) { return ESP_ERR_INVALID_ARG; } admin_ssh_console_token_t copy = *token; return open_session(©, principal, owner, false); } 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_NONE) { session->prompt_state = ADMIN_PROMPT_DISCONNECTED; secure_wipe(session->prompt_input, sizeof(session->prompt_input)); session->prompt_length = 0U; 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) && !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); 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->deferred_action_pending) { 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 (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; taskEXIT_CRITICAL(&s_lock); continue; } 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}; 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); } } 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)); 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); secure_wipe(session->output + session->output_start, first); if (copied > first) { memcpy(data + first, session->output, copied - first); secure_wipe(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->deferred_action_pending = session->deferred_action_pending; snapshot->input_length = session->input_length; snapshot->output_length = session->output_length; taskEXIT_CRITICAL(&s_lock); return ESP_OK; }