feat: add bounded admin WebSocket backend (Phase 8D.5)

- Require current admin cookie sessions, Origin checks and single-use
  tickets
- Reuse the shared console with session-aware authorization and slot
  allocation
- Add HTTPD-owned I/O, bounded buffering and revocation cleanup
- Prevent LRU eviction of serial clients and stale admin socket closure
- Reject unsupported web-shell mutations before side effects
- Add host regressions, a smoke client and resource accounting

Validated by user sign-off after a 15-minute full-client soak at 230400
baud, with a few broker drops under heavy output. Browser UI remains
for Phase 8D.6; numeric memory reserves remain open.
This commit is contained in:
2026-09-06 14:41:41 +02:00
parent e5dce12ed4
commit aeb2043396
37 changed files with 3651 additions and 91 deletions
+48 -3
View File
@@ -23,6 +23,7 @@
#include "user_database.h"
#include "web_security.h"
#include "web_serial_transport.h"
#include "web_admin_transport.h"
#include "web_session_store.h"
#include "web_cookie_auth.h"
#include "web_httpd_adapter.h"
@@ -39,6 +40,8 @@ static bool s_transitioning;
static bool s_serial_transport_init_attempted;
static bool s_serial_transport_initialized;
static bool s_serial_transport_attached;
/* Retained across failed stop so queued admin work cannot outlive its server. */
static bool s_admin_transport_owned;
static esp_err_t s_last_error = ESP_ERR_INVALID_STATE;
static esp_err_t s_serial_transport_error = ESP_ERR_INVALID_STATE;
static web_server_counters_t s_counters;
@@ -368,6 +371,19 @@ static const httpd_uri_t s_websocket_uri = {
.handle_ws_control_frames = false,
};
static const httpd_uri_t s_admin_ticket_uri = {
.uri = WEB_ADMIN_TICKET_URI,
.method = HTTP_POST,
.handler = web_admin_transport_ticket_handler,
};
static const httpd_uri_t s_admin_websocket_uri = {
.uri = WEB_ADMIN_WS_URI,
.method = HTTP_GET,
.handler = web_admin_transport_upgrade_handler,
.is_websocket = false, /* Cookie/Origin/ticket/console admission precedes 101. */
};
static const httpd_uri_t s_xterm_js_uri = {
.uri = "/assets/xterm.js",
.method = HTTP_GET,
@@ -497,12 +513,13 @@ esp_err_t web_server_start(void)
private_key, sizeof(private_key), &private_key_length);
if (error == ESP_OK) {
httpd_ssl_config_t config = HTTPD_SSL_CONFIG_DEFAULT();
/* Two browser terminals retain room for parallel assets and status fetches. */
/* Two serial + one admin socket leave three slots for HTTPS requests. */
config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]);
config.httpd.lru_purge_enable = true;
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 2U;
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
config.httpd.lru_purge_enable = false;
config.httpd.recv_wait_timeout = 1;
config.httpd.send_wait_timeout = 1;
config.servercert = certificate;
@@ -535,6 +552,18 @@ esp_err_t web_server_start(void)
attach_error = web_serial_transport_attach_server(server);
serial_transport_attached = attach_error == ESP_OK;
}
bool admin_transport_owned = false;
if (error == ESP_OK) {
/* Even optional route allocation failure must leave M1 available. */
esp_err_t admin_error = httpd_register_uri_handler(server, &s_admin_ticket_uri);
bool ticket_registered = admin_error == ESP_OK;
if (admin_error == ESP_OK)
admin_error = httpd_register_uri_handler(server, &s_admin_websocket_uri);
if (admin_error != ESP_OK && ticket_registered)
(void)httpd_unregister_uri_handler(server, WEB_ADMIN_TICKET_URI, HTTP_POST);
if (admin_error == ESP_OK && web_admin_transport_init() == ESP_OK)
admin_transport_owned = web_admin_transport_attach(server) == ESP_OK;
}
if (error != ESP_OK) {
web_cookie_auth_stop();
}
@@ -553,6 +582,7 @@ esp_err_t web_server_start(void)
s_last_error = error;
s_serial_transport_error = attach_error;
s_serial_transport_attached = serial_transport_attached;
s_admin_transport_owned = admin_transport_owned;
if (error == ESP_OK) {
s_server = server;
++s_counters.starts;
@@ -578,11 +608,24 @@ esp_err_t web_server_stop(void)
}
httpd_handle_t server = s_server;
bool serial_transport_attached = s_serial_transport_attached;
bool admin_transport_owned = s_admin_transport_owned;
esp_err_t serial_transport_error = s_serial_transport_error;
s_transitioning = true;
xSemaphoreGive(s_server_mutex);
web_cookie_auth_stop();
if (admin_transport_owned) {
esp_err_t detach_error = web_admin_transport_detach(server);
if (detach_error != ESP_OK) {
/* Unlike serial's broker timeout, an admin submission timeout must
* retain HTTPD until detach can fence all queue submitters. */
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
s_last_error = detach_error;
xSemaphoreGive(s_server_mutex);
return detach_error;
}
}
if (serial_transport_attached) {
esp_err_t detach_error = web_serial_transport_detach_server(server);
if (detach_error != ESP_OK && detach_error != ESP_ERR_TIMEOUT) {
@@ -597,6 +640,7 @@ esp_err_t web_server_stop(void)
}
esp_err_t error = httpd_ssl_stop(server);
if (error == ESP_OK && admin_transport_owned) web_admin_transport_stopped(server);
if (error != ESP_OK && serial_transport_attached) {
/* Stay detached: old HTTPD work may still be reading static TX storage. */
serial_transport_error = ESP_ERR_INVALID_STATE;
@@ -609,6 +653,7 @@ esp_err_t web_server_stop(void)
s_serial_transport_attached = false;
if (error == ESP_OK) {
s_server = NULL;
s_admin_transport_owned = false;
++s_counters.stops;
}
xSemaphoreGive(s_server_mutex);