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:
@@ -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
@@ -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;
|
||||
|
||||
+23
-3
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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, ©.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(©, 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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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, ¤t) != 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, ¤t);
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user