Add authenticated WebSocket serial terminal - dirty commit with front-
and backend issues
This commit is contained in:
@@ -16,6 +16,9 @@ idf_component_register(
|
||||
"usb_cdc_transport.c"
|
||||
"usb_console.c"
|
||||
"web_security.c"
|
||||
"web_serial_transport.c"
|
||||
"web_assets_data.c"
|
||||
"web_ui.c"
|
||||
"web_server.c"
|
||||
"web_console.c"
|
||||
"wifi_config.c"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Generated web-asset data declarations. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
extern const uint8_t web_asset_xterm_js_gz[];
|
||||
extern const size_t web_asset_xterm_js_gz_size;
|
||||
|
||||
extern const uint8_t web_asset_xterm_css_gz[];
|
||||
extern const size_t web_asset_xterm_css_gz_size;
|
||||
|
||||
extern const uint8_t web_asset_addon_fit_js_gz[];
|
||||
extern const size_t web_asset_addon_fit_js_gz_size;
|
||||
|
||||
+84
-5
@@ -10,6 +10,7 @@
|
||||
#include "esp_console.h"
|
||||
#include "secure_random.h"
|
||||
#include "web_security.h"
|
||||
#include "web_serial_transport.h"
|
||||
#include "web_server.h"
|
||||
|
||||
static void print_usage(void)
|
||||
@@ -57,7 +58,37 @@ static int show_status(void)
|
||||
printf("Authentication material unavailable: %s; use 'web reset --force' to replace it.\n",
|
||||
esp_err_to_name(security_error));
|
||||
}
|
||||
printf("Endpoints: GET / and GET /api/status (authentication required)\n");
|
||||
printf("Endpoints: GET /, GET /api/status, POST /api/ws-ticket, WSS /ws/serial\n");
|
||||
|
||||
web_serial_transport_snapshot_t transport;
|
||||
esp_err_t transport_error = web_serial_transport_get_snapshot(&transport);
|
||||
if (transport_error != ESP_OK) {
|
||||
printf("WebSocket serial transport unavailable: %s\n",
|
||||
esp_err_to_name(snapshot.serial_transport_error));
|
||||
return 0;
|
||||
}
|
||||
|
||||
printf("WebSocket serial: attached=%s sessions=%" PRIu32 "/%u tickets=%" PRIu32 "\n",
|
||||
transport.server_attached ? "yes" : "no",
|
||||
transport.active_sessions,
|
||||
WEB_SERIAL_TRANSPORT_MAX_SESSIONS,
|
||||
transport.active_tickets);
|
||||
for (size_t index = 0U; index < WEB_SERIAL_TRANSPORT_MAX_SESSIONS; ++index) {
|
||||
const web_serial_transport_session_snapshot_t *session =
|
||||
&transport.sessions[index];
|
||||
if (!session->active) {
|
||||
continue;
|
||||
}
|
||||
printf(" slot=%u fd=%d generation=%" PRIu32 " broker=%" PRIu32
|
||||
" role=%s tx-pending=%s closing=%s\n",
|
||||
(unsigned int)index,
|
||||
session->socket_fd,
|
||||
session->generation,
|
||||
session->broker_client_id,
|
||||
session->writer ? "writer" : "observer",
|
||||
session->tx_pending ? "yes" : "no",
|
||||
session->close_requested ? "yes" : "no");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -76,10 +107,50 @@ static int show_counters(void)
|
||||
counter->starts, counter->start_failures, counter->stops);
|
||||
printf("Requests: total=%" PRIu64 " authenticated=%" PRIu64
|
||||
" auth-failures=%" PRIu64 " root=%" PRIu64
|
||||
" status=%" PRIu64 " response-errors=%" PRIu64 "\n",
|
||||
" status=%" PRIu64 " tickets=%" PRIu64 " assets=%" PRIu64
|
||||
" response-errors=%" PRIu64 "\n",
|
||||
counter->requests, counter->authenticated_requests,
|
||||
counter->authentication_failures, counter->root_requests,
|
||||
counter->status_requests, counter->response_errors);
|
||||
counter->status_requests, counter->ticket_requests,
|
||||
counter->asset_requests, counter->response_errors);
|
||||
|
||||
web_serial_transport_snapshot_t transport;
|
||||
error = web_serial_transport_get_snapshot(&transport);
|
||||
if (error != ESP_OK) {
|
||||
printf("WebSocket serial counters unavailable: %s\n", esp_err_to_name(error));
|
||||
return 0;
|
||||
}
|
||||
const web_serial_transport_counters_t *websocket = &transport.counters;
|
||||
printf("Tickets: issued=%" PRIu64 " consumed=%" PRIu64
|
||||
" rejected=%" PRIu64 " expired=%" PRIu64 "\n",
|
||||
websocket->tickets_issued, websocket->tickets_consumed,
|
||||
websocket->tickets_rejected, websocket->tickets_expired);
|
||||
printf("WebSocket sessions: connect=%" PRIu64 " failures=%" PRIu64
|
||||
" disconnect=%" PRIu64 " service-start-failures=%" PRIu64
|
||||
" broker-failures=%" PRIu64 "\n",
|
||||
websocket->connections, websocket->connection_failures,
|
||||
websocket->disconnections, websocket->service_start_failures,
|
||||
websocket->broker_failures);
|
||||
printf("WebSocket RX: frames-ok=%" PRIu64 " frames-rejected=%" PRIu64
|
||||
" bytes-ok=%" PRIu64 " bytes-rejected=%" PRIu64 "\n",
|
||||
websocket->rx_ws_frames_accepted,
|
||||
websocket->rx_ws_frames_rejected,
|
||||
websocket->rx_ws_bytes_accepted,
|
||||
websocket->rx_ws_bytes_rejected);
|
||||
printf("WebSocket TX: binary-frames=%" PRIu64 " binary-bytes=%" PRIu64
|
||||
" control-frames=%" PRIu64 " control-bytes=%" PRIu64 "\n",
|
||||
websocket->tx_binary_frames, websocket->tx_binary_bytes,
|
||||
websocket->tx_control_frames, websocket->tx_control_bytes);
|
||||
printf("WebSocket control: writer-requests=%" PRIu64
|
||||
" grants=%" PRIu64 " denials=%" PRIu64
|
||||
" releases=%" PRIu64 " revocations=%" PRIu64 "\n",
|
||||
websocket->writer_requests, websocket->writer_grants,
|
||||
websocket->writer_denials, websocket->writer_releases,
|
||||
websocket->writer_revocations);
|
||||
printf("WebSocket failures: send=%" PRIu64 " queue=%" PRIu64
|
||||
" protocol=%" PRIu64 " closes=%" PRIu64 "\n",
|
||||
websocket->send_failures, websocket->queue_failures,
|
||||
websocket->protocol_errors, websocket->close_requests);
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -160,7 +231,12 @@ static int rotate_credentials(void)
|
||||
return 1;
|
||||
}
|
||||
|
||||
esp_err_t revoke_error = web_serial_transport_revoke_sessions();
|
||||
printf("Web credentials rotated and persisted. Existing Basic credentials are now invalid.\n");
|
||||
if (revoke_error != ESP_OK && revoke_error != ESP_ERR_INVALID_STATE) {
|
||||
printf("Warning: existing WebSocket sessions could not be revoked: %s\n",
|
||||
esp_err_to_name(revoke_error));
|
||||
}
|
||||
printf("Username: %.*s\nPassword: %.*s\n",
|
||||
(int)credentials.username_length, credentials.username,
|
||||
(int)credentials.password_length, credentials.password);
|
||||
@@ -247,11 +323,14 @@ static int command_web(int argc, char **argv)
|
||||
}
|
||||
if (argc == 2 && strcmp(argv[1], "clear-counters") == 0) {
|
||||
esp_err_t error = web_server_clear_counters();
|
||||
if (error == ESP_OK) {
|
||||
error = web_serial_transport_clear_counters();
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
printf("Could not clear HTTPS counters: %s\n", esp_err_to_name(error));
|
||||
printf("Could not clear web counters: %s\n", esp_err_to_name(error));
|
||||
return 1;
|
||||
}
|
||||
printf("HTTPS counters cleared.\n");
|
||||
printf("HTTPS and WebSocket counters cleared.\n");
|
||||
return 0;
|
||||
}
|
||||
if (argc == 3 && strcmp(argv[1], "credentials") == 0 &&
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Authenticated, bounded WebSocket transport for the serial session broker. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "session_broker.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define WEB_SERIAL_TRANSPORT_MAX_SESSIONS 2U
|
||||
#define WEB_SERIAL_TRANSPORT_MAX_TICKETS 4U
|
||||
#define WEB_SERIAL_TRANSPORT_TICKET_LENGTH 32U
|
||||
#define WEB_SERIAL_TRANSPORT_TICKET_CAPACITY \
|
||||
(WEB_SERIAL_TRANSPORT_TICKET_LENGTH + 1U)
|
||||
#define WEB_SERIAL_TRANSPORT_TICKET_LIFETIME_SECONDS 30U
|
||||
#define WEB_SERIAL_TRANSPORT_MAX_RX_PAYLOAD 1024U
|
||||
#define WEB_SERIAL_TRANSPORT_TX_PAYLOAD_SIZE 512U
|
||||
|
||||
#define WEB_SERIAL_TRANSPORT_TICKET_URI "/api/ws-ticket"
|
||||
#define WEB_SERIAL_TRANSPORT_WS_URI "/ws/serial"
|
||||
#define WEB_SERIAL_TRANSPORT_TICKET_QUERY_KEY "ticket"
|
||||
|
||||
typedef struct {
|
||||
uint64_t tickets_issued;
|
||||
uint64_t tickets_consumed;
|
||||
uint64_t tickets_rejected;
|
||||
uint64_t tickets_expired;
|
||||
|
||||
uint64_t connections;
|
||||
uint64_t connection_failures;
|
||||
uint64_t disconnections;
|
||||
uint64_t service_start_failures;
|
||||
uint64_t broker_failures;
|
||||
|
||||
uint64_t writer_requests;
|
||||
uint64_t writer_grants;
|
||||
uint64_t writer_denials;
|
||||
uint64_t writer_releases;
|
||||
uint64_t writer_revocations;
|
||||
|
||||
uint64_t rx_ws_frames_accepted;
|
||||
uint64_t rx_ws_frames_rejected;
|
||||
uint64_t rx_ws_bytes_accepted;
|
||||
uint64_t rx_ws_bytes_rejected;
|
||||
|
||||
uint64_t tx_binary_frames;
|
||||
uint64_t tx_binary_bytes;
|
||||
uint64_t tx_control_frames;
|
||||
uint64_t tx_control_bytes;
|
||||
uint64_t send_failures;
|
||||
uint64_t queue_failures;
|
||||
uint64_t protocol_errors;
|
||||
uint64_t close_requests;
|
||||
} web_serial_transport_counters_t;
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
bool writer;
|
||||
bool tx_pending;
|
||||
bool close_requested;
|
||||
int socket_fd;
|
||||
uint32_t generation;
|
||||
session_broker_client_id_t broker_client_id;
|
||||
} web_serial_transport_session_snapshot_t;
|
||||
|
||||
typedef struct {
|
||||
bool initialized;
|
||||
bool server_attached;
|
||||
uint32_t active_sessions;
|
||||
uint32_t active_tickets;
|
||||
web_serial_transport_session_snapshot_t
|
||||
sessions[WEB_SERIAL_TRANSPORT_MAX_SESSIONS];
|
||||
web_serial_transport_counters_t counters;
|
||||
} web_serial_transport_snapshot_t;
|
||||
|
||||
/*
|
||||
* Allocate no per-session heap objects and start the permanent transport task.
|
||||
* CONFIG_HTTPD_WS_SUPPORT must be enabled. CONFIG_HTTPD_QUEUE_WORK_BLOCKING must
|
||||
* be disabled because that IDF mode can wait forever inside httpd_queue_work().
|
||||
*/
|
||||
esp_err_t web_serial_transport_init(void);
|
||||
|
||||
/* Attach after httpd start; detach as part of stopping that same server. */
|
||||
esp_err_t web_serial_transport_attach_server(httpd_handle_t server);
|
||||
esp_err_t web_serial_transport_detach_server(httpd_handle_t server);
|
||||
|
||||
/*
|
||||
* Mint a one-time bearer ticket for an already-authenticated caller. The output
|
||||
* is exactly 32 Base64URL characters plus a terminator and expires after 30
|
||||
* monotonic seconds. Never log or persist the returned value.
|
||||
*/
|
||||
esp_err_t web_serial_transport_mint_ticket(char *ticket, size_t capacity);
|
||||
|
||||
/*
|
||||
* Convenience POST response helper for /api/ws-ticket. Authentication is
|
||||
* intentionally outside this module: call this only after Basic authentication
|
||||
* has already succeeded. Register it as HTTP_POST, not as a public handler.
|
||||
*/
|
||||
esp_err_t web_serial_transport_handle_authenticated_ticket_request(
|
||||
httpd_req_t *request);
|
||||
|
||||
/*
|
||||
* Handler for /ws/serial. Register as HTTP_GET with is_websocket=true and
|
||||
* handle_ws_control_frames=false. The initial upgraded GET authenticates the
|
||||
* ticket; later invocations process one complete data frame.
|
||||
*/
|
||||
esp_err_t web_serial_transport_ws_handler(httpd_req_t *request);
|
||||
|
||||
esp_err_t web_serial_transport_get_snapshot(
|
||||
web_serial_transport_snapshot_t *snapshot);
|
||||
|
||||
/* Clearing counters does not alter tickets, sessions, ownership, or queued data. */
|
||||
esp_err_t web_serial_transport_clear_counters(void);
|
||||
|
||||
/* Invalidate outstanding tickets and close authenticated web serial sessions. */
|
||||
esp_err_t web_serial_transport_revoke_sessions(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+185
-33
@@ -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;
|
||||
|
||||
@@ -21,6 +21,8 @@ typedef struct {
|
||||
uint64_t authentication_failures;
|
||||
uint64_t root_requests;
|
||||
uint64_t status_requests;
|
||||
uint64_t ticket_requests;
|
||||
uint64_t asset_requests;
|
||||
uint64_t response_errors;
|
||||
} web_server_counters_t;
|
||||
|
||||
@@ -30,6 +32,7 @@ typedef struct {
|
||||
bool transitioning;
|
||||
uint16_t port;
|
||||
esp_err_t last_error;
|
||||
esp_err_t serial_transport_error;
|
||||
web_server_counters_t counters;
|
||||
} web_server_snapshot_t;
|
||||
|
||||
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Offline browser UI response helpers for authenticated HTTPS routes. */
|
||||
|
||||
#include "web_ui.h"
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "web_assets_data.h"
|
||||
|
||||
#define WEB_UI_DOCUMENT_CACHE_CONTROL "private, max-age=300"
|
||||
#define WEB_UI_ASSET_CACHE_CONTROL "private, max-age=604800"
|
||||
|
||||
static const char s_index_html[] =
|
||||
"<!doctype html>\n"
|
||||
"<html lang=\"en\">\n"
|
||||
"<head>\n"
|
||||
"<meta charset=\"utf-8\">\n"
|
||||
"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
|
||||
"<meta name=\"color-scheme\" content=\"dark\">\n"
|
||||
"<title>ESP32 Serial Console</title>\n"
|
||||
"<style>\n"
|
||||
":root{color-scheme:dark;--bg:#090d14;--panel:#111824;--panel-2:#161f2e;"
|
||||
"--line:#29364a;--text:#e8eef8;--muted:#91a0b5;--accent:#55c2ff;"
|
||||
"--good:#52d68b;--warn:#ffc857;--bad:#ff6b7a;--radius:14px}\n"
|
||||
"*{box-sizing:border-box}\n"
|
||||
"html,body{height:100%;margin:0}\n"
|
||||
"body{background:radial-gradient(circle at top left,#142033 0,var(--bg) 42rem);"
|
||||
"color:var(--text);font:14px/1.45 system-ui,-apple-system,BlinkMacSystemFont,"
|
||||
"\"Segoe UI\",sans-serif}\n"
|
||||
"button{font:inherit}\n"
|
||||
".page{min-height:100%;max-width:1440px;margin:auto;padding:clamp(14px,2.5vw,32px);"
|
||||
"display:grid;grid-template-rows:auto auto minmax(360px,1fr);gap:16px}\n"
|
||||
".topbar{display:flex;align-items:center;justify-content:space-between;gap:16px}\n"
|
||||
".brand{display:flex;align-items:center;gap:12px;min-width:0}\n"
|
||||
".logo{width:42px;height:42px;border:1px solid #347ba5;border-radius:12px;"
|
||||
"display:grid;place-items:center;background:#10273a;color:var(--accent);"
|
||||
"font-weight:800;letter-spacing:.06em;box-shadow:0 0 30px #1c8fc333}\n"
|
||||
"h1{font-size:clamp(18px,3vw,24px);line-height:1.15;margin:0}\n"
|
||||
".subtitle{color:var(--muted);margin:3px 0 0;font-size:13px}\n"
|
||||
".badge{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;"
|
||||
"border:1px solid var(--line);border-radius:999px;padding:6px 10px;"
|
||||
"background:#121a26;color:var(--muted);font-weight:700;font-size:12px}\n"
|
||||
".badge:before{content:\"\";width:7px;height:7px;border-radius:50%;background:currentColor;"
|
||||
"box-shadow:0 0 10px currentColor}\n"
|
||||
".badge[data-tone=good]{color:var(--good);border-color:#245a42}\n"
|
||||
".badge[data-tone=warn]{color:var(--warn);border-color:#675526}\n"
|
||||
".badge[data-tone=bad]{color:var(--bad);border-color:#64313b}\n"
|
||||
".dashboard{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(280px,.65fr);gap:16px}\n"
|
||||
".panel{background:linear-gradient(145deg,#151e2cdd,#0f1621ee);border:1px solid var(--line);"
|
||||
"border-radius:var(--radius);box-shadow:0 16px 45px #0005}\n"
|
||||
".status-grid{padding:16px;display:grid;grid-template-columns:repeat(4,minmax(105px,1fr));gap:12px}\n"
|
||||
".status-item{min-width:0;padding:10px 12px;background:#0b111b99;border:1px solid #202c3e;"
|
||||
"border-radius:10px}\n"
|
||||
".status-item.wide{grid-column:span 2}\n"
|
||||
".label{display:block;color:var(--muted);font-size:11px;font-weight:700;"
|
||||
"letter-spacing:.08em;text-transform:uppercase;margin-bottom:6px}\n"
|
||||
".value{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}\n"
|
||||
".controls{padding:16px;display:flex;flex-direction:column;justify-content:center;gap:12px}\n"
|
||||
".button-row{display:flex;flex-wrap:wrap;gap:9px}\n"
|
||||
".button{min-height:38px;border:1px solid #36506d;border-radius:9px;padding:8px 13px;"
|
||||
"background:#18263a;color:var(--text);font-weight:750;cursor:pointer;"
|
||||
"transition:background .15s,border-color .15s,transform .15s}\n"
|
||||
".button:hover:not(:disabled){background:#203652;border-color:#4b789f;transform:translateY(-1px)}\n"
|
||||
".button.primary{background:#126390;border-color:#278abd}\n"
|
||||
".button.danger{background:#512631;border-color:#81404e}\n"
|
||||
".button:disabled{cursor:not-allowed;opacity:.42}\n"
|
||||
".input-state{margin:0;color:var(--warn);font-size:13px}\n"
|
||||
".input-state[data-enabled=true]{color:var(--good)}\n"
|
||||
".connection-detail{margin:0;color:var(--muted);font-size:12px;min-height:1.45em}\n"
|
||||
".terminal-panel{min-height:0;padding:10px;display:flex;flex-direction:column;overflow:hidden}\n"
|
||||
".terminal-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;"
|
||||
"padding:1px 5px 9px;color:var(--muted);font-size:12px}\n"
|
||||
".terminal-title{color:var(--text);font-weight:750;letter-spacing:.02em}\n"
|
||||
"#terminal{flex:1;min-height:0;border-radius:9px;overflow:hidden;background:#080c12;padding:8px}\n"
|
||||
"#terminal .xterm{height:100%}\n"
|
||||
"#terminal .xterm-viewport{border-radius:7px}\n"
|
||||
"@media(max-width:850px){.dashboard{grid-template-columns:1fr}.controls{align-items:flex-start}"
|
||||
".status-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}\n"
|
||||
"@media(max-width:480px){.page{padding:10px;gap:10px}.topbar{align-items:flex-start}"
|
||||
".logo{width:36px;height:36px}.status-grid{padding:10px;gap:8px}"
|
||||
".status-item{padding:9px}.controls{padding:12px}.terminal-panel{padding:7px}"
|
||||
".button-row{display:grid;grid-template-columns:1fr 1fr;width:100%}"
|
||||
".button:last-child{grid-column:1/-1}.page{grid-template-rows:auto auto minmax(420px,1fr)}}\n"
|
||||
"</style>\n"
|
||||
"<link rel=\"stylesheet\" href=\"/assets/xterm.css\">\n"
|
||||
"<script defer src=\"/assets/xterm.js\"></script>\n"
|
||||
"<script defer src=\"/assets/addon-fit.js\"></script>\n"
|
||||
"<script defer src=\"/assets/app.js\"></script>\n"
|
||||
"</head>\n"
|
||||
"<body>\n"
|
||||
"<main class=\"page\">\n"
|
||||
"<header class=\"topbar\">\n"
|
||||
"<div class=\"brand\"><div class=\"logo\" aria-hidden=\"true\">SAK</div><div>"
|
||||
"<h1>ESP32 Serial Console</h1><p class=\"subtitle\">Secure local serial workspace</p></div></div>\n"
|
||||
"<span id=\"connection-status\" class=\"badge\" data-tone=\"warn\">Connecting</span>\n"
|
||||
"</header>\n"
|
||||
"<section class=\"dashboard\" aria-label=\"Connection and device status\">\n"
|
||||
"<div class=\"panel status-grid\">\n"
|
||||
"<div class=\"status-item\"><span class=\"label\">Role</span>"
|
||||
"<span id=\"role-status\" class=\"badge\" data-tone=\"warn\">Observer</span></div>\n"
|
||||
"<div class=\"status-item\"><span class=\"label\">Broker client</span>"
|
||||
"<span id=\"client-id\" class=\"value\">—</span></div>\n"
|
||||
"<div class=\"status-item\"><span class=\"label\">Active writer</span>"
|
||||
"<span id=\"writer-id\" class=\"value\">None</span></div>\n"
|
||||
"<div class=\"status-item\"><span class=\"label\">Broker clients</span>"
|
||||
"<span id=\"broker-clients\" class=\"value\">—</span></div>\n"
|
||||
"<div class=\"status-item wide\"><span class=\"label\">Wi-Fi</span>"
|
||||
"<span id=\"wifi-summary\" class=\"value\">Loading…</span></div>\n"
|
||||
"<div class=\"status-item wide\"><span class=\"label\">Serial</span>"
|
||||
"<span id=\"serial-summary\" class=\"value\">Loading…</span></div>\n"
|
||||
"</div>\n"
|
||||
"<div class=\"panel controls\">\n"
|
||||
"<div class=\"button-row\">\n"
|
||||
"<button id=\"request-control\" class=\"button primary\" type=\"button\" disabled>Request control</button>\n"
|
||||
"<button id=\"release-control\" class=\"button danger\" type=\"button\" disabled>Release control</button>\n"
|
||||
"<button id=\"reconnect\" class=\"button\" type=\"button\">Reconnect</button>\n"
|
||||
"</div>\n"
|
||||
"<p id=\"input-state\" class=\"input-state\" data-enabled=\"false\" aria-live=\"polite\">"
|
||||
"Observer mode — terminal input is disabled.</p>\n"
|
||||
"<p id=\"connection-detail\" class=\"connection-detail\" aria-live=\"polite\">"
|
||||
"Requesting a one-time connection ticket…</p>\n"
|
||||
"</div>\n"
|
||||
"</section>\n"
|
||||
"<section class=\"panel terminal-panel\" aria-label=\"Serial terminal\">\n"
|
||||
"<div class=\"terminal-toolbar\"><span class=\"terminal-title\">Live serial stream</span>"
|
||||
"<span>Binary, unmodified device output</span></div>\n"
|
||||
"<div id=\"terminal\"></div>\n"
|
||||
"</section>\n"
|
||||
"</main>\n"
|
||||
"</body>\n"
|
||||
"</html>\n";
|
||||
|
||||
static const char s_app_js[] =
|
||||
"(() => {\n"
|
||||
"'use strict';\n"
|
||||
"const element = (id) => document.getElementById(id);\n"
|
||||
"const connectionStatus = element('connection-status');\n"
|
||||
"const roleStatus = element('role-status');\n"
|
||||
"const clientIdField = element('client-id');\n"
|
||||
"const writerIdField = element('writer-id');\n"
|
||||
"const brokerClientsField = element('broker-clients');\n"
|
||||
"const wifiSummary = element('wifi-summary');\n"
|
||||
"const serialSummary = element('serial-summary');\n"
|
||||
"const inputState = element('input-state');\n"
|
||||
"const connectionDetail = element('connection-detail');\n"
|
||||
"const requestControl = element('request-control');\n"
|
||||
"const releaseControl = element('release-control');\n"
|
||||
"const reconnectButton = element('reconnect');\n"
|
||||
"const terminalHost = element('terminal');\n"
|
||||
"const terminal = new Terminal({\n"
|
||||
" allowProposedApi: false, convertEol: false, cursorBlink: true, disableStdin: true,\n"
|
||||
" fontFamily: '\"SFMono-Regular\",Consolas,\"Liberation Mono\",monospace',\n"
|
||||
" fontSize: 14, scrollback: 5000, theme: {\n"
|
||||
" background: '#080c12', foreground: '#dce7f5', cursor: '#55c2ff',\n"
|
||||
" selectionBackground: '#285173', black: '#18202b', red: '#ff6b7a',\n"
|
||||
" green: '#52d68b', yellow: '#ffc857', blue: '#55aaff',\n"
|
||||
" magenta: '#c792ea', cyan: '#55d6be', white: '#e8eef8'\n"
|
||||
" }\n"
|
||||
"});\n"
|
||||
"const fitAddon = new FitAddon.FitAddon();\n"
|
||||
"terminal.loadAddon(fitAddon);\n"
|
||||
"terminal.open(terminalHost);\n"
|
||||
"const encoder = new TextEncoder();\n"
|
||||
"let socket = null;\n"
|
||||
"let ticketAbort = null;\n"
|
||||
"let reconnectTimer = null;\n"
|
||||
"let reconnectDelay = 1000;\n"
|
||||
"let connectionGeneration = 0;\n"
|
||||
"let role = 'observer';\n"
|
||||
"let clientId = null;\n"
|
||||
"let writerId = 0;\n"
|
||||
"let unloading = false;\n"
|
||||
"let fitFrame = 0;\n"
|
||||
"let statusInFlight = false;\n"
|
||||
"let statusTimer = null;\n"
|
||||
"const setBadge = (target, text, tone) => {\n"
|
||||
" target.textContent = text;\n"
|
||||
" target.dataset.tone = tone;\n"
|
||||
"};\n"
|
||||
"const validId = (value) => Number.isSafeInteger(value) && value >= 0;\n"
|
||||
"const displayId = (value, noneText) => validId(value) && value !== 0 ? String(value) : noneText;\n"
|
||||
"const socketOpen = () => socket !== null && socket.readyState === WebSocket.OPEN;\n"
|
||||
"const updateControls = () => {\n"
|
||||
" const writer = role === 'writer';\n"
|
||||
" terminal.options.disableStdin = !writer;\n"
|
||||
" requestControl.disabled = !socketOpen() || writer;\n"
|
||||
" releaseControl.disabled = !socketOpen() || !writer;\n"
|
||||
" inputState.dataset.enabled = writer ? 'true' : 'false';\n"
|
||||
" inputState.textContent = writer\n"
|
||||
" ? 'Writer mode — terminal input is enabled.'\n"
|
||||
" : 'Observer mode — terminal input is disabled.';\n"
|
||||
" setBadge(roleStatus, writer ? 'Writer' : 'Observer', writer ? 'good' : 'warn');\n"
|
||||
"};\n"
|
||||
"const setRole = (nextRole) => {\n"
|
||||
" role = nextRole === 'writer' ? 'writer' : 'observer';\n"
|
||||
" updateControls();\n"
|
||||
"};\n"
|
||||
"const setConnection = (text, tone, detail) => {\n"
|
||||
" setBadge(connectionStatus, text, tone);\n"
|
||||
" connectionDetail.textContent = detail;\n"
|
||||
" updateControls();\n"
|
||||
"};\n"
|
||||
"const clearReconnectTimer = () => {\n"
|
||||
" if (reconnectTimer !== null) {\n"
|
||||
" window.clearTimeout(reconnectTimer);\n"
|
||||
" reconnectTimer = null;\n"
|
||||
" }\n"
|
||||
"};\n"
|
||||
"const scheduleReconnect = () => {\n"
|
||||
" if (unloading || reconnectTimer !== null) return;\n"
|
||||
" const delay = reconnectDelay;\n"
|
||||
" reconnectDelay = Math.min(reconnectDelay * 2, 10000);\n"
|
||||
" setConnection('Disconnected', 'bad', `Reconnecting in ${Math.ceil(delay / 1000)} second(s)…`);\n"
|
||||
" reconnectTimer = window.setTimeout(() => {\n"
|
||||
" reconnectTimer = null;\n"
|
||||
" connect();\n"
|
||||
" }, delay);\n"
|
||||
"};\n"
|
||||
"const acceptBrokerMessage = (message) => {\n"
|
||||
" if (message === null || typeof message !== 'object') return;\n"
|
||||
" if (message.type !== 'hello' && message.type !== 'writer') return;\n"
|
||||
" if (message.role !== 'writer' && message.role !== 'observer') return;\n"
|
||||
" if (!validId(message.writerId)) return;\n"
|
||||
" if (message.type === 'hello') {\n"
|
||||
" if (!validId(message.clientId) || message.clientId === 0) return;\n"
|
||||
" clientId = message.clientId;\n"
|
||||
" clientIdField.textContent = String(clientId);\n"
|
||||
" reconnectDelay = 1000;\n"
|
||||
" }\n"
|
||||
" writerId = message.writerId;\n"
|
||||
" writerIdField.textContent = displayId(writerId, 'None');\n"
|
||||
" setRole(message.role);\n"
|
||||
"};\n"
|
||||
"const handleSocketMessage = (event) => {\n"
|
||||
" if (typeof event.data === 'string') {\n"
|
||||
" try {\n"
|
||||
" acceptBrokerMessage(JSON.parse(event.data));\n"
|
||||
" } catch (_) {\n"
|
||||
" setConnection('Protocol error', 'bad', 'The device sent an invalid control message.');\n"
|
||||
" }\n"
|
||||
" return;\n"
|
||||
" }\n"
|
||||
" if (event.data instanceof ArrayBuffer) {\n"
|
||||
" terminal.write(new Uint8Array(event.data));\n"
|
||||
" }\n"
|
||||
"};\n"
|
||||
"async function requestTicket(signal) {\n"
|
||||
" const response = await fetch('/api/ws-ticket', {\n"
|
||||
" method: 'POST', credentials: 'same-origin', cache: 'no-store', signal\n"
|
||||
" });\n"
|
||||
" if (!response.ok) throw new Error('ticket request failed');\n"
|
||||
" const payload = await response.json();\n"
|
||||
" if (payload === null || typeof payload !== 'object' ||\n"
|
||||
" typeof payload.ticket !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(payload.ticket)) {\n"
|
||||
" throw new Error('invalid ticket response');\n"
|
||||
" }\n"
|
||||
" const ticket = payload.ticket;\n"
|
||||
" payload.ticket = '';\n"
|
||||
" return ticket;\n"
|
||||
"}\n"
|
||||
"async function connect() {\n"
|
||||
" if (unloading) return;\n"
|
||||
" clearReconnectTimer();\n"
|
||||
" const generation = ++connectionGeneration;\n"
|
||||
" if (ticketAbort !== null) ticketAbort.abort();\n"
|
||||
" ticketAbort = new AbortController();\n"
|
||||
" if (socket !== null) {\n"
|
||||
" const previous = socket;\n"
|
||||
" socket = null;\n"
|
||||
" previous.close();\n"
|
||||
" }\n"
|
||||
" clientId = null;\n"
|
||||
" clientIdField.textContent = '—';\n"
|
||||
" setRole('observer');\n"
|
||||
" setConnection('Connecting', 'warn', 'Requesting a one-time connection ticket…');\n"
|
||||
" try {\n"
|
||||
" const ticket = await requestTicket(ticketAbort.signal);\n"
|
||||
" if (unloading || generation !== connectionGeneration) return;\n"
|
||||
" ticketAbort = null;\n"
|
||||
" const url = new URL('/ws/serial', window.location.origin);\n"
|
||||
" url.protocol = 'wss:';\n"
|
||||
" url.searchParams.set('ticket', ticket);\n"
|
||||
" const nextSocket = new WebSocket(url.toString());\n"
|
||||
" url.search = '';\n"
|
||||
" nextSocket.binaryType = 'arraybuffer';\n"
|
||||
" socket = nextSocket;\n"
|
||||
" nextSocket.addEventListener('open', () => {\n"
|
||||
" if (socket !== nextSocket) return;\n"
|
||||
" setConnection('Connected', 'good', 'Connected; waiting for broker role information.');\n"
|
||||
" });\n"
|
||||
" nextSocket.addEventListener('message', (event) => {\n"
|
||||
" if (socket === nextSocket) handleSocketMessage(event);\n"
|
||||
" });\n"
|
||||
" nextSocket.addEventListener('error', () => {\n"
|
||||
" if (socket === nextSocket) {\n"
|
||||
" setConnection('Connection error', 'bad', 'The WebSocket connection failed.');\n"
|
||||
" }\n"
|
||||
" });\n"
|
||||
" nextSocket.addEventListener('close', () => {\n"
|
||||
" if (socket !== nextSocket) return;\n"
|
||||
" socket = null;\n"
|
||||
" clientId = null;\n"
|
||||
" clientIdField.textContent = '—';\n"
|
||||
" setRole('observer');\n"
|
||||
" scheduleReconnect();\n"
|
||||
" });\n"
|
||||
" } catch (error) {\n"
|
||||
" if (generation !== connectionGeneration || unloading || error.name === 'AbortError') return;\n"
|
||||
" ticketAbort = null;\n"
|
||||
" scheduleReconnect();\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"terminal.onData((data) => {\n"
|
||||
" if (role !== 'writer' || !socketOpen()) return;\n"
|
||||
" const bytes = encoder.encode(data);\n"
|
||||
" for (let offset = 0; offset < bytes.length; offset += 1024) {\n"
|
||||
" socket.send(bytes.subarray(offset, Math.min(offset + 1024, bytes.length)));\n"
|
||||
" }\n"
|
||||
"});\n"
|
||||
"requestControl.addEventListener('click', () => {\n"
|
||||
" if (role !== 'writer' && socketOpen()) socket.send('request-writer');\n"
|
||||
"});\n"
|
||||
"releaseControl.addEventListener('click', () => {\n"
|
||||
" if (role === 'writer' && socketOpen()) socket.send('release-writer');\n"
|
||||
"});\n"
|
||||
"reconnectButton.addEventListener('click', () => {\n"
|
||||
" reconnectDelay = 1000;\n"
|
||||
" connect();\n"
|
||||
"});\n"
|
||||
"const scheduleFit = () => {\n"
|
||||
" if (fitFrame !== 0) return;\n"
|
||||
" fitFrame = window.requestAnimationFrame(() => {\n"
|
||||
" fitFrame = 0;\n"
|
||||
" try { fitAddon.fit(); } catch (_) {}\n"
|
||||
" });\n"
|
||||
"};\n"
|
||||
"if ('ResizeObserver' in window) new ResizeObserver(scheduleFit).observe(terminalHost);\n"
|
||||
"window.addEventListener('resize', scheduleFit);\n"
|
||||
"const textValue = (value, fallback) => typeof value === 'string' && value.length > 0 ? value : fallback;\n"
|
||||
"const updateStatus = (status) => {\n"
|
||||
" const wifi = status !== null && typeof status === 'object' ? status.wifi : null;\n"
|
||||
" if (wifi && wifi.available) {\n"
|
||||
" const parts = [textValue(wifi.state, 'unknown')];\n"
|
||||
" if (typeof wifi.sta_ipv4 === 'string' && wifi.sta_ipv4 !== '0.0.0.0') parts.push(wifi.sta_ipv4);\n"
|
||||
" if (Number.isFinite(wifi.rssi)) parts.push(`${wifi.rssi} dBm`);\n"
|
||||
" if (Number.isFinite(wifi.channel) && wifi.channel > 0) parts.push(`channel ${wifi.channel}`);\n"
|
||||
" if (wifi.ap_running && Number.isFinite(wifi.ap_clients)) parts.push(`AP clients ${wifi.ap_clients}`);\n"
|
||||
" wifiSummary.textContent = parts.join(' · ');\n"
|
||||
" } else {\n"
|
||||
" wifiSummary.textContent = 'Unavailable';\n"
|
||||
" }\n"
|
||||
" const serial = status !== null && typeof status === 'object' ? status.serial : null;\n"
|
||||
" if (serial && typeof serial === 'object') {\n"
|
||||
" const parts = [serial.running ? 'Running' : 'Stopped'];\n"
|
||||
" if (serial.config_available) {\n"
|
||||
" if (Number.isFinite(serial.baud)) parts.push(`${serial.baud} baud`);\n"
|
||||
" parts.push(`${textValue(serial.data_bits, '?')} data`);\n"
|
||||
" parts.push(`${textValue(serial.parity, '?')} parity`);\n"
|
||||
" parts.push(`${textValue(serial.stop_bits, '?')} stop`);\n"
|
||||
" parts.push(`${textValue(serial.flow, '?')} flow`);\n"
|
||||
" }\n"
|
||||
" serialSummary.textContent = parts.join(' · ');\n"
|
||||
" } else {\n"
|
||||
" serialSummary.textContent = 'Unavailable';\n"
|
||||
" }\n"
|
||||
" const broker = status !== null && typeof status === 'object' ? status.broker : null;\n"
|
||||
" if (broker && broker.available) {\n"
|
||||
" brokerClientsField.textContent = validId(broker.clients) ? String(broker.clients) : '—';\n"
|
||||
" if (validId(broker.writer)) {\n"
|
||||
" writerId = broker.writer;\n"
|
||||
" writerIdField.textContent = displayId(writerId, 'None');\n"
|
||||
" }\n"
|
||||
" } else {\n"
|
||||
" brokerClientsField.textContent = '—';\n"
|
||||
" }\n"
|
||||
"};\n"
|
||||
"async function pollStatus() {\n"
|
||||
" if (unloading || statusInFlight) return;\n"
|
||||
" statusInFlight = true;\n"
|
||||
" try {\n"
|
||||
" const response = await fetch('/api/status', {credentials: 'same-origin', cache: 'no-store'});\n"
|
||||
" if (!response.ok) throw new Error('status request failed');\n"
|
||||
" updateStatus(await response.json());\n"
|
||||
" } catch (_) {\n"
|
||||
" wifiSummary.textContent = 'Unavailable';\n"
|
||||
" serialSummary.textContent = 'Unavailable';\n"
|
||||
" brokerClientsField.textContent = '—';\n"
|
||||
" } finally {\n"
|
||||
" statusInFlight = false;\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"const shutdown = () => {\n"
|
||||
" if (unloading) return;\n"
|
||||
" unloading = true;\n"
|
||||
" ++connectionGeneration;\n"
|
||||
" clearReconnectTimer();\n"
|
||||
" if (statusTimer !== null) window.clearInterval(statusTimer);\n"
|
||||
" if (ticketAbort !== null) ticketAbort.abort();\n"
|
||||
" if (socket !== null) socket.close();\n"
|
||||
" socket = null;\n"
|
||||
"};\n"
|
||||
"window.addEventListener('pagehide', shutdown, {once: true});\n"
|
||||
"updateControls();\n"
|
||||
"scheduleFit();\n"
|
||||
"pollStatus();\n"
|
||||
"statusTimer = window.setInterval(pollStatus, 5000);\n"
|
||||
"connect();\n"
|
||||
"})();\n";
|
||||
|
||||
typedef struct {
|
||||
const char *content_type;
|
||||
const char *cache_control;
|
||||
const char *content_encoding;
|
||||
const uint8_t *data;
|
||||
size_t length;
|
||||
bool content_security_policy;
|
||||
} web_ui_response_t;
|
||||
|
||||
static esp_err_t describe_resource(web_ui_resource_t resource,
|
||||
web_ui_response_t *response)
|
||||
{
|
||||
response->cache_control = WEB_UI_ASSET_CACHE_CONTROL;
|
||||
response->content_encoding = NULL;
|
||||
response->content_security_policy = false;
|
||||
|
||||
switch (resource) {
|
||||
case WEB_UI_RESOURCE_INDEX:
|
||||
response->content_type = "text/html; charset=utf-8";
|
||||
response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL;
|
||||
response->data = (const uint8_t *)s_index_html;
|
||||
response->length = sizeof(s_index_html) - 1U;
|
||||
response->content_security_policy = true;
|
||||
return ESP_OK;
|
||||
case WEB_UI_RESOURCE_XTERM_JS:
|
||||
response->content_type = "text/javascript; charset=utf-8";
|
||||
response->content_encoding = "gzip";
|
||||
response->data = web_asset_xterm_js_gz;
|
||||
response->length = web_asset_xterm_js_gz_size;
|
||||
return ESP_OK;
|
||||
case WEB_UI_RESOURCE_XTERM_CSS:
|
||||
response->content_type = "text/css; charset=utf-8";
|
||||
response->content_encoding = "gzip";
|
||||
response->data = web_asset_xterm_css_gz;
|
||||
response->length = web_asset_xterm_css_gz_size;
|
||||
return ESP_OK;
|
||||
case WEB_UI_RESOURCE_ADDON_FIT_JS:
|
||||
response->content_type = "text/javascript; charset=utf-8";
|
||||
response->content_encoding = "gzip";
|
||||
response->data = web_asset_addon_fit_js_gz;
|
||||
response->length = web_asset_addon_fit_js_gz_size;
|
||||
return ESP_OK;
|
||||
case WEB_UI_RESOURCE_APP_JS:
|
||||
response->content_type = "text/javascript; charset=utf-8";
|
||||
response->cache_control = WEB_UI_DOCUMENT_CACHE_CONTROL;
|
||||
response->data = (const uint8_t *)s_app_js;
|
||||
response->length = sizeof(s_app_js) - 1U;
|
||||
return ESP_OK;
|
||||
default:
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
}
|
||||
|
||||
static esp_err_t set_response_headers(httpd_req_t *request,
|
||||
const web_ui_response_t *response)
|
||||
{
|
||||
esp_err_t result = httpd_resp_set_type(request, response->content_type);
|
||||
if (result == ESP_OK) {
|
||||
result = httpd_resp_set_hdr(request, "Cache-Control",
|
||||
response->cache_control);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = httpd_resp_set_hdr(request, "X-Content-Type-Options",
|
||||
"nosniff");
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
}
|
||||
if (result == ESP_OK && response->content_encoding != NULL) {
|
||||
result = httpd_resp_set_hdr(request, "Content-Encoding",
|
||||
response->content_encoding);
|
||||
}
|
||||
if (result == ESP_OK && response->content_security_policy) {
|
||||
result = httpd_resp_set_hdr(
|
||||
request, "Content-Security-Policy",
|
||||
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
||||
"connect-src 'self'; base-uri 'none'; form-action 'none'; "
|
||||
"frame-ancestors 'none'");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
esp_err_t web_ui_send_response(httpd_req_t *request,
|
||||
web_ui_resource_t resource)
|
||||
{
|
||||
if (request == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
web_ui_response_t response = {0};
|
||||
esp_err_t result = describe_resource(resource, &response);
|
||||
if (result == ESP_OK) {
|
||||
result = set_response_headers(request, &response);
|
||||
}
|
||||
if (result == ESP_OK) {
|
||||
result = httpd_resp_send(request, (const char *)response.data,
|
||||
(ssize_t)response.length);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
/* Offline browser UI response helpers for authenticated HTTPS routes. */
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef enum {
|
||||
WEB_UI_RESOURCE_INDEX = 0,
|
||||
WEB_UI_RESOURCE_XTERM_JS,
|
||||
WEB_UI_RESOURCE_XTERM_CSS,
|
||||
WEB_UI_RESOURCE_ADDON_FIT_JS,
|
||||
WEB_UI_RESOURCE_APP_JS,
|
||||
} web_ui_resource_t;
|
||||
|
||||
/*
|
||||
* Send one UI resource after the caller has authenticated the request.
|
||||
* This module deliberately performs no authentication or URI dispatch.
|
||||
*/
|
||||
esp_err_t web_ui_send_response(httpd_req_t *request,
|
||||
web_ui_resource_t resource);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user