feat: add bounded admin WebSocket backend (Phase 8D.5)

- Require current admin cookie sessions, Origin checks and single-use
  tickets
- Reuse the shared console with session-aware authorization and slot
  allocation
- Add HTTPD-owned I/O, bounded buffering and revocation cleanup
- Prevent LRU eviction of serial clients and stale admin socket closure
- Reject unsupported web-shell mutations before side effects
- Add host regressions, a smoke client and resource accounting

Validated by user sign-off after a 15-minute full-client soak at 230400
baud, with a few broker drops under heavy output. Browser UI remains
for Phase 8D.6; numeric memory reserves remain open.
This commit is contained in:
2026-09-06 14:41:41 +02:00
parent e5dce12ed4
commit aeb2043396
37 changed files with 3651 additions and 91 deletions
+139 -39
View File
@@ -148,6 +148,31 @@ static bool token_matches(const admin_session_t *session,
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;
}
static bool append_output_locked(admin_session_t *session,
const uint8_t *data, size_t length)
{
@@ -308,6 +333,9 @@ esp_err_t admin_ssh_console_dispatch_read_input(
*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];
@@ -330,27 +358,36 @@ esp_err_t admin_ssh_console_dispatch_read_input(
if (!published) {
return ESP_ERR_NO_MEM;
}
if (xSemaphoreTake(s_prompt_done, portMAX_DELAY) != pdTRUE) {
return ESP_FAIL;
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 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(
@@ -454,6 +491,31 @@ static bool remote_command_allowed(const admin_request_t *request)
(strcmp(argv[1], "bootstrap") == 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 || 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 ||
(argc == 2U && (strcmp(argv[1], "status") == 0 ||
strcmp(argv[1], "list") == 0)) ||
(argc == 3U && strcmp(argv[1], "show") == 0);
} else if (strcmp(argv[0], "reboot") == 0) {
allowed = false;
} else if (strcmp(argv[0], "ssh") == 0 && argc >= 2U) {
/* These handlers defer for every remote; WEB supports SELF_CLOSE only. */
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;
}
@@ -518,8 +580,11 @@ static void dispatch_registered_command(admin_request_t *request)
}
int command_result = 0;
esp_err_t error = esp_console_run((const char *)request->line, &command_result);
report_command_result(error, command_result);
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;
@@ -550,35 +615,33 @@ static void worker_task(void *context)
continue;
}
bool current = false;
esp_err_t auth_error = user_database_principal_is_current(&request.principal, &current);
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 = token_matches(session, &request.token) && session->command_pending &&
active = current && 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);
bool authorized = active && 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");
request.token.transport == ADMIN_CONSOLE_TRANSPORT_WEB
? "Command is unavailable from the web console; use UART0 or SSH where permitted. Bootstrap/recovery require 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 = auth_error == ESP_OK && current &&
prompt = current &&
request.principal.role == USER_ROLE_ADMIN &&
!session->deferred_action_pending;
} else if (!session->active && session->executing &&
@@ -770,12 +833,14 @@ esp_err_t admin_ssh_console_start_uart_frontend(void)
return ESP_OK;
}
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)
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_valid(token) || principal == NULL || principal->role != USER_ROLE_ADMIN ||
owner == NULL || owner->drained == NULL || owner->perform == NULL) {
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);
@@ -789,7 +854,19 @@ esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
return ESP_ERR_INVALID_STATE;
}
taskENTER_CRITICAL(&s_lock);
admin_session_t *session = &s_sessions[token->slot_index];
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;
@@ -797,6 +874,7 @@ esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
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;
@@ -811,6 +889,24 @@ esp_err_t admin_ssh_console_open_owned(const admin_ssh_console_token_t *token,
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(&copy, principal, owner, false);
}
void admin_ssh_console_close(const admin_ssh_console_token_t *token)
{
if (!token_valid(token)) {
@@ -821,8 +917,10 @@ void admin_ssh_console_close(const admin_ssh_console_token_t *token)
bool matched = token_matches(session, token);
bool wake_prompt = false;
if (matched) {
if (session->prompt_state == ADMIN_PROMPT_WAITING) {
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;
@@ -1137,8 +1235,10 @@ esp_err_t admin_ssh_console_read_output(const admin_ssh_console_token_t *token,
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;