Add authenticated WebSocket serial terminal - dirty commit with front-

and backend issues
This commit is contained in:
2026-08-24 19:45:36 +02:00
parent 8b5417881c
commit 5f7ea5b79d
22 changed files with 8845 additions and 45 deletions
+185 -33
View File
@@ -21,28 +21,24 @@
#include "session_broker.h"
#include "usb_cdc_transport.h"
#include "web_security.h"
#include "web_serial_transport.h"
#include "web_ui.h"
#include "wifi_manager.h"
#define WEB_SERVER_PORT 443U
#define WEB_SERVER_MAX_AUTHORIZATION 128U
#define WEB_SERVER_MAX_BASIC_DECODED 64U
#define WEB_SERVER_STATUS_JSON_CAPACITY 2304U
static const char s_index_html[] =
"<!doctype html><html lang=en><meta charset=utf-8>"
"<meta name=viewport content=\"width=device-width,initial-scale=1\">"
"<title>ESP32 Serial Swiss Army Knife</title>"
"<style>body{font:16px system-ui;max-width:54rem;margin:3rem auto;padding:0 1rem}"
"pre{background:#171717;color:#eee;padding:1rem;overflow:auto}</style>"
"<h1>ESP32 Serial Swiss Army Knife</h1>"
"<p>Authenticated HTTPS is operational. Interactive web serial access is not enabled yet.</p>"
"<p><a href=/api/status>JSON status</a></p></html>";
#define WEB_SERVER_STATUS_JSON_CAPACITY 3072U
static SemaphoreHandle_t s_server_mutex;
static httpd_handle_t s_server;
static bool s_initialized;
static bool s_transitioning;
static bool s_serial_transport_init_attempted;
static bool s_serial_transport_initialized;
static bool s_serial_transport_attached;
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;
static esp_err_t ensure_mutex(void)
@@ -167,33 +163,62 @@ static esp_err_t authorize_or_respond(httpd_req_t *request, bool *authorized)
return *authorized ? ESP_OK : send_authentication_required(request);
}
static esp_err_t root_handler(httpd_req_t *request)
static esp_err_t send_authenticated_ui(httpd_req_t *request,
web_ui_resource_t resource,
uint64_t *counter)
{
bool authorized = false;
esp_err_t error = authorize_or_respond(request, &authorized);
if (error != ESP_OK || !authorized) {
return error;
}
increment_counter(&s_counters.root_requests);
increment_counter(counter);
error = httpd_resp_set_type(request, "text/html; charset=utf-8");
if (error == ESP_OK) {
error = set_common_headers(request);
}
if (error == ESP_OK) {
error = httpd_resp_set_hdr(
request, "Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'");
}
if (error == ESP_OK) {
error = httpd_resp_send(request, s_index_html, HTTPD_RESP_USE_STRLEN);
}
error = web_ui_send_response(request, resource);
if (error != ESP_OK) {
increment_counter(&s_counters.response_errors);
}
return error;
}
static esp_err_t root_handler(httpd_req_t *request)
{
return send_authenticated_ui(request, WEB_UI_RESOURCE_INDEX,
&s_counters.root_requests);
}
static esp_err_t asset_handler(httpd_req_t *request)
{
web_ui_resource_t resource = (web_ui_resource_t)(uintptr_t)request->user_ctx;
return send_authenticated_ui(request, resource, &s_counters.asset_requests);
}
static esp_err_t ticket_handler(httpd_req_t *request)
{
bool authorized = false;
esp_err_t error = authorize_or_respond(request, &authorized);
if (error != ESP_OK || !authorized) {
return error;
}
increment_counter(&s_counters.ticket_requests);
if (request->content_len != 0U) {
return send_plain_error(request, "400 Bad Request",
"Ticket requests must have an empty body.\n");
}
error = web_serial_transport_handle_authenticated_ticket_request(request);
if (error == ESP_OK) {
return ESP_OK;
}
if (error == ESP_ERR_INVALID_ARG) {
return send_plain_error(request, "400 Bad Request",
"Invalid web-terminal ticket request.\n");
}
increment_counter(&s_counters.response_errors);
return send_plain_error(request, "503 Service Unavailable",
"Web terminal transport unavailable.\n");
}
static const char *safe_string(const char *value)
{
return value != NULL ? value : "unknown";
@@ -242,6 +267,7 @@ static esp_err_t status_handler(httpd_req_t *request)
session_broker_global_snapshot_t broker = {0};
usb_cdc_transport_snapshot_t usb = {0};
web_server_snapshot_t web = {0};
web_serial_transport_snapshot_t web_serial = {0};
web_security_certificate_metadata_t certificate = {0};
char ipv4[16] = {0};
char fingerprint[WEB_SECURITY_SHA256_LENGTH * 3U] = {0};
@@ -253,6 +279,8 @@ static esp_err_t status_handler(httpd_req_t *request)
bool broker_available = session_broker_get_global_snapshot(&broker) == ESP_OK;
bool usb_available = usb_cdc_transport_get_snapshot(&usb) == ESP_OK;
bool web_available = web_server_get_snapshot(&web) == ESP_OK;
bool web_serial_available =
web_serial_transport_get_snapshot(&web_serial) == ESP_OK;
bool certificate_available =
web_security_get_certificate_metadata(&certificate) == ESP_OK;
@@ -278,7 +306,10 @@ static esp_err_t status_handler(httpd_req_t *request)
" \"usb\":{\"available\":%s,\"attached\":%s,\"host_open\":%s,\"writer\":%s},\n"
" \"https\":{\"running\":%s,\"requests\":%" PRIu64 ","
"\"authenticated_requests\":%" PRIu64 ",\"authentication_failures\":%" PRIu64 ","
"\"certificate_sha256\":\"%s\"}\n"
"\"certificate_sha256\":\"%s\"},\n"
" \"websocket\":{\"available\":%s,\"sessions\":%" PRIu32 ","
"\"active_tickets\":%" PRIu32 ",\"rx_bytes\":%" PRIu64 ","
"\"rx_rejected\":%" PRIu64 ",\"tx_bytes\":%" PRIu64 "}\n"
"}\n",
(uint64_t)(esp_timer_get_time() / 1000),
wifi_available ? "true" : "false",
@@ -309,7 +340,16 @@ static esp_err_t status_handler(httpd_req_t *request)
web_available ? web.counters.requests : 0U,
web_available ? web.counters.authenticated_requests : 0U,
web_available ? web.counters.authentication_failures : 0U,
fingerprint);
fingerprint,
web_serial_available ? "true" : "false",
web_serial_available ? web_serial.active_sessions : 0U,
web_serial_available ? web_serial.active_tickets : 0U,
web_serial_available ? web_serial.counters.rx_ws_bytes_accepted : 0U,
web_serial_available ? web_serial.counters.rx_ws_bytes_rejected : 0U,
web_serial_available
? web_serial.counters.tx_binary_bytes +
web_serial.counters.tx_control_bytes
: 0U);
if (written < 0 || (size_t)written >= sizeof(response)) {
return send_plain_error(request, "500 Internal Server Error",
@@ -342,18 +382,93 @@ static const httpd_uri_t s_status_uri = {
.user_ctx = NULL,
};
static const httpd_uri_t s_ticket_uri = {
.uri = WEB_SERIAL_TRANSPORT_TICKET_URI,
.method = HTTP_POST,
.handler = ticket_handler,
.user_ctx = NULL,
};
static const httpd_uri_t s_websocket_uri = {
.uri = WEB_SERIAL_TRANSPORT_WS_URI,
.method = HTTP_GET,
.handler = web_serial_transport_ws_handler,
.user_ctx = NULL,
.is_websocket = true,
.handle_ws_control_frames = false,
};
static const httpd_uri_t s_xterm_js_uri = {
.uri = "/assets/xterm.js",
.method = HTTP_GET,
.handler = asset_handler,
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_XTERM_JS,
};
static const httpd_uri_t s_xterm_css_uri = {
.uri = "/assets/xterm.css",
.method = HTTP_GET,
.handler = asset_handler,
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_XTERM_CSS,
};
static const httpd_uri_t s_addon_fit_js_uri = {
.uri = "/assets/addon-fit.js",
.method = HTTP_GET,
.handler = asset_handler,
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_ADDON_FIT_JS,
};
static const httpd_uri_t s_app_js_uri = {
.uri = "/assets/app.js",
.method = HTTP_GET,
.handler = asset_handler,
.user_ctx = (void *)(uintptr_t)WEB_UI_RESOURCE_APP_JS,
};
static const httpd_uri_t *const s_uri_handlers[] = {
&s_root_uri,
&s_status_uri,
&s_ticket_uri,
&s_websocket_uri,
&s_xterm_js_uri,
&s_xterm_css_uri,
&s_addon_fit_js_uri,
&s_app_js_uri,
};
esp_err_t web_server_init(void)
{
esp_err_t error = ensure_mutex();
if (error != ESP_OK) {
return error;
}
bool initialize_serial_transport = false;
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (!s_serial_transport_init_attempted) {
s_serial_transport_init_attempted = true;
initialize_serial_transport = true;
}
xSemaphoreGive(s_server_mutex);
esp_err_t serial_transport_error = ESP_OK;
if (initialize_serial_transport) {
serial_transport_error = web_serial_transport_init();
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (initialize_serial_transport) {
s_serial_transport_error = serial_transport_error;
s_serial_transport_initialized = serial_transport_error == ESP_OK;
}
s_initialized = true;
if (s_last_error == ESP_ERR_INVALID_STATE) {
s_last_error = ESP_OK;
}
xSemaphoreGive(s_server_mutex);
/* The Phase 5A HTTPS recovery surface remains available if WebSocket setup fails. */
return ESP_OK;
}
@@ -364,12 +479,14 @@ esp_err_t web_server_start(void)
return error;
}
bool serial_transport_ready;
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
if (s_server != NULL || s_transitioning) {
xSemaphoreGive(s_server_mutex);
return ESP_ERR_INVALID_STATE;
}
s_transitioning = true;
serial_transport_ready = s_serial_transport_initialized;
xSemaphoreGive(s_server_mutex);
uint8_t certificate[WEB_SECURITY_CERTIFICATE_DER_CAPACITY] = {0};
@@ -383,9 +500,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();
config.httpd.max_open_sockets = 2;
config.httpd.max_uri_handlers = 2;
/* Two browser terminals retain room for parallel assets and status fetches. */
config.httpd.max_open_sockets = 6;
config.httpd.max_uri_handlers =
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]);
config.httpd.lru_purge_enable = true;
config.httpd.recv_wait_timeout = 1;
config.httpd.send_wait_timeout = 1;
config.servercert = certificate;
config.servercert_len = certificate_length;
config.prvtkey_pem = private_key;
@@ -397,11 +518,18 @@ esp_err_t web_server_start(void)
secure_wipe(certificate, sizeof(certificate));
secure_wipe(private_key, sizeof(private_key));
if (error == ESP_OK) {
error = httpd_register_uri_handler(server, &s_root_uri);
for (size_t index = 0U;
error == ESP_OK &&
index < sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]);
++index) {
error = httpd_register_uri_handler(server, s_uri_handlers[index]);
}
if (error == ESP_OK) {
error = httpd_register_uri_handler(server, &s_status_uri);
bool serial_transport_attached = false;
esp_err_t attach_error = s_serial_transport_error;
if (error == ESP_OK && serial_transport_ready) {
attach_error = web_serial_transport_attach_server(server);
serial_transport_attached = attach_error == ESP_OK;
}
if (error != ESP_OK && server != NULL) {
(void)httpd_ssl_stop(server);
@@ -411,6 +539,8 @@ esp_err_t web_server_start(void)
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
s_last_error = error;
s_serial_transport_error = attach_error;
s_serial_transport_attached = serial_transport_attached;
if (error == ESP_OK) {
s_server = server;
++s_counters.starts;
@@ -433,14 +563,35 @@ esp_err_t web_server_stop(void)
return ESP_ERR_INVALID_STATE;
}
httpd_handle_t server = s_server;
bool serial_transport_attached = s_serial_transport_attached;
esp_err_t serial_transport_error = s_serial_transport_error;
s_transitioning = true;
xSemaphoreGive(s_server_mutex);
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) {
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
s_last_error = detach_error;
s_serial_transport_error = detach_error;
xSemaphoreGive(s_server_mutex);
return detach_error;
}
serial_transport_error = detach_error;
}
esp_err_t error = httpd_ssl_stop(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;
}
xSemaphoreTake(s_server_mutex, portMAX_DELAY);
s_transitioning = false;
s_last_error = error;
s_serial_transport_error = serial_transport_error;
s_serial_transport_attached = false;
if (error == ESP_OK) {
s_server = NULL;
++s_counters.stops;
@@ -465,6 +616,7 @@ esp_err_t web_server_get_snapshot(web_server_snapshot_t *snapshot)
snapshot->transitioning = s_transitioning;
snapshot->port = WEB_SERVER_PORT;
snapshot->last_error = s_last_error;
snapshot->serial_transport_error = s_serial_transport_error;
snapshot->counters = s_counters;
xSemaphoreGive(s_server_mutex);
return ESP_OK;