607 lines
24 KiB
C
607 lines
24 KiB
C
/* 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 "esp_system.h"
|
|
#include "web_server.h"
|
|
#include "web_security.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 && action != ADMIN_SSH_DEFER_REBOOT &&
|
|
action != ADMIN_CONSOLE_DEFER_WEB_STOP &&
|
|
action != ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE) return ESP_ERR_NOT_SUPPORTED;
|
|
/* Drain is only a delivery heuristic, not an authorization lease. The
|
|
* execution task must recheck cookie/account binding after delay/queueing. */
|
|
taskENTER_CRITICAL(&s_lock);
|
|
user_principal_t principal = s_slot.principal;
|
|
taskEXIT_CRITICAL(&s_lock);
|
|
bool current = owner_current(token, &principal);
|
|
secure_wipe(&principal, sizeof(principal));
|
|
if (!current) return ESP_ERR_NOT_FOUND;
|
|
if (action == ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE) {
|
|
/* The owner mask routes this crypto/NVS path to the 12KiB dispatcher.
|
|
* Commit before restart; a failed stop retains HTTPD ownership and must
|
|
* not be followed by start. No socket IO or console handler calls here. */
|
|
bool committed = false;
|
|
return web_server_replace_identity(0, 0, false, &committed);
|
|
}
|
|
if (action == ADMIN_CONSOLE_DEFER_WEB_STOP) return web_server_stop();
|
|
if (action == ADMIN_SSH_DEFER_REBOOT) {
|
|
esp_restart();
|
|
return ESP_OK;
|
|
}
|
|
taskENTER_CRITICAL(&s_lock);
|
|
bool valid = token_matches(token) && s_slot.active && s_accepting && !s_slot.close_requested;
|
|
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) |
|
|
(1U << ADMIN_SSH_DEFER_REBOOT) | (1U << ADMIN_CONSOLE_DEFER_WEB_STOP) |
|
|
(1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE),
|
|
.dispatcher_actions = 1U << ADMIN_CONSOLE_DEFER_WEB_CERTIFICATE_ROTATE,
|
|
.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 void discard_pending_input(void)
|
|
{
|
|
secure_wipe(s_payload->rx, sizeof(s_payload->rx));
|
|
s_payload->rx_offset = s_payload->rx_length = 0;
|
|
s_payload->input_deadline = 0;
|
|
count(&s_counts.input_backpressure, 1);
|
|
}
|
|
|
|
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;
|
|
admin_ssh_console_session_snapshot_t console;
|
|
if (admin_ssh_console_get_session_snapshot(token, &console) != ESP_OK || !console.active) {
|
|
request_close();
|
|
return false;
|
|
}
|
|
if (console.deferred_action_pending) {
|
|
/* Never replay buffered keystrokes if a deferred action fails/cancels. */
|
|
discard_pending_input();
|
|
return true;
|
|
}
|
|
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;
|
|
}
|
|
admin_ssh_console_session_snapshot_t console;
|
|
if (admin_ssh_console_get_session_snapshot(&token, &console) != ESP_OK || !console.active)
|
|
goto failure;
|
|
/* Latch before the potentially blocking receive: cancellation during receive
|
|
* must not turn input observed during deferral into a new command. */
|
|
bool discard_frame = console.deferred_action_pending;
|
|
if (s_payload->rx_length != s_payload->rx_offset) {
|
|
if (!discard_frame) {
|
|
count(&s_counts.input_backpressure, 1);
|
|
goto failure;
|
|
}
|
|
/* Deferral may start before the next poll discards buffered trailing input. */
|
|
discard_pending_input();
|
|
}
|
|
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 (discard_frame) discard_pending_input();
|
|
else 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);
|
|
}
|