/* 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 #include #include #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); }