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
+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;