Extend browser admin lifecycle actions

Support browser reboot and HTTPS stop through deferred control, plus
exact
`web certificate rotate --force` handoff to the dispatcher. Add typed
request
validation and focused boundary and lifecycle coverage.
This commit is contained in:
2026-09-07 09:36:38 +02:00
parent 17520b15b7
commit 326119812f
23 changed files with 699 additions and 47 deletions
+82 -14
View File
@@ -70,9 +70,17 @@ typedef struct {
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_origin_t;
typedef struct {
@@ -80,16 +88,12 @@ typedef struct {
admin_ssh_console_token_t token;
user_principal_t principal;
TaskHandle_t completion_task;
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
union {
uint8_t line[ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY + 1U];
admin_control_request_t deferred;
};
} admin_request_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;
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. */
@@ -122,6 +126,12 @@ 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;
@@ -401,7 +411,7 @@ esp_err_t admin_ssh_console_dispatch_defer(
bool valid = token_matches(session, &s_dispatch_token) &&
!session->deferred_action_pending;
const admin_console_owner_t *owner = valid ? session->owner : NULL;
if (valid && ((unsigned)action > ADMIN_CONSOLE_DEFER_SELF_CLOSE ||
if (valid && ((unsigned)action > ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE ||
!(owner->supported_actions & (1U << action)))) {
taskEXIT_CRITICAL(&s_lock);
return ESP_ERR_NOT_SUPPORTED;
@@ -496,8 +506,12 @@ static bool remote_command_allowed(const admin_request_t *request)
* 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 || strcmp(argv[0], "wifi") == 0 ||
strcmp(argv[0], "mdns") == 0) {
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 = argc == 1U ||
@@ -505,9 +519,9 @@ static bool remote_command_allowed(const admin_request_t *request)
strcmp(argv[1], "list") == 0)) ||
(argc == 3U && strcmp(argv[1], "show") == 0);
} else if (strcmp(argv[0], "reboot") == 0) {
allowed = false;
allowed = argc == 1U;
} else if (strcmp(argv[0], "ssh") == 0 && argc >= 2U) {
/* These handlers defer for every remote; WEB supports SELF_CLOSE only. */
/* 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 &&
@@ -598,6 +612,8 @@ static void dispatch_registered_command(admin_request_t *request)
}
}
static void dispatch_deferred_request(admin_request_t *request);
static void worker_task(void *context)
{
(void)context;
@@ -606,6 +622,11 @@ static void worker_task(void *context)
if (xQueueReceive(s_request_queue, &request, portMAX_DELAY) != pdTRUE) {
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) {
@@ -683,6 +704,34 @@ static void finish_deferred_request(const admin_control_request_t *request,
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;
@@ -718,8 +767,26 @@ static void control_task(void *context)
}
vTaskDelay(pdMS_TO_TICKS(200U));
taskENTER_CRITICAL(&s_lock);
bool current = token_matches(&s_sessions[request.token.slot_index], &request.token);
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);
@@ -1266,6 +1333,7 @@ esp_err_t admin_ssh_console_get_session_snapshot(
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);
+14 -2
View File
@@ -35,6 +35,8 @@ typedef enum {
ADMIN_SSH_DEFER_HOST_KEY_ROTATE,
ADMIN_SSH_DEFER_HOST_KEY_RESET,
ADMIN_CONSOLE_DEFER_SELF_CLOSE,
ADMIN_CONSOLE_DEFER_WEB_STOP,
ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
} admin_ssh_deferred_action_type_t;
/* Small owner boundary; module/API names are retained for existing SSH callers.
@@ -44,7 +46,10 @@ typedef enum {
* An occupied or still-executing slot cannot be replaced by open_owned().
*
* The immutable adapter lives for firmware lifetime. Callbacks run on the
* control task OUTSIDE console locks for drained/perform. Required is_current
* control task OUTSIDE console locks for drained/perform, except perform actions
* selected by dispatcher_actions run serialized on the existing 12KiB dispatcher
* after drain/delay and queued identity/principal revalidation (no command replay).
* Zero dispatcher_actions preserves legacy control-task execution. Required is_current
* runs on the dispatcher outside console locks; it must be bounded and validate
* full transport identity, originating-session liveness and principal binding,
* without calling socket libraries or handlers. Core separately checks accounts.
@@ -53,7 +58,9 @@ typedef enum {
* its owner, never call socket libraries here. Neither callback may call console
* handlers. supported_actions is a bitmask (1U << action); reject unsupported
* actions before side effects. Legacy STOP/DISCONNECT/key actions mean SSH;
* SELF_CLOSE means this frontend, with argument ignored.
* SELF_CLOSE means this frontend; WEB_STOP means HTTPS, not SSH.
* WEB_CERTIFICATE_ROTATE replaces the HTTPS identity and restarts HTTPS.
* These WEB actions and SELF_CLOSE ignore argument.
*
* One owner serializes feed calls per session; different owners may feed in
* parallel. Shared completion scratch is nonblocking/serialized by the core.
@@ -66,10 +73,13 @@ typedef enum {
* Close wakes prompts; executing state is retained until the handler returns.
* Output remains bounded (5s write backpressure); deferred work waits at most
* 10s for application drain plus 200ms, NOT peer-delivery confirmation.
* Dispatcher actions then wait behind queued commands/prompts, with input gated
* until completion or cancellation; the drain bound is not an execution deadline.
* No new tasks, queues, slots, or browser endpoint are provided by this API.
*/
typedef struct {
uint32_t supported_actions;
uint32_t dispatcher_actions; /* Subset of supported_actions; immutable. */
bool (*is_current)(const admin_ssh_console_token_t *token,
const user_principal_t *principal);
bool (*drained)(const admin_ssh_console_token_t *token);
@@ -95,6 +105,7 @@ typedef struct {
bool command_pending;
bool input_pending;
bool output_pending;
bool deferred_action_pending;
size_t input_length;
size_t output_length;
} admin_ssh_console_session_snapshot_t;
@@ -108,6 +119,7 @@ esp_err_t admin_ssh_console_start_uart_frontend(void);
/* Valid only while a registered command callback runs on the dispatcher task. */
bool admin_ssh_console_dispatch_is_remote(void);
bool admin_ssh_console_dispatch_is_web(void);
const user_principal_t *admin_ssh_console_dispatch_principal(void);
esp_err_t admin_ssh_console_dispatch_read_input(
const char *prompt, uint8_t *output, size_t capacity,
+1 -1
View File
@@ -54,7 +54,7 @@ static int command_reboot(int argc, char **argv)
printf("Could not schedule reboot: %s\n", esp_err_to_name(error));
return 1;
}
printf("Reboot scheduled after SSH output drains; unsaved changes will be lost.\n");
printf("Reboot scheduled after console output drains; unsaved changes will be lost.\n");
return 0;
}
printf("Rebooting now; unsaved RAM-only configuration changes will be lost.\n");
+66 -6
View File
@@ -8,6 +8,9 @@
#include "admin_ssh_console.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"
#include "esp_system.h"
#include "web_server.h"
#include "web_security.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "secure_random.h"
@@ -91,9 +94,34 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
admin_ssh_deferred_action_type_t action, uint32_t argument)
{
(void)argument;
if (action != ADMIN_CONSOLE_DEFER_SELF_CLOSE) return ESP_ERR_NOT_SUPPORTED;
if (action != ADMIN_CONSOLE_DEFER_SELF_CLOSE && action != ADMIN_SSH_DEFER_REBOOT &&
action != ADMIN_CONSOLE_DEFER_WEB_STOP &&
action != ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE) return ESP_ERR_NOT_SUPPORTED;
/* Drain is only a delivery heuristic, not an authorization lease. The
* execution task must recheck cookie/account binding after delay/queueing. */
taskENTER_CRITICAL(&s_lock);
bool valid = token_matches(token) && s_slot.active && s_accepting;
user_principal_t principal = s_slot.principal;
taskEXIT_CRITICAL(&s_lock);
bool current = owner_current(token, &principal);
secure_wipe(&principal, sizeof(principal));
if (!current) return ESP_ERR_NOT_FOUND;
if (action == ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE) {
/* The owner mask routes this crypto/NVS path to the 12KiB dispatcher.
* Commit before restart; a failed stop retains HTTPD ownership and must
* not be followed by start. No socket IO or console handler calls here. */
esp_err_t error = web_security_rotate_certificate();
if (error != ESP_OK) return error;
error = web_server_stop();
if (error != ESP_OK) return error;
return web_server_start();
}
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
if (action == ADMIN_SSH_DEFER_REBOOT) {
esp_restart();
return ESP_OK;
}
taskENTER_CRITICAL(&s_lock);
bool valid = token_matches(token) && s_slot.active && s_accepting && !s_slot.close_requested;
if (valid) s_slot.close_requested = true;
taskEXIT_CRITICAL(&s_lock);
if (valid) admin_ssh_console_close(token);
@@ -101,7 +129,10 @@ static esp_err_t owner_perform(const admin_ssh_console_token_t *token,
}
static const admin_console_owner_t s_owner = {
.supported_actions = 1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE,
.supported_actions = (1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE) |
(1U << ADMIN_SSH_DEFER_REBOOT) | (1U << ADMIN_CONSOLE_DEFER_WEB_STOP) |
(1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE),
.dispatcher_actions = 1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
.is_current = owner_current, .drained = owner_drained, .perform = owner_perform,
};
@@ -152,11 +183,29 @@ static bool input_current(const admin_ssh_console_token_t *token,
return false;
}
static void discard_pending_input(void)
{
secure_wipe(s_payload->rx, sizeof(s_payload->rx));
s_payload->rx_offset = s_payload->rx_length = 0;
s_payload->input_deadline = 0;
count(&s_counts.input_backpressure, 1);
}
static bool feed_pending(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
if (s_payload->rx_offset == s_payload->rx_length) return true;
if (!input_current(token, principal)) return false;
admin_ssh_console_session_snapshot_t console;
if (admin_ssh_console_get_session_snapshot(token, &console) != ESP_OK || !console.active) {
request_close();
return false;
}
if (console.deferred_action_pending) {
/* Never replay buffered keystrokes if a deferred action fails/cancels. */
discard_pending_input();
return true;
}
if (esp_timer_get_time() >= s_payload->input_deadline) {
count(&s_counts.input_backpressure, 1);
request_close();
@@ -425,9 +474,19 @@ static esp_err_t frame_handler(httpd_req_t *request)
count(&s_counts.protocol_errors, 1);
goto failure;
}
if (s_payload->rx_length != s_payload->rx_offset) {
count(&s_counts.input_backpressure, 1);
admin_ssh_console_session_snapshot_t console;
if (admin_ssh_console_get_session_snapshot(&token, &console) != ESP_OK || !console.active)
goto failure;
/* Latch before the potentially blocking receive: cancellation during receive
* must not turn input observed during deferral into a new command. */
bool discard_frame = console.deferred_action_pending;
if (s_payload->rx_length != s_payload->rx_offset) {
if (!discard_frame) {
count(&s_counts.input_backpressure, 1);
goto failure;
}
/* Deferral may start before the next poll discards buffered trailing input. */
discard_pending_input();
}
frame.payload = s_payload->rx;
/* IDF treats len==0 as another header probe, not an empty payload read. */
@@ -436,7 +495,8 @@ static esp_err_t frame_handler(httpd_req_t *request)
s_payload->rx_length = frame.len;
s_payload->rx_offset = 0;
s_payload->input_deadline = esp_timer_get_time() + ADMIN_INPUT_TIMEOUT_US;
if (!feed_pending(&token, &principal)) goto failure;
if (discard_frame) discard_pending_input();
else if (!feed_pending(&token, &principal)) goto failure;
count(&s_counts.rx_bytes, (uint32_t)frame.len);
secure_wipe(&principal, sizeof(principal));
return ESP_OK;
+20
View File
@@ -2,6 +2,7 @@
/* UART0 HTTPS lifecycle, legacy recovery credential, and certificate commands. */
#include "web_console.h"
#include "admin_ssh_console.h"
#include <inttypes.h>
#include <stdio.h>
@@ -394,6 +395,15 @@ static int command_web(int argc, char **argv)
return 0;
}
if (argc == 2 && strcmp(argv[1], "stop") == 0) {
if (admin_ssh_console_dispatch_is_web()) {
esp_err_t error = admin_ssh_console_dispatch_defer(ADMIN_CONSOLE_DEFER_WEB_STOP, 0U);
if (error != ESP_OK) {
printf("Could not schedule HTTPS stop: %s\n", esp_err_to_name(error));
return 1;
}
printf("HTTPS stop scheduled after console output drains; both browser connections will close.\n");
return 0;
}
esp_err_t error = web_server_stop();
if (error != ESP_OK) {
printf("Could not stop HTTPS: %s\n", esp_err_to_name(error));
@@ -439,6 +449,16 @@ static int command_web(int argc, char **argv)
printf("Certificate rotation requires: web certificate rotate --force\n");
return 1;
}
if (admin_ssh_console_dispatch_is_web()) {
esp_err_t error = admin_ssh_console_dispatch_defer(
ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE, 0U);
if (error != ESP_OK) {
printf("Could not schedule HTTPS certificate rotation: %s\n", esp_err_to_name(error));
return 1;
}
printf("HTTPS certificate rotation scheduled after console output drains; both browser connections will close. Reconnect and verify the new certificate. If restart fails, use UART0 or SSH recovery.\n");
return 0;
}
return rotate_certificate();
}
if (strcmp(argv[1], "reset") == 0) {