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
+2
View File
@@ -31,6 +31,8 @@ idf_component_register(
"user_console.c"
"web_security.c"
"web_serial_transport.c"
"web_admin_tickets.c"
"web_admin_transport.c"
"web_assets_data.c"
"web_ui.c"
"web_server.c"
+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;
+23 -3
View File
@@ -17,6 +17,9 @@ extern "C" {
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
#define ADMIN_CONSOLE_TRANSPORT_SSH 0U
#define ADMIN_CONSOLE_TRANSPORT_WEB 1U
typedef struct {
uint8_t slot_index;
uint32_t session_id;
@@ -36,12 +39,15 @@ typedef enum {
/* Small owner boundary; module/API names are retained for existing SSH callers.
* Exactly two shared console slots, not two per transport. slot_index addresses
* this pool; owners coordinate admission and must not reuse an identity while
* this pool; open_available atomically selects a free slot. Owners must not reuse an identity while
* old work can exist. transport is a firmware-assigned namespace (0 = SSH).
* 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, never on the dispatcher or socket owner.
* control task OUTSIDE console locks for drained/perform. 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.
* drained must be nonblocking, validate the full identity and include pending
* owner output. perform must revalidate identity and marshal lifecycle work to
* its owner, never call socket libraries here. Neither callback may call console
@@ -53,7 +59,10 @@ typedef enum {
* parallel. Shared completion scratch is nonblocking/serialized by the core.
* The owner alone consumes output, maintains authentication/session liveness,
* and calls close on disconnect/revocation. Core copies/rechecks principals at
* admission and dispatch, but does not implement transport-specific expiry.
* admission and dispatch. Dispatch and prompts also check owner currentness;
* blocked prompts recheck every 250ms (plus check/scheduling latency). This does
* not cancel or roll back arbitrary executing handlers. Admission remains the
* owner's responsibility; is_current need not accept unpublished admission.
* 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.
@@ -61,11 +70,22 @@ typedef enum {
*/
typedef struct {
uint32_t supported_actions;
bool (*is_current)(const admin_ssh_console_token_t *token,
const user_principal_t *principal);
bool (*drained)(const admin_ssh_console_token_t *token);
esp_err_t (*perform)(const admin_ssh_console_token_t *token,
admin_ssh_deferred_action_type_t action, uint32_t argument);
} admin_console_owner_t;
/* Selects any inactive, nonexecuting slot from the shared two-slot pool.
* Input slot_index is ignored; only slot_index changes, and only on success.
* Caller supplies transport/session_id/slot_generation and must retain the
* returned token. Full pool returns ESP_ERR_INVALID_STATE, like open_owned.
*/
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);
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);
+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 {
+300
View File
@@ -0,0 +1,300 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#include "web_admin_tickets.h"
#include <limits.h>
#include <string.h>
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "mbedtls/sha256.h"
#include "secure_random.h"
typedef struct {
uint64_t generation;
web_session_id_t id;
int64_t expires_at_us;
user_principal_t principal;
uint8_t digest[32];
} ticket_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static struct {
ticket_t tickets[WEB_ADMIN_TICKET_CAPACITY];
uint64_t epoch;
uint64_t generation;
uint32_t issued, consumed, rejected, capacity_rejections;
bool ready;
} s_state;
static void increment(uint32_t *counter)
{
if (*counter != UINT32_MAX) ++*counter;
}
static bool equal_digest(const uint8_t *a, const uint8_t *b)
{
volatile uint8_t difference = 0;
for (size_t i = 0; i < 32; ++i) difference |= a[i] ^ b[i];
return difference == 0;
}
static bool admin(const user_principal_t *p)
{
return p != NULL && p->role == USER_ROLE_ADMIN &&
p->method == USER_AUTH_METHOD_PASSWORD && p->user_id != 0 &&
p->auth_generation != 0 && p->username_length != 0 &&
p->username_length <= USER_DATABASE_USERNAME_CAPACITY;
}
static bool same_principal(const user_principal_t *a, const user_principal_t *b)
{
return admin(b) && a->user_id == b->user_id &&
a->auth_generation == b->auth_generation && a->role == b->role &&
a->method == b->method && a->username_length == b->username_length &&
memcmp(a->username, b->username, a->username_length) == 0;
}
static bool current(web_session_id_t id, const user_principal_t *p)
{
bool valid = false;
return id != 0 && admin(p) &&
web_session_store_check_principal(id, p, &valid) == ESP_OK && valid;
}
static void expire_locked(int64_t now)
{
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t *t = &s_state.tickets[i];
if (t->generation && t->expires_at_us <= now) secure_wipe(t, sizeof(*t));
}
}
/* Fixed two-slot walk. Generation prevents an external check from deleting a
* replacement, including when RNG returns the same bytes on a later issue. */
static void prune(void)
{
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t copy = {0};
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
copy = s_state.tickets[i];
taskEXIT_CRITICAL(&s_lock);
if (copy.generation && !current(copy.id, &copy.principal)) {
taskENTER_CRITICAL(&s_lock);
if (s_state.tickets[i].generation == copy.generation)
secure_wipe(&s_state.tickets[i], sizeof(ticket_t));
taskEXIT_CRITICAL(&s_lock);
}
secure_wipe(&copy, sizeof(copy));
}
}
static void advance_epoch_locked(void)
{
if (s_state.epoch != UINT64_MAX) ++s_state.epoch;
if (s_state.epoch == UINT64_MAX) {
s_state.ready = false;
secure_wipe(s_state.tickets, sizeof(s_state.tickets));
}
}
void web_admin_tickets_start(void)
{
taskENTER_CRITICAL(&s_lock);
if (!s_state.ready && s_state.epoch != UINT64_MAX &&
s_state.generation != UINT64_MAX) {
advance_epoch_locked();
s_state.ready = s_state.epoch != UINT64_MAX;
}
taskEXIT_CRITICAL(&s_lock);
}
void web_admin_tickets_stop(void)
{
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
s_state.ready = false;
secure_wipe(s_state.tickets, sizeof(s_state.tickets));
taskEXIT_CRITICAL(&s_lock);
}
static bool capture_epoch(uint64_t *epoch)
{
taskENTER_CRITICAL(&s_lock);
*epoch = s_state.epoch;
bool ready = s_state.ready;
taskEXIT_CRITICAL(&s_lock);
return ready;
}
static esp_err_t result(esp_err_t error)
{
if (error != ESP_OK) {
taskENTER_CRITICAL(&s_lock);
increment(&s_state.rejected);
taskEXIT_CRITICAL(&s_lock);
}
return error;
}
esp_err_t web_admin_tickets_issue(web_session_id_t id,
const user_principal_t *principal, char token[WEB_ADMIN_TICKET_LENGTH + 1U])
{
ticket_t candidate = {0};
uint8_t random[32] = {0};
uint64_t epoch = 0;
esp_err_t error = ESP_ERR_INVALID_ARG;
if (token == NULL) return result(error);
secure_wipe(token, WEB_ADMIN_TICKET_LENGTH + 1U);
if (id == 0 || principal == NULL) goto done;
error = ESP_ERR_INVALID_STATE;
if (!capture_epoch(&epoch) || !current(id, principal)) goto done;
candidate.id = id;
candidate.principal = *principal;
prune();
if (!current(id, &candidate.principal)) goto done;
error = secure_random_fill(random, sizeof(random));
/* Recheck even when crypto fails; never use an old authorization result. */
bool valid = current(id, &candidate.principal);
if (error != ESP_OK) goto done;
error = ESP_ERR_INVALID_STATE;
if (!valid) goto done;
static const char hex[] = "0123456789abcdef";
for (size_t i = 0; i < sizeof(random); ++i) {
token[2 * i] = hex[random[i] >> 4];
token[2 * i + 1] = hex[random[i] & 15];
}
int crypto = mbedtls_sha256(random, sizeof(random), candidate.digest, 0);
valid = current(id, &candidate.principal);
error = crypto == 0 ? ESP_ERR_INVALID_STATE : ESP_FAIL;
if (crypto != 0 || !valid) goto done;
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
if (s_state.ready && epoch == s_state.epoch && now >= 0 &&
now <= INT64_MAX - WEB_ADMIN_TICKET_LIFETIME_US &&
s_state.generation != UINT64_MAX) {
ticket_t *free_slot = NULL;
bool duplicate = false;
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t *t = &s_state.tickets[i];
if (!t->generation) free_slot = t;
else if (equal_digest(t->digest, candidate.digest)) duplicate = true;
}
if (duplicate) error = ESP_FAIL;
else if (free_slot == NULL) {
increment(&s_state.capacity_rejections);
error = ESP_ERR_NO_MEM;
} else {
candidate.generation = ++s_state.generation;
candidate.expires_at_us = now + WEB_ADMIN_TICKET_LIFETIME_US;
*free_slot = candidate;
increment(&s_state.issued);
error = ESP_OK;
}
}
taskEXIT_CRITICAL(&s_lock);
done:
secure_wipe(random, sizeof(random));
secure_wipe(&candidate, sizeof(candidate));
if (error != ESP_OK) secure_wipe(token, WEB_ADMIN_TICKET_LENGTH + 1U);
return result(error);
}
static int unhex(char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
esp_err_t web_admin_tickets_consume(const char *token, web_session_id_t id,
const user_principal_t *principal)
{
uint8_t bytes[32] = {0}, digest[32] = {0};
ticket_t found = {0};
uint64_t epoch = 0;
esp_err_t error = ESP_ERR_INVALID_ARG;
if (token == NULL) goto done;
for (size_t i = 0; i < WEB_ADMIN_TICKET_LENGTH; ++i) {
int n = unhex(token[i]);
if (n < 0) goto done;
bytes[i / 2] |= (uint8_t)(n << ((i % 2 == 0) ? 4 : 0));
}
if (token[WEB_ADMIN_TICKET_LENGTH] != '\0') goto done;
error = ESP_ERR_INVALID_STATE;
if (!capture_epoch(&epoch)) goto done;
bool before = current(id, principal);
if (mbedtls_sha256(bytes, sizeof(bytes), digest, 0) != 0) {
(void)current(id, principal);
error = ESP_FAIL;
goto done;
}
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
if (s_state.ready && epoch == s_state.epoch) {
error = ESP_ERR_NOT_FOUND;
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t *t = &s_state.tickets[i];
if (t->generation && equal_digest(t->digest, digest)) {
found = *t;
secure_wipe(t, sizeof(*t));
increment(&s_state.consumed);
break;
}
}
}
taskEXIT_CRITICAL(&s_lock);
/* Burn precedes acting on either currentness result or identity binding. */
bool after = current(id, principal);
if (found.generation) {
now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
error = before && after && found.id == id &&
same_principal(&found.principal, principal) && s_state.ready &&
epoch == s_state.epoch && now < found.expires_at_us ?
ESP_OK : ESP_ERR_INVALID_STATE;
taskEXIT_CRITICAL(&s_lock);
}
done:
secure_wipe(bytes, sizeof(bytes));
secure_wipe(digest, sizeof(digest));
secure_wipe(&found, sizeof(found));
return result(error);
}
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *username,
size_t length)
{
taskENTER_CRITICAL(&s_lock);
advance_epoch_locked();
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i) {
ticket_t *t = &s_state.tickets[i];
bool match = id != 0 ? t->id == id : username == NULL ||
(length == t->principal.username_length &&
length <= USER_DATABASE_USERNAME_CAPACITY &&
memcmp(username, t->principal.username, length) == 0);
if (match) secure_wipe(t, sizeof(*t));
}
taskEXIT_CRITICAL(&s_lock);
}
void web_admin_tickets_get_snapshot(web_admin_tickets_snapshot_t *snapshot)
{
if (snapshot == NULL) return;
prune();
int64_t now = esp_timer_get_time();
taskENTER_CRITICAL(&s_lock);
expire_locked(now);
*snapshot = (web_admin_tickets_snapshot_t) {
.issued = s_state.issued, .consumed = s_state.consumed,
.rejected = s_state.rejected,
.capacity_rejections = s_state.capacity_rejections,
.storage_bytes = sizeof(s_state) + sizeof(s_lock), .ready = s_state.ready,
};
for (size_t i = 0; i < WEB_ADMIN_TICKET_CAPACITY; ++i)
if (s_state.tickets[i].generation) ++snapshot->active;
taskEXIT_CRITICAL(&s_lock);
}
+44
View File
@@ -0,0 +1,44 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "web_session_store.h"
#define WEB_ADMIN_TICKET_LENGTH 64U
#define WEB_ADMIN_TICKET_CAPACITY 2U
#define WEB_ADMIN_TICKET_LIFETIME_US 30000000LL
typedef struct {
uint32_t issued;
uint32_t consumed;
uint32_t rejected;
uint32_t capacity_rejections;
uint32_t active;
size_t storage_bytes;
bool ready;
} web_admin_tickets_snapshot_t;
/* Trusted internal API, not HTTP authorization. Start is idempotent while ready;
* stop wipes records. Neither lifecycle operation resets epochs or counters.
* RNG must already be initialized. Exhausted generations fail closed. */
void web_admin_tickets_start(void);
void web_admin_tickets_stop(void);
/* Only current password-authenticated administrators. No live eviction.
* Output must not alias inputs; all 65 bytes are wiped on failure.
* NO_MEM: capacity; INVALID_ARG: malformed input; INVALID_STATE: stopped,
* stale, unauthorized or raced; FAIL: SHA failure; RNG errors propagate. */
esp_err_t web_admin_tickets_issue(web_session_id_t id,
const user_principal_t *principal, char token[WEB_ADMIN_TICKET_LENGTH + 1U]);
/* Exact hex string (either case). Matching tickets are burned even for wrong
* session/principal or failed currentness. NOT_FOUND means no live match.
* Crypto failure cannot identify/burn a ticket. Success is not a session lease. */
esp_err_t web_admin_tickets_consume(const char *token, web_session_id_t id,
const user_principal_t *principal);
/* Caller invalidates sessions FIRST. Nonzero ID takes precedence; otherwise
* non-NULL username matches exact bytes/length; otherwise revoke all.
* Every call cancels in-flight work, even when no record matches. */
void web_admin_tickets_revoke(web_session_id_t id, const uint8_t *username,
size_t length);
/* Saturating lifetime counters; consumed counts burned matches, not admissions.
* rejected counts failed issue/consume (including capacity). Snapshot prunes
* expired/stale records; storage_bytes includes state and lock, no secrets. */
void web_admin_tickets_get_snapshot(web_admin_tickets_snapshot_t *snapshot);
+549
View File
@@ -0,0 +1,549 @@
/* SPDX-License-Identifier: GPL-3.0-only */
/* One optional admin socket. HTTPD owns IO; the canonical dispatcher owns commands. */
#include "web_admin_transport.h"
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include "admin_ssh_console.h"
#include "esp_heap_caps.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "secure_random.h"
#include "web_admin_tickets.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
#define ADMIN_POLL_US 20000ULL
#define ADMIN_INPUT_TIMEOUT_US 5000000LL
#define ADMIN_DETACH_TIMEOUT_US 2000000LL
typedef struct {
uint8_t rx[WEB_ADMIN_RX_CAPACITY];
uint8_t tx[WEB_ADMIN_TX_CAPACITY];
size_t rx_length, rx_offset;
int64_t input_deadline;
} admin_payload_t;
typedef struct {
bool occupied, active, console_open, close_requested, close_triggered, sending;
int fd;
web_session_id_t session;
user_principal_t principal;
admin_ssh_console_token_t token;
} admin_slot_t;
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
static admin_slot_t s_slot;
static admin_payload_t *s_payload; /* PSRAM; touched only by HTTPD while attached. */
static esp_timer_handle_t s_timer;
static httpd_handle_t s_server;
static bool s_initialized, s_accepting, s_queued;
static unsigned s_submitting;
static uint32_t s_generation; /* Never wrap/reuse within a boot. */
static web_admin_transport_snapshot_t s_counts;
static void count(uint32_t *value, uint32_t amount)
{
taskENTER_CRITICAL(&s_lock);
*value = UINT32_MAX - *value < amount ? UINT32_MAX : *value + amount;
taskEXIT_CRITICAL(&s_lock);
}
static bool token_matches(const admin_ssh_console_token_t *token)
{
return token && s_slot.occupied && s_slot.console_open &&
token->transport == ADMIN_CONSOLE_TRANSPORT_WEB &&
token->session_id == s_slot.token.session_id &&
token->slot_generation == s_slot.token.slot_generation &&
token->slot_index == s_slot.token.slot_index;
}
static bool owner_current(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
taskENTER_CRITICAL(&s_lock);
bool valid = token_matches(token) && s_accepting && s_slot.active && !s_slot.close_requested;
web_session_id_t id = valid ? s_slot.session : 0;
taskEXIT_CRITICAL(&s_lock);
bool current = false;
if (!valid || !principal || principal->role != USER_ROLE_ADMIN ||
web_session_store_check_principal(id, principal, &current) != ESP_OK || !current)
return false;
taskENTER_CRITICAL(&s_lock);
valid = token_matches(token) && s_accepting && s_slot.active &&
!s_slot.close_requested && s_slot.session == id;
taskEXIT_CRITICAL(&s_lock);
return valid;
}
static bool owner_drained(const admin_ssh_console_token_t *token)
{
taskENTER_CRITICAL(&s_lock);
bool drained = token_matches(token) && s_slot.active &&
!s_slot.close_requested && !s_slot.sending;
taskEXIT_CRITICAL(&s_lock);
return drained;
}
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;
taskENTER_CRITICAL(&s_lock);
bool valid = token_matches(token) && s_slot.active && s_accepting;
if (valid) s_slot.close_requested = true;
taskEXIT_CRITICAL(&s_lock);
if (valid) admin_ssh_console_close(token);
return valid ? ESP_OK : ESP_ERR_NOT_FOUND;
}
static const admin_console_owner_t s_owner = {
.supported_actions = 1U << ADMIN_CONSOLE_DEFER_SELF_CLOSE,
.is_current = owner_current, .drained = owner_drained, .perform = owner_perform,
};
/* No IO and no payload mutation: safe on console/revocation/lifecycle callers. */
static void request_close(void)
{
taskENTER_CRITICAL(&s_lock);
bool opened = s_slot.console_open;
admin_ssh_console_token_t token = s_slot.token;
if (s_slot.occupied) s_slot.close_requested = true;
taskEXIT_CRITICAL(&s_lock);
if (opened) admin_ssh_console_close(&token);
}
/* HTTPD callback, or lifecycle caller ONLY after HTTPD has successfully stopped. */
static void session_free(void *context)
{
if (context != &s_slot) return;
taskENTER_CRITICAL(&s_lock);
bool occupied = s_slot.occupied;
bool opened = s_slot.console_open;
bool active = s_slot.active;
admin_ssh_console_token_t token = s_slot.token;
secure_wipe(&s_slot, sizeof(s_slot));
taskEXIT_CRITICAL(&s_lock);
if (opened) admin_ssh_console_close(&token);
if (occupied && s_payload) secure_wipe(s_payload, sizeof(*s_payload));
if (active) count(&s_counts.disconnections, 1);
}
static bool capture(admin_ssh_console_token_t *token, user_principal_t *principal, int *fd)
{
taskENTER_CRITICAL(&s_lock);
bool active = s_slot.active;
*token = s_slot.token;
*principal = s_slot.principal;
*fd = s_slot.fd;
taskEXIT_CRITICAL(&s_lock);
return active;
}
static bool input_current(const admin_ssh_console_token_t *token,
const user_principal_t *principal)
{
if (owner_current(token, principal)) return true;
count(&s_counts.authorization_rejections, 1);
request_close();
return false;
}
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;
if (esp_timer_get_time() >= s_payload->input_deadline) {
count(&s_counts.input_backpressure, 1);
request_close();
return false;
}
size_t consumed = 0;
(void)admin_ssh_console_feed_input(token, s_payload->rx + s_payload->rx_offset,
s_payload->rx_length - s_payload->rx_offset, &consumed);
secure_wipe(s_payload->rx + s_payload->rx_offset, consumed);
s_payload->rx_offset += consumed;
if (s_payload->rx_offset == s_payload->rx_length) {
s_payload->rx_offset = s_payload->rx_length = 0;
s_payload->input_deadline = 0;
}
return true;
}
/* Only this HTTPD work callback sends console output or requests idle closure. */
static void poll_work(void *argument)
{
httpd_handle_t server = argument;
admin_ssh_console_token_t token;
user_principal_t principal;
int fd;
bool active = capture(&token, &principal, &fd);
taskENTER_CRITICAL(&s_lock);
bool attached = s_accepting && server == s_server;
taskEXIT_CRITICAL(&s_lock);
if (!active || !attached) goto done;
if (!input_current(&token, &principal)) goto closing;
if (httpd_sess_get_ctx(server, fd) != &s_slot ||
httpd_ws_get_fd_info(server, fd) != HTTPD_WS_CLIENT_WEBSOCKET) {
request_close();
goto closing;
}
admin_ssh_console_session_snapshot_t console;
if (admin_ssh_console_get_session_snapshot(&token, &console) != ESP_OK || !console.active) {
request_close();
goto closing;
}
if (!feed_pending(&token, &principal)) goto closing;
taskENTER_CRITICAL(&s_lock);
s_slot.sending = true; /* Covers the gap between ring consumption and socket send. */
taskEXIT_CRITICAL(&s_lock);
size_t length = 0;
esp_err_t error = admin_ssh_console_read_output(&token, s_payload->tx,
sizeof(s_payload->tx), &length);
if (error == ESP_OK && length && input_current(&token, &principal)) {
httpd_ws_frame_t frame = {.final = true, .type = HTTPD_WS_TYPE_BINARY,
.payload = s_payload->tx, .len = length};
error = httpd_ws_send_frame_async(server, fd, &frame);
if (error == ESP_OK) count(&s_counts.tx_bytes, (uint32_t)length);
}
secure_wipe(s_payload->tx, sizeof(s_payload->tx));
taskENTER_CRITICAL(&s_lock);
s_slot.sending = false;
taskEXIT_CRITICAL(&s_lock);
if (error != ESP_OK) {
count(&s_counts.send_failures, 1);
request_close();
}
closing:
taskENTER_CRITICAL(&s_lock);
bool close = s_slot.active && s_slot.close_requested && !s_slot.close_triggered;
taskEXIT_CRITICAL(&s_lock);
if (close && httpd_sess_get_ctx(server, fd) == &s_slot) {
/* IDF's queued close retains a reusable sock_db pointer. Shutdown on
* HTTPD instead: its next read owns deletion, with no late close that
* could evict a replacement (including a serial client). */
if (shutdown(fd, SHUT_RDWR) == 0) {
taskENTER_CRITICAL(&s_lock);
s_slot.close_triggered = true;
taskEXIT_CRITICAL(&s_lock);
} else count(&s_counts.send_failures, 1); /* Retry on the next bounded poll. */
}
done:
secure_wipe(&principal, sizeof(principal));
taskENTER_CRITICAL(&s_lock);
s_queued = false;
taskEXIT_CRITICAL(&s_lock);
}
/* ESP timer task: no database/console/socket calls, no waits, one queue entry max.
* Detach prevents new submissions and fences any submission already outside lock. */
static void poll_timer(void *argument)
{
(void)argument;
taskENTER_CRITICAL(&s_lock);
httpd_handle_t server = NULL;
uint32_t generation = 0;
if (s_accepting && s_slot.active && !s_queued) {
server = s_server;
generation = s_slot.token.slot_generation;
s_queued = true;
++s_submitting;
}
taskEXIT_CRITICAL(&s_lock);
if (!server) return;
esp_err_t error = httpd_queue_work(server, poll_work, server);
taskENTER_CRITICAL(&s_lock);
--s_submitting;
if (error != ESP_OK) {
s_queued = false;
if (s_slot.active && s_slot.token.slot_generation == generation)
s_slot.close_requested = true;
}
taskEXIT_CRITICAL(&s_lock);
if (error != ESP_OK) count(&s_counts.queue_failures, 1);
}
esp_err_t web_admin_transport_init(void)
{
#if defined(CONFIG_HTTPD_QUEUE_WORK_BLOCKING) && CONFIG_HTTPD_QUEUE_WORK_BLOCKING
return ESP_ERR_NOT_SUPPORTED;
#else
if (s_initialized) return ESP_OK; /* Lifecycle caller serializes initialization. */
admin_payload_t *payload = heap_caps_calloc(1, sizeof(*payload), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
esp_err_t error = payload ? ESP_OK : ESP_ERR_NO_MEM;
esp_timer_handle_t timer = NULL;
const esp_timer_create_args_t args = {
.callback = poll_timer, .name = "web_admin", .skip_unhandled_events = true,
};
if (error == ESP_OK) error = esp_timer_create(&args, &timer);
if (error == ESP_OK) error = esp_timer_start_periodic(timer, ADMIN_POLL_US);
if (error != ESP_OK) {
if (timer) (void)esp_timer_delete(timer);
if (payload) heap_caps_free(payload);
}
taskENTER_CRITICAL(&s_lock);
if (error == ESP_OK) {
s_payload = payload;
s_timer = timer;
s_initialized = true;
}
s_counts.last_error = error;
taskEXIT_CRITICAL(&s_lock);
return error;
#endif
}
esp_err_t web_admin_transport_attach(httpd_handle_t server)
{
if (!server) return ESP_ERR_INVALID_ARG;
taskENTER_CRITICAL(&s_lock);
bool allowed = s_initialized && !s_server && !s_queued && !s_submitting && !s_slot.occupied;
taskEXIT_CRITICAL(&s_lock);
if (!allowed) return ESP_ERR_INVALID_STATE;
web_admin_tickets_start();
taskENTER_CRITICAL(&s_lock);
s_server = server;
s_accepting = true;
taskEXIT_CRITICAL(&s_lock);
return ESP_OK;
}
esp_err_t web_admin_transport_detach(httpd_handle_t server)
{
taskENTER_CRITICAL(&s_lock);
bool owned = server && server == s_server;
if (owned) s_accepting = false;
taskEXIT_CRITICAL(&s_lock);
if (!owned) return ESP_ERR_INVALID_STATE;
web_admin_tickets_stop();
request_close();
int64_t deadline = esp_timer_get_time() + ADMIN_DETACH_TIMEOUT_US;
for (;;) {
taskENTER_CRITICAL(&s_lock);
bool submitting = s_submitting != 0;
taskEXIT_CRITICAL(&s_lock);
if (!submitting) return ESP_OK;
if (esp_timer_get_time() >= deadline) return ESP_ERR_TIMEOUT;
vTaskDelay(1);
}
}
void web_admin_transport_stopped(httpd_handle_t server)
{
taskENTER_CRITICAL(&s_lock);
bool owned = server && s_server == server && !s_accepting && !s_submitting;
taskEXIT_CRITICAL(&s_lock);
if (!owned) return;
session_free(&s_slot);
taskENTER_CRITICAL(&s_lock);
s_server = NULL;
s_queued = false; /* HTTPD is gone; its queued callbacks can no longer execute. */
taskEXIT_CRITICAL(&s_lock);
}
void web_admin_transport_revoke(web_session_id_t id, const uint8_t *username, size_t length)
{
web_admin_tickets_revoke(id, username, length);
taskENTER_CRITICAL(&s_lock);
bool match = s_slot.occupied && (id ? s_slot.session == id :
!username || (length == s_slot.principal.username_length &&
length <= USER_DATABASE_USERNAME_CAPACITY &&
memcmp(username, s_slot.principal.username, length) == 0));
admin_ssh_console_token_t token = s_slot.token;
bool opened = match && s_slot.console_open;
if (match) s_slot.close_requested = true;
taskEXIT_CRITICAL(&s_lock);
if (opened) admin_ssh_console_close(&token);
}
static esp_err_t response(httpd_req_t *request, const char *status, const char *body)
{
esp_err_t error = httpd_resp_set_status(request, status);
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
return error;
}
static esp_err_t deny(httpd_req_t *request, const char *status, const char *body)
{
if (!strcmp(status, "503 Service Unavailable") &&
httpd_resp_set_hdr(request, "Retry-After", "5") != ESP_OK) return ESP_FAIL;
(void)response(request, status, body);
return ESP_FAIL; /* Close after rejection, never leave unread frames/body alive. */
}
esp_err_t web_admin_transport_ticket_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
char ticket[WEB_ADMIN_TICKET_LENGTH + 1U] = {0}, body[128] = {0};
bool allowed = false;
esp_err_t error = web_cookie_auth_require(request, true, false, &view, &allowed);
if (error != ESP_OK || !allowed) goto cleanup;
if (view.principal.role != USER_ROLE_ADMIN) {
count(&s_counts.authorization_rejections, 1);
error = deny(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto cleanup;
}
taskENTER_CRITICAL(&s_lock);
bool attached = s_accepting && s_server == request->handle;
taskEXIT_CRITICAL(&s_lock);
error = attached ? web_admin_tickets_issue(view.id, &view.principal, ticket) : ESP_ERR_INVALID_STATE;
if (error != ESP_OK) {
if (error == ESP_ERR_NO_MEM) count(&s_counts.capacity_rejections, 1);
error = deny(request, "503 Service Unavailable", "{\"error\":\"admin_unavailable_or_capacity\"}");
goto cleanup;
}
int n = snprintf(body, sizeof(body), "{\"ticket\":\"%s\",\"expires_in\":30}", ticket);
error = n > 0 && (size_t)n < sizeof(body) ? response(request, "200 OK", body) : ESP_FAIL;
cleanup:
secure_wipe(ticket, sizeof(ticket));
secure_wipe(body, sizeof(body));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
static esp_err_t frame_handler(httpd_req_t *request)
{
admin_ssh_console_token_t token;
user_principal_t principal;
int fd;
bool active = capture(&token, &principal, &fd);
bool valid = active && request->sess_ctx == &s_slot &&
fd == httpd_req_to_sockfd(request) && input_current(&token, &principal);
if (!valid) goto failure;
httpd_ws_frame_t frame = {0};
if (httpd_ws_recv_frame(request, &frame, 0) != ESP_OK || !frame.final ||
frame.type != HTTPD_WS_TYPE_BINARY || frame.len > WEB_ADMIN_RX_CAPACITY) {
count(&s_counts.protocol_errors, 1);
goto failure;
}
if (s_payload->rx_length != s_payload->rx_offset) {
count(&s_counts.input_backpressure, 1);
goto failure;
}
frame.payload = s_payload->rx;
/* IDF treats len==0 as another header probe, not an empty payload read. */
if (frame.len && httpd_ws_recv_frame(request, &frame, sizeof(s_payload->rx)) != ESP_OK)
goto failure;
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;
count(&s_counts.rx_bytes, (uint32_t)frame.len);
secure_wipe(&principal, sizeof(principal));
return ESP_OK;
failure:
secure_wipe(&principal, sizeof(principal));
request_close();
return ESP_FAIL;
}
esp_err_t web_admin_transport_upgrade_handler(httpd_req_t *request)
{
web_session_view_t view = {0};
char ticket[WEB_ADMIN_TICKET_LENGTH + 1U] = {0};
admin_ssh_console_token_t token = {0};
bool allowed = false, reserved = false, opened = false;
esp_err_t error = web_cookie_auth_require(request, false, true, &view, &allowed);
if (error != ESP_OK || !allowed) goto cleanup;
if (view.principal.role != USER_ROLE_ADMIN) {
count(&s_counts.authorization_rejections, 1);
error = deny(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
goto cleanup;
}
static const char prefix[] = WEB_ADMIN_WS_URI "?ticket=";
if (!web_httpd_upgrade_requested(request) ||
strncmp(request->uri, prefix, sizeof(prefix) - 1U) ||
strlen(request->uri) != sizeof(prefix) - 1U + WEB_ADMIN_TICKET_LENGTH) {
error = deny(request, "400 Bad Request", "{\"error\":\"invalid_upgrade\"}");
goto cleanup;
}
memcpy(ticket, request->uri + sizeof(prefix) - 1U, WEB_ADMIN_TICKET_LENGTH);
if (web_admin_tickets_consume(ticket, view.id, &view.principal) != ESP_OK) {
count(&s_counts.authorization_rejections, 1);
error = deny(request, "403 Forbidden", "{\"error\":\"invalid_ticket\"}");
goto cleanup;
}
int socket_fd = httpd_req_to_sockfd(request);
taskENTER_CRITICAL(&s_lock);
if (socket_fd >= 0 && s_accepting && s_server == request->handle &&
!s_slot.occupied && s_generation != UINT32_MAX) {
++s_generation;
token = (admin_ssh_console_token_t){.transport = ADMIN_CONSOLE_TRANSPORT_WEB,
.session_id = s_generation, .slot_generation = s_generation};
s_slot.occupied = true;
s_slot.session = view.id;
s_slot.principal = view.principal;
s_slot.fd = socket_fd;
s_slot.token = token;
reserved = true;
}
taskEXIT_CRITICAL(&s_lock);
if (!reserved) {
count(&s_counts.capacity_rejections, 1);
error = deny(request, "503 Service Unavailable", "{\"error\":\"admin_capacity\"}");
goto cleanup;
}
error = admin_ssh_console_open_available(&token, &view.principal, &s_owner);
if (error != ESP_OK) {
count(&s_counts.capacity_rejections, 1);
error = deny(request, "503 Service Unavailable", "{\"error\":\"console_capacity_or_unavailable\"}");
goto cleanup;
}
opened = true;
bool current = false;
error = web_session_store_check_principal(view.id, &view.principal, &current);
taskENTER_CRITICAL(&s_lock);
s_slot.token = token;
s_slot.console_open = true;
bool admitted = error == ESP_OK && current && s_accepting && !s_slot.close_requested;
taskEXIT_CRITICAL(&s_lock);
if (!admitted) {
error = deny(request, "403 Forbidden", "{\"error\":\"session_revoked\"}");
goto cleanup;
}
error = web_httpd_upgrade(request, frame_handler);
if (error != ESP_OK) goto cleanup;
taskENTER_CRITICAL(&s_lock);
admitted = s_accepting && !s_slot.close_requested;
if (admitted) s_slot.active = true;
taskEXIT_CRITICAL(&s_lock);
if (!admitted) { error = ESP_FAIL; goto cleanup; }
request->sess_ctx = &s_slot;
request->free_ctx = session_free;
count(&s_counts.connections, 1);
reserved = false; /* HTTPD context now owns cleanup. */
cleanup:
if (reserved) {
if (opened) admin_ssh_console_close(&token);
session_free(&s_slot);
}
secure_wipe(ticket, sizeof(ticket));
secure_wipe(&view, sizeof(view));
web_httpd_wipe_request(request, web_httpd_unread_body(request));
return error;
}
void web_admin_transport_get_snapshot(web_admin_transport_snapshot_t *snapshot)
{
if (!snapshot) return;
taskENTER_CRITICAL(&s_lock);
*snapshot = s_counts;
snapshot->initialized = s_initialized;
snapshot->attached = s_accepting;
snapshot->active = s_slot.active;
snapshot->closing = s_slot.close_requested;
snapshot->payload_bytes = s_payload ? sizeof(*s_payload) : 0;
snapshot->static_bytes = sizeof(s_lock) + sizeof(s_slot) + sizeof(s_payload) +
sizeof(s_timer) + sizeof(s_server) + sizeof(s_initialized) + sizeof(s_accepting) +
sizeof(s_queued) + sizeof(s_submitting) + sizeof(s_generation) + sizeof(s_counts);
taskEXIT_CRITICAL(&s_lock);
}
+42
View File
@@ -0,0 +1,42 @@
/* SPDX-License-Identifier: GPL-3.0-only */
#pragma once
#include "esp_http_server.h"
#include "web_session_store.h"
#define WEB_ADMIN_TICKET_URI "/api/admin/ws-ticket"
#define WEB_ADMIN_WS_URI "/ws/admin"
#define WEB_ADMIN_MAX_SESSIONS 1U
#define WEB_ADMIN_RX_CAPACITY 512U
#define WEB_ADMIN_TX_CAPACITY 1024U
typedef struct {
bool initialized, attached, active, closing;
uint32_t connections, disconnections, capacity_rejections, authorization_rejections;
uint32_t protocol_errors, input_backpressure, send_failures, queue_failures;
uint32_t rx_bytes, tx_bytes;
size_t static_bytes, payload_bytes;
esp_err_t last_error;
} web_admin_transport_snapshot_t;
/* Lifecycle caller serializes init/attach/detach/stopped. Optional PSRAM-only
* payload allocation; no internal fallback, new task, broker client or dispatcher.
* Timer only queues at most one poll; HTTPD owns all payload/IO/session cleanup. */
esp_err_t web_admin_transport_init(void);
esp_err_t web_admin_transport_attach(httpd_handle_t server);
/* Disable admission and console access, then wait a bounded time for timer
* submissions to finish. On timeout do NOT stop/free HTTPD; retry detach first. */
esp_err_t web_admin_transport_detach(httpd_handle_t server);
/* Call ONLY after successful httpd_ssl_stop, including partial startup cleanup.
* Retires any unexecuted queued poll before allowing reuse of its static storage. */
void web_admin_transport_stopped(httpd_handle_t server);
/* Ordinary HTTP routes, never register is_websocket=true: admission before 101.
* These handlers enforce cookie/Origin/CSRF/role themselves. Binary frames carry
* console bytes, final/unfragmented, at most RX_CAPACITY; no serial controls. */
esp_err_t web_admin_transport_ticket_handler(httpd_req_t *request);
esp_err_t web_admin_transport_upgrade_handler(httpd_req_t *request);
/* Notification after authoritative store invalidation. id wins; else exact
* username; else all. Safe before init. No socket calls from notifier context. */
void web_admin_transport_revoke(web_session_id_t id, const uint8_t *username, size_t length);
void web_admin_transport_get_snapshot(web_admin_transport_snapshot_t *snapshot);
+29
View File
@@ -15,6 +15,8 @@
#include "web_serial_transport.h"
#include "web_server.h"
#include "web_cookie_auth.h"
#include "web_admin_transport.h"
#include "web_admin_tickets.h"
static void print_usage(void)
{
@@ -35,6 +37,30 @@ static void print_fingerprint(const uint8_t fingerprint[WEB_SECURITY_SHA256_LENG
}
}
static void show_admin_transport(void)
{
web_admin_transport_snapshot_t admin;
web_admin_tickets_snapshot_t tickets;
web_admin_transport_get_snapshot(&admin);
web_admin_tickets_get_snapshot(&tickets);
printf("WebSocket admin: initialized=%s attached=%s active=%s/1 closing=%s init-error=%s\n",
admin.initialized ? "yes" : "no", admin.attached ? "yes" : "no",
admin.active ? "yes" : "no", admin.closing ? "yes" : "no", esp_err_to_name(admin.last_error));
printf(" tickets=%" PRIu32 "/%u issued=%" PRIu32 " consumed=%" PRIu32
" rejected=%" PRIu32 " capacity=%" PRIu32 "\n",
tickets.active, WEB_ADMIN_TICKET_CAPACITY, tickets.issued, tickets.consumed,
tickets.rejected, tickets.capacity_rejections);
printf(" connected=%" PRIu32 " disconnected=%" PRIu32 " capacity=%" PRIu32
" authorization=%" PRIu32 " protocol=%" PRIu32 " input-backpressure=%" PRIu32 "\n",
admin.connections, admin.disconnections, admin.capacity_rejections,
admin.authorization_rejections, admin.protocol_errors, admin.input_backpressure);
printf(" rx-bytes=%" PRIu32 " tx-bytes=%" PRIu32 " send-failures=%" PRIu32
" queue-failures=%" PRIu32 " static=%u ticket-storage=%u PSRAM-payload=%u bytes\n",
admin.rx_bytes, admin.tx_bytes, admin.send_failures, admin.queue_failures,
(unsigned)admin.static_bytes, (unsigned)tickets.storage_bytes, (unsigned)admin.payload_bytes);
printf(" Admin counters are saturating lifetime counts (not reset by web clear-counters).\n");
}
static int show_status(void)
{
web_server_snapshot_t snapshot;
@@ -61,6 +87,8 @@ static int show_status(void)
}
printf("Endpoints: GET /, GET /api/status, POST /api/ws-ticket, WSS /ws/serial\n");
printf("Authentication routes: GET /login, GET /api/login-challenge, POST /api/login, GET /api/session, POST /api/logout\n");
printf("Admin-only backend: POST /api/admin/ws-ticket, WSS /ws/admin (no normal UI entry)\n");
show_admin_transport();
web_cookie_auth_snapshot_t auth;
web_cookie_auth_get_snapshot(&auth);
web_session_store_snapshot_t sessions;
@@ -122,6 +150,7 @@ static int show_counters(void)
return 1;
}
show_admin_transport();
const web_server_counters_t *counter = &snapshot.counters;
printf("Lifecycle: starts=%" PRIu64 " start-failures=%" PRIu64
" stops=%" PRIu64 "\n",
+4
View File
@@ -16,6 +16,7 @@
#include "serial_service.h"
#include "web_auth_parse.h"
#include "web_httpd_adapter.h"
#include "web_admin_transport.h"
#if !defined(CONFIG_HTTPD_WS_SUPPORT) || !CONFIG_HTTPD_WS_SUPPORT
#error "web_serial_transport requires CONFIG_HTTPD_WS_SUPPORT"
@@ -1843,6 +1844,7 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
}
web_session_store_invalidate_username(username, username_length);
web_admin_transport_revoke(0, username, username_length);
bool notify = false;
taskENTER_CRITICAL(&s_lock);
if (s_ticket_epoch != UINT64_MAX) {
@@ -1879,6 +1881,7 @@ esp_err_t web_serial_transport_revoke_user(const uint8_t *username,
esp_err_t web_serial_transport_revoke_sessions(void)
{
web_session_store_invalidate_username(NULL, 0U);
web_admin_transport_revoke(0, NULL, 0);
taskENTER_CRITICAL(&s_lock);
if (s_ticket_epoch != UINT64_MAX) {
++s_ticket_epoch;
@@ -1907,6 +1910,7 @@ esp_err_t web_serial_transport_revoke_web_session(web_session_id_t id)
return ESP_ERR_INVALID_ARG;
}
web_session_store_invalidate(id);
web_admin_transport_revoke(id, NULL, 0);
taskENTER_CRITICAL(&s_lock);
if (s_ticket_epoch != UINT64_MAX) {
++s_ticket_epoch;
+48 -3
View File
@@ -23,6 +23,7 @@
#include "user_database.h"
#include "web_security.h"
#include "web_serial_transport.h"
#include "web_admin_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
@@ -39,6 +40,8 @@ static bool s_transitioning;
static bool s_serial_transport_init_attempted;
static bool s_serial_transport_initialized;
static bool s_serial_transport_attached;
/* Retained across failed stop so queued admin work cannot outlive its server. */
static bool s_admin_transport_owned;
static esp_err_t s_last_error = ESP_ERR_INVALID_STATE;
static esp_err_t s_serial_transport_error = ESP_ERR_INVALID_STATE;
static web_server_counters_t s_counters;
@@ -368,6 +371,19 @@ static const httpd_uri_t s_websocket_uri = {
.handle_ws_control_frames = false,
};
static const httpd_uri_t s_admin_ticket_uri = {
.uri = WEB_ADMIN_TICKET_URI,
.method = HTTP_POST,
.handler = web_admin_transport_ticket_handler,
};
static const httpd_uri_t s_admin_websocket_uri = {
.uri = WEB_ADMIN_WS_URI,
.method = HTTP_GET,
.handler = web_admin_transport_upgrade_handler,
.is_websocket = false, /* Cookie/Origin/ticket/console admission precedes 101. */
};
static const httpd_uri_t s_xterm_js_uri = {
.uri = "/assets/xterm.js",
.method = HTTP_GET,
@@ -497,12 +513,13 @@ esp_err_t web_server_start(void)
private_key, sizeof(private_key), &private_key_length);
if (error == ESP_OK) {
httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT();
/* Two browser terminals retain room for parallel assets and status fetches. */
/* Two serial + one admin socket leave three slots for HTTPS requests. */
config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]);
config.httpd.lru_purge_enable = true;
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 2U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
config.httpd.send_wait_timeout = 1;
config.servercert = certificate;
@@ -535,6 +552,18 @@ esp_err_t web_server_start(void)
attach_error = web_serial_transport_attach_server(server);
serial_transport_attached = attach_error == ESP_OK;
}
bool admin_transport_owned = false;
if (error == ESP_OK) {
/* Even optional route allocation failure must leave M1 available. */
esp_err_t admin_error = httpd_register_uri_handler(server, &s_admin_ticket_uri);
bool ticket_registered = admin_error == ESP_OK;
if (admin_error == ESP_OK)
admin_error = httpd_register_uri_handler(server, &s_admin_websocket_uri);
if (admin_error != ESP_OK && ticket_registered)
(void)httpd_unregister_uri_handler(server, WEB_ADMIN_TICKET_URI, HTTP_POST);
if (admin_error == ESP_OK && web_admin_transport_init() == ESP_OK)
admin_transport_owned = web_admin_transport_attach(server) == ESP_OK;
}
if (error != ESP_OK) {
web_cookie_auth_stop();
}
@@ -553,6 +582,7 @@ esp_err_t web_server_start(void)
s_last_error = error;
s_serial_transport_error = attach_error;
s_serial_transport_attached = serial_transport_attached;
s_admin_transport_owned = admin_transport_owned;
if (error == ESP_OK) {
s_server = server;
++s_counters.starts;
@@ -578,11 +608,24 @@ esp_err_t web_server_stop(void)
}
httpd_handle_t server = s_server;
bool serial_transport_attached = s_serial_transport_attached;
bool admin_transport_owned = s_admin_transport_owned;
esp_err_t serial_transport_error = s_serial_transport_error;
s_transitioning = true;
xSemaphoreGive(s_server_mutex);
web_cookie_auth_stop();
if (admin_transport_owned) {
esp_err_t detach_error = web_admin_transport_detach(server);
if (detach_error != ESP_OK) {
/* Unlike serial's broker timeout, an admin submission timeout must
* retain HTTPD until detach can fence all queue submitters. */
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
s_last_error = detach_error;
xSemaphoreGive(s_server_mutex);
return detach_error;
}
}
if (serial_transport_attached) {
esp_err_t detach_error = web_serial_transport_detach_server(server);
if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) {
@@ -597,6 +640,7 @@ esp_err_t web_server_stop(void)
}
esp_err_t error = httpd_ssl_stop(server);
if (error == ESP_OK && admin_transport_owned) web_admin_transport_stopped(server);
if (error != ESP_OK && serial_transport_attached) {
/* Stay detached: old HTTPD work may still be reading static TX storage. */
serial_transport_error = ESP_ERR_INVALID_STATE;
@@ -609,6 +653,7 @@ esp_err_t web_server_stop(void)
s_serial_transport_attached = false;
if (error == ESP_OK) {
s_server = NULL;
s_admin_transport_owned = false;
++s_counters.stops;
}
xSemaphoreGive(s_server_mutex);