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:
+139
-39
@@ -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, ¤t);
|
||||
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(©, 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;
|
||||
|
||||
Reference in New Issue
Block a user