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
+86 -18
View File
@@ -66,6 +66,7 @@ typedef struct {
bool pending_principal_valid;
bool authenticated;
bool shell_requested;
uint8_t console_slot_index;
uint8_t authentication_attempts;
word32 io_read_budget;
bool writer;
@@ -87,6 +88,9 @@ static ssh_slot_t s_slots[SSH_TRANSPORT_MAX_SESSIONS];
static ssh_transport_session_snapshot_t
s_session_snapshots[SSH_TRANSPORT_MAX_SESSIONS];
static uint32_t s_external_close_id[SSH_TRANSPORT_MAX_SESSIONS];
/* Published with snapshots; dispatcher never reads owner-task slot storage. */
static user_principal_t s_console_principals[SSH_TRANSPORT_MAX_SESSIONS];
static uint8_t s_console_slot_indices[SSH_TRANSPORT_MAX_SESSIONS];
static ssh_transport_counters_t s_counters;
static SemaphoreHandle_t s_command_mutex;
static bool s_initializing;
@@ -132,8 +136,9 @@ static void notify_task(void)
static admin_ssh_console_token_t admin_console_token(const ssh_slot_t *slot,
size_t slot_index)
{
(void)slot_index; /* Physical SSH index is not the shared console index. */
return (admin_ssh_console_token_t){
.slot_index = (uint8_t)slot_index,
.slot_index = slot->console_slot_index,
.session_id = slot->session_id,
.slot_generation = slot->generation,
};
@@ -177,19 +182,74 @@ static void publish_slot(const ssh_slot_t *slot, size_t slot_index)
taskENTER_CRITICAL(&s_lock);
s_session_snapshots[slot_index] = snapshot;
s_console_slot_indices[slot_index] =
snapshot.active && slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE
? slot->console_slot_index : UINT8_MAX;
if (slot->principal_valid) {
s_console_principals[slot_index] = slot->principal;
} else {
secure_wipe(&s_console_principals[slot_index], sizeof(user_principal_t));
}
taskEXIT_CRITICAL(&s_lock);
}
/* Control-task adapter: copied snapshots only, no runtime wolfSSH calls. */
static bool admin_console_drained(const admin_ssh_console_token_t *token)
/* Caller holds s_lock. Match session identity first, then the assigned console
* binding; callbacks must never index physical SSH storage by console slot.
*/
static size_t admin_console_snapshot_index_locked(const admin_ssh_console_token_t *token)
{
if (token->transport != 0U || token->slot_index >= SSH_TRANSPORT_MAX_SESSIONS) {
if (token == NULL || token->transport != ADMIN_CONSOLE_TRANSPORT_SSH ||
token->session_id == 0U || token->slot_generation == 0U) {
return SSH_TRANSPORT_MAX_SESSIONS;
}
for (size_t i = 0U; i < SSH_TRANSPORT_MAX_SESSIONS; ++i) {
const ssh_transport_session_snapshot_t *slot = &s_session_snapshots[i];
if (slot->active && slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE &&
slot->session_id == token->session_id &&
slot->generation == token->slot_generation &&
s_console_slot_indices[i] != UINT8_MAX &&
s_console_slot_indices[i] == token->slot_index) {
return i;
}
}
return SSH_TRANSPORT_MAX_SESSIONS;
}
/* Dispatcher/control adapters: published state only, no runtime wolfSSH calls. */
static bool admin_console_is_current(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
if (principal == NULL) {
return false;
}
taskENTER_CRITICAL(&s_lock);
const ssh_transport_session_snapshot_t *slot = &s_session_snapshots[token->slot_index];
bool drained = slot->active && slot->session_id == token->session_id &&
slot->generation == token->slot_generation && !slot->tx_pending;
size_t index = admin_console_snapshot_index_locked(token);
if (index == SSH_TRANSPORT_MAX_SESSIONS) {
taskEXIT_CRITICAL(&s_lock);
return false;
}
const ssh_transport_session_snapshot_t *slot = &s_session_snapshots[index];
const user_principal_t *bound = &s_console_principals[index];
bool current = slot->active && slot->authenticated && slot->principal_valid &&
slot->state == SSH_TRANSPORT_SESSION_ACTIVE &&
slot->route == SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE && !slot->close_requested &&
s_external_close_id[index] != token->session_id &&
slot->session_id == token->session_id && slot->generation == token->slot_generation &&
bound->user_id == principal->user_id && bound->auth_generation == principal->auth_generation &&
bound->role == USER_ROLE_ADMIN && bound->role == principal->role &&
bound->method == principal->method && bound->username_length == principal->username_length &&
bound->username_length <= USER_DATABASE_USERNAME_CAPACITY &&
memcmp(bound->username, principal->username, bound->username_length) == 0;
taskEXIT_CRITICAL(&s_lock);
return current;
}
static bool admin_console_drained(const admin_ssh_console_token_t *token)
{
taskENTER_CRITICAL(&s_lock);
size_t index = admin_console_snapshot_index_locked(token);
bool drained = index < SSH_TRANSPORT_MAX_SESSIONS &&
!s_session_snapshots[index].tx_pending;
taskEXIT_CRITICAL(&s_lock);
return drained;
}
@@ -220,21 +280,23 @@ static esp_err_t admin_console_perform(const admin_ssh_console_token_t *token,
}
}
static const admin_console_owner_t s_admin_console_owner = {
.supported_actions = (1U << ADMIN_SSH_DEFER_REBOOT) |
(1U << ADMIN_SSH_DEFER_STOP) | (1U << ADMIN_SSH_DEFER_DISCONNECT) |
(1U << ADMIN_SSH_DEFER_HOST_KEY_ROTATE) |
(1U << ADMIN_SSH_DEFER_HOST_KEY_RESET) | (1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE),
.drained = admin_console_drained,
.is_current = admin_console_is_current,
.perform = admin_console_perform,
};
esp_err_t admin_ssh_console_open(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
static const admin_console_owner_t owner = {
.supported_actions = (1U << ADMIN_SSH_DEFER_REBOOT) |
(1U << ADMIN_SSH_DEFER_STOP) | (1U << ADMIN_SSH_DEFER_DISCONNECT) |
(1U << ADMIN_SSH_DEFER_HOST_KEY_ROTATE) |
(1U << ADMIN_SSH_DEFER_HOST_KEY_RESET) | (1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE),
.drained = admin_console_drained,
.perform = admin_console_perform,
};
if (token == NULL || token->transport != 0U) {
if (token == NULL || token->transport != ADMIN_CONSOLE_TRANSPORT_SSH) {
return ESP_ERR_INVALID_ARG;
}
return admin_ssh_console_open_owned(token, principal, &owner);
return admin_ssh_console_open_owned(token, principal, &s_admin_console_owner);
}
static bool consume_external_close(const ssh_slot_t *slot, size_t slot_index)
@@ -242,6 +304,10 @@ static bool consume_external_close(const ssh_slot_t *slot, size_t slot_index)
taskENTER_CRITICAL(&s_lock);
bool requested = s_external_close_id[slot_index] != 0U &&
s_external_close_id[slot_index] == slot->session_id;
if (requested) {
/* Keep close intent visible while the owner begins cleanup. */
s_session_snapshots[slot_index].close_requested = true;
}
if (requested || slot->state == SSH_TRANSPORT_SESSION_FREE) {
s_external_close_id[slot_index] = 0U;
}
@@ -982,12 +1048,14 @@ static void process_handshake(ssh_slot_t *slot, size_t slot_index)
slot->route = SSH_TRANSPORT_ROUTE_BROKER;
} else if (slot->principal.role == USER_ROLE_ADMIN) {
admin_ssh_console_token_t token = admin_console_token(slot, slot_index);
error = admin_ssh_console_open(&token, &slot->principal);
error = admin_ssh_console_open_available(&token, &slot->principal,
&s_admin_console_owner);
if (error != ESP_OK) {
add_counter(&s_counters.admin_console_admission_failures, 1U);
request_slot_close(slot, false);
return;
}
slot->console_slot_index = token.slot_index;
slot->route = SSH_TRANSPORT_ROUTE_ADMIN_CONSOLE;
add_counter(&s_counters.admin_console_admissions, 1U);
} else {